联合索引(也叫复合索引)就是把多个列组合在一起建成一个索引。很多开发者知道“联合索引要遵循最左前缀原则”,但容易停留在死记规则的层面,一旦遇到“为什么这个查询用不到索引”时就卡住了。只有理解了联合索引在 B+ 树里的真实存储结构,才能彻底吃透最左前缀原则。
联合索引在 B+ 树中是怎么存的
假设有一张用户表 users,我们建了一个联合索引:
CREATE INDEX idx_name_age ON users(name, age);
在单列索引的 B+ 树里,非叶子节点的键就是索引列的值,叶子节点存放键和对应的主键值。联合索引的特别之处在于:索引键不是一个单一的值,而是一个由多个列值拼接而成的“组合键”。
对于 idx_name_age,InnoDB 会按先 name 再 age 的顺序对数据进行排序存储。也就是说:
- 首先按
name列排序,name相同再按age排序。 - 在 B+ 树的每个节点中,相邻的索引记录都是先比较
name,name相等再比较age,以此决定先后顺序。
例如表中有这几行数据:
| id | name | age |
|----|------|-----|
| 1 | Alice| 25 |
| 2 | Alice| 30 |
| 3 | Bob | 22 |
| 4 | Bob | 28 |
那么在联合索引的叶子节点中,索引记录的排列顺序会是:
(Alice, 25) -> (Alice, 30) -> (Bob, 22) -> (Bob, 28)
这个顺序至关重要,因为它决定了索引能够支持哪些查询条件。
最左前缀原则的本质
所谓最左前缀原则,是指只有当查询条件中包含了联合索引最左边开始的连续列时,索引才能被有效利用。这不是数据库故意设下的规矩,而是由 B+ 树的排序方式天然决定的。
还是用 (name, age) 这个联合索引来举例:
- 可以用到索引:
WHERE name = 'Alice'—— 等值匹配最左列,可以快速在 B+ 树中定位到(Alice, *)的起始范围。WHERE name = 'Alice' AND age = 25—— 两个列都等值匹配,索引定位更精确,直接锁定叶子节点中的具体记录。WHERE name = 'Alice' AND age > 20——name等值确定一个块,然后按age排序在块内进行范围扫描。
- 无法用到索引或只能用一部分:
WHERE age = 25—— 缺少最左列name。因为索引整体是按name排序的,整个 B+ 树中age=25的记录分散在不同name的区间里,就像一本按姓氏排序的电话本,你想找所有年龄为 25 岁的人,索引帮不了你,只能全表扫描。WHERE name > 'Alice' AND age = 25—— 索引中name是范围查询,age列虽然在索引中,但因为name不是等值,导致age在跨name时并不可控。实际上优化器只会使用索引对name进行范围过滤,然后再在结果集上逐行判断age。
这就是为什么联合索引的列顺序设计如此重要。建立 (name, age) 这个索引后,就相当于你同时免费获得了 (name) 的索引,因为数据已经按照 name 排序。但你并没有获得 (age) 的单独索引,除非你另外再建一个。
不止等值:范围条件的截断效应
最左前缀原则在实际使用中有一个非常容易踩坑的地方:一旦最左匹配链条中出现了范围条件,后面就更进一步的条件通常无法继续使用索引进行定位。
举例来说,假设有一个联合索引 (a, b, c),查询条件是 WHERE a = 1 AND b > 10 AND c = 5。
- 对于条件
a = 1和b > 10,索引可以发挥作用:在 B+ 树中先定位到a=1的区间,然后在这个区间内按b的排序继续排除b <= 10的部分。 - 但对于
c = 5,由于b已经是范围条件,不同b值对应的叶子节点里c没有全局排序,索引无法继续利用c来减少扫描范围。c = 5只能作为过滤器在回表后或者索引条件下推时做判断。
所以这个索引实际只用了两列:a 和 b。如果把列顺序调整为 (a, c, b),而查询是 a = 1 AND c = 5 AND b > 10,a 和 c 都是等值,索引对 a 和 c 都能精确定位,剩下的 b 虽然还是范围,但这时 b 前面都是等值,它依然能部分利用索引做范围扫描。这说明联合索引中把区分度高、常用等值查询的列放在前面,把范围查询的列往后放,通常更优。
同样地,LIKE 'abc%' 这种前缀模糊查询可以视为等值前缀,但 LIKE '%abc' 就不能利用了,因为它在 B+ 树中没有顺序。
能跳过最左列吗?索引跳跃扫描
从 MySQL 8.0.13 开始,优化器引入了一项叫索引跳跃扫描(Index Skip Scan)的新能力。在某些情况下,即使查询跳过了最左列,依然可以使用联合索引。
比如有索引 (gender, age),查询条件只有 WHERE age = 25。gender 的取值比较少(如只有男、女两个值),优化器内部可以把它拆成两个范围扫描——先扫 gender='男' 下 age=25 的,再扫 gender='女' 下 age=25 的——然后将结果合并。这相当于把一个大范围扫描拆成了多个小范围扫描,当最左列基数很低时,性能远比全表扫描好。
但这不是万能的,只有当最左列不同值比较少、SELECT 中未涉及太多额外列时,优化器才可能选择这种策略。它并没有推翻最左前缀原则,而是利用前缀值集有限的特点做了变通。在绝大多数情况下,你仍然应该遵守最左前缀来设计索引。
如何用好最左前缀原则
- 分析你的查询模式:不要拍脑袋建
(a, b, c),先统计系统中哪些查询最常见、哪些条件组合最高频。 - 把等值条件列往前放:高频等值查询列放在联合索引最左,能最大化利用索引精确定位的能力。
- 范围条件往后排:
>、<、BETWEEN这类条件可以放在等值列之后,这样它们前面等值部分依然可以全面利用索引。 - 避免最左列缺失:如果查询经常跨过最左列,说明这个索引设计可能不适合该查询,需要调整列顺序或建新索引。
- 善用覆盖索引:如果联合索引已经包含了查询需要的全部字段,即使是用到部分列,也可以避免回表,提升性能。比如
SELECT name, age FROM users WHERE name = 'Alice'在(name, age)索引上就是覆盖查询。
理解最左前缀的底层逻辑,会让你在设计索引和诊断慢查询时胸有成竹。记住,不是 MySQL 故意用规则为难你,而是 B+ 树这种顺序数据结构本身就决定了这种使用方式。