MySQL / InnoDB 存储引擎系列 · 第七篇

同一个复合索引,换一下查询条件的列,为什么直接从"能用"变"完全用不上"?

第一篇讲过:B+树的叶子是按索引列排好序、串成一条链的。这篇把这句话用在复合索引上——索引 (a, b, c) 的叶子是按 a, b, c 依次排序的,不是三个独立的索引凑在一起。这个顺序决定了:从最左边的列开始、连续地给条件,索引才能真正帮上忙;跳过第一列直接查后面的列,索引基本上就是摆设。

下面全部是本机真实建表、真实 EXPLAIN 跑出来的结果。

4 → 8 → 12
真实测出的 key_len(字节),对应用上 1/2/3 个索引列
ALL
跳过第一列查询时,真实 EXPLAIN 里的访问类型——索引完全没用上

复合索引是"一棵树",不是"三个索引"

建一个 KEY idx_abc (a,b,c),物理上只有一棵 B+树,叶子节点按 (a,b,c) 这个元组整体排序——先比 a,a 相等再比 b,b 也相等才比 c。

按元组排序

(1,5) 排在 (2,1) 前面

哪怕 5 > 1,只要 a 那一位 1<2,整个元组就排在前面——这和字典排序完全一样,先比第一个字,后面的字再重要也没用。

最左前缀

只能从头开始"够得着"

要利用这种排序做范围收缩,查询条件必须从 a 开始、连续地给,中间不能空——空了一列,后面的列在排好序的意义上就是"随机打散"的。

store_length

索引用了几个列,数字说了算

MySQL 每个索引列在源码里都有一个 store_length(占用字节数),用了几个列,EXPLAIN 里的 key_len 就是这些字节数的和——不用猜,数字直接告诉你。

sql/key.h · mysql-server @ mysql-9.7.1, L57 class KEY_PART_INFO { /* Info about a key part */ public: Field *field; ... /* Length of key part in bytes, excluding NULL flag and length bytes */ uint16 length; /* Number of bytes required to store the keypart value ... */ uint16 store_length; ... };

真实实测:同一张表,六种查询条件

t(a,b,c,d),20 万行,复合索引 idx_abc (a,b,c),全部是 INT NOT NULL(每列 4 字节,不需要额外的 NULL 标记字节)。

查询typekey_lenExtra是否用上索引narrow范围
WHERE a=5ref4是(1 列)
WHERE a=5 AND b=10ref8是(2 列)
WHERE a=5 AND b=10 AND c=100ref12是(3 列,全用上)
WHERE a=5 AND c=100(跳过 b)ref4Using index condition否,c 只能靠 ICP 过滤,不能收缩范围
WHERE b=10(不带 a)ALLNULLUsing where否,索引完全没用上,全表扫描

第四行最能说明问题:条件里明明写了 ac 两个索引列,但 key_len 还是只有 4——只有 a 真正参与了缩小索引扫描范围。c 能不能帮上忙,靠的是另一个机制:索引条件下推(ICP)——在 a=5 圈定的这 2000 行索引记录里,顺便把 c=100 也判断一遍,省下几趟不必要的回表,但没办法真正把索引扫描范围收窄到"只查 c=100 相关的记录"——因为在 a=5 内部,记录是按 b 排的,c 值是打散的。

第五行更直接:b 不是索引的第一列,单独拿 b 做条件,idx_abc 直接不在候选索引里——type=ALL,全表扫描 199,920 行。

这不是"MySQL 不够聪明"。B+树的叶子只有一种物理排列方式(按 a、再按 b、再按 c),一旦确定了这个顺序,"能不能利用排序快速缩小范围"这件事就是纯粹的物理约束,不是优化器可以绕过去的。

演示:为什么跳过第一列,索引就"扫描不动"

用一棵玩具 B+树(键是 (a,b) 元组)直观看这件事:同样是"查一个值",从最左边的 a 开始查,和跳过 a 直接查 b,访问的叶子数量差多少。

选择查询方式

玩具树一共 5 个叶子。WHERE a=2 能先用二分找到第一个可能匹配的叶子,然后只沿着链表往右走,一旦某个叶子里再也找不到 a=2 就立刻停下——因为排序保证了后面不会再出现;WHERE b=5 没有这种"停下来"的依据,b=5 的记录可能出现在任意一个叶子里,只能把 5 个叶子全部看一遍。

顺带的好处:排序也能"免费"拿到

上一节的排序性质不只是帮着缩小扫描范围,还能省掉一次显式排序。

查询Extra
WHERE a=5 ORDER BY b(无 filesort——叶子里 a=5 的记录本来就按 b 排好了)
WHERE a=5 ORDER BY cUsing filesort
SELECT a,b,c WHERE a=5Using index(覆盖索引,连聚簇索引都不用碰——第二篇的话题)

ORDER BY b 不需要额外排序,因为 a=5 圈定的这段叶子记录,本来就是按 b 排好的——这是索引结构的副产品,不用额外花代价。但 ORDER BY c 就不行了:固定 a 之后,b 还在变化,c 只在"a 和 b 都固定"的情况下才有序,所以还是得老老实实排一次序。

怎么给复合索引排列顺序

结合前面几篇的内容,一个实用的排列思路:

1. 等值查询的列放前面——能作为"精确匹配"的列,排列顺序随便调整都不影响是否走索引,优先把选择性最高(能过滤掉最多行)的放最前。

2. 需要范围查询(BETWEEN>)或排序的列放在等值列后面、其余列前面——一旦遇到范围条件,后面的列就没法再用来收缩索引范围了(这点本文没有展开实验,是最左前缀规则的直接推论:范围条件之后,记录已经不再按后续列整体有序)。

3. 如果高频查询要 SELECT 某几个字段,考虑把它们也纳入索引——换来覆盖索引,彻底跳过回表(第二篇)。

参考与说明

  • 所有 EXPLAIN 结果均在本机真实运行的 MySQL 9.7.1 上,针对同一张 20 万行的表(a=n%100, b=n%500, c=n%5000,已 ANALYZE TABLE)真实执行,未做任何删改。
  • KEY_PART_INFO 的引用取自 mysql/mysql-server 仓库 mysql-9.7.1 标签(commit a26ea1a2)的 sql/key.h,与本机安装的 MySQL 9.7.1 完全一致;这是 SQL 层(优化器)的代码,和本系列此前引用的 storage/innobase/ 存储引擎层代码是两个不同的层次——最左前缀规则本身是索引物理结构决定的,但"要不要用、怎么用"的判断发生在优化器这一层。
  • 演示动画的玩具 B+树延续第一篇的实现和规则(阶为 4),最左前缀扫描逻辑(二分定位 + 沿链表走到匹配结束为止)是真实"ref 访问"策略的简化建模,用于可视化,不是逐行照抄优化器源码。
  • 没有涉及:范围条件之后的列为什么不能再用于索引收缩(只给出了结论,没有单独实验验证)、多个候选索引之间优化器如何用代价模型选择、索引合并(index merge)。
☕ 如果这篇文章帮到你,可以请作者喝杯咖啡 · 爱发电