联合索引(复合索引)详解 + B+ 树扫描过程
一、先给出结论(摘要)
- 最左前缀规则:联合索引
(a, b, c)可以被认为同时存在索引(a)、(a,b)、(a,b,c)。查询能用到哪个前缀,取决于条件使用情况。 - 等值优先、范围阻断:在前缀中,如果遇到一个 范围查询(
>、<、BETWEEN、LIKE 'x%'除外的LIKE、IN有时也按范围处理),从该列开始 后面的列无法用于索引定位(无法做索引跳转) ,只能作为过滤条件在扫描时做过滤。 - 范围扫描 vs 定位查找:等值条件可以把索引精确定位到一个子集;范围条件会把索引“打开”成一个区间,必须顺序扫描该区间的叶子记录,无法再按后续列做跳转查找。
- 覆盖索引(index-only):如果查询要的列全部在索引上(包括用于过滤/排序/返回的列),就可以避免回表(直接从索引叶子返回数据),性能最好。
- InnoDB 特点:二级索引(非聚簇索引)叶子记录中还包含主键,用来回表。聚簇索引(PRIMARY KEY)叶子就是整行数据。
下面逐步细讲为什么以及具体如何工作,并用 B+ 树示意 演示查找与范围扫描过程。
二、联合索引的几个重要原则(详细版)
1. 最左前缀(left-most prefix)
-
索引
(a,b,c)能被优化器视为可使用的索引前缀集合:(a)(a,b)(a,b,c)
-
只有当查询条件对最左列
a有条件时(例如a = ...或a BETWEEN ...或a > ...),索引才有机会被利用;如果查询只对b有条件但没有a,则(a,b,c)不能 用作b的索引(除非使用索引跳过扫描——MySQL 不支持 skip-scan)。
2. 等值条件要放前面(设计角度)
-
在构建联合索引并期待被多个查询使用时,把等值条件(筛选性好)放在前面,把范围条件放后面通常更好。
- 例如:若经常查询
WHERE type = ? AND created_at > ?,索引(type, created_at)是合适的;如果反过来建(created_at, type),在created_at > ?时type就无法再做索引定位。
- 例如:若经常查询
3. 范围条件会“中断”后续列
- 例如
(a,b,c),若a = 10 AND b > 5 AND c = 3:a是等值 → 继续;b是范围 → 从b开始,c无法用于索引跳转;系统会扫描a=10且b>5的所有索引记录并过滤c=3。 - 注意:范围条件包括
> < >= <= BETWEEN,以及一些LIKE(有前缀的LIKE 'abc%'仍是前缀匹配、可利用索引)。
4. 覆盖索引(Covering Index)
- 如果查询的
SELECT列以及WHERE、ORDER BY、GROUP BY的列都被联合索引包含(不需要回表),MySQL 可以直接从索引叶子返回结果 —— 这叫覆盖索引或 索引覆盖查询,非常快。 - 例如索引
(a,b,c),查询SELECT b,c FROM t WHERE a = 1 AND b = 2可以直接从索引得到b,c。
5. ORDER BY / GROUP BY 与索引顺序
- 如果
ORDER BY的顺序与索引列顺序一致(方向也匹配),优化器可以直接利用索引避免 filesort。 - 例如
(a,b)索引,ORDER BY a ASC, b ASC可以走索引;但若b方向不一致或ORDER BY b,a则不能直接使用(除非优化器特殊处理)。
6. 前缀索引(VARCHAR(100) 索引前 n 个字符)
- 字符串可以做前缀索引(如
name(10)),但此时索引只按前 n 个字符排序与比较,选择性受限,范围规则仍然适用。
7. 函数或表达式上索引(不可用)
- 如果 WHERE 中对列使用函数(如
WHERE YEAR(date) = 2020)或者表达式,会使索引失效(除非 MySQL 8+ 的表达式索引/函数索引被声明)。
三、B+ 树结构(核心概念)——为什么范围会中断后续列
先看 B+ 树的基本结构(简化示意):
[internal node keys]
/ | \
[leaf block] [leaf block] [leaf block]
更详细的示例(叶子节点带有链表指针):
Root
/ \
Node1 Node2
| |
LeafA -> LeafB -> LeafC -> NULL
- 内部节点(非叶子) :存放引导键(用于决定往哪个子树走),并不存完整记录。
- 叶子节点:存放实际索引条目(key + 指向数据的指针/或整行数据),且叶子节点之间通过指针顺序相连(链表),用于顺序/范围扫描。
- B+ 树的键在叶子层是 按索引键的顺序 完全有序排列的 —— 这是关键。
叶子节点记录示例(复合索引 (age, reward))
索引 (age, reward) 的叶子记录排序示例(每条记录按 (age, reward) 排序):
(age, reward) : row pointer
(18, 50000) -> r6
(20,100000) -> r1
(22,100000) -> r2
(24,100000) -> r3
(30, 50000) -> r4
(36, 2000) -> r8
(39, 50000) -> r7
(49,1999999) -> r5
(100,2000) -> r9
注意这些是按 age 先排序,然后在相同 age 下按 reward 排序。
四、B+ 树上执行查询的两种基本操作(点查 + 范围查)
1) 点查(精确定位,例如 age = 22 AND reward = 100000)
- 从 root 到内部节点做比较,走到正确的叶子节点(因为
(22,100000)是一个精确键)。 - 在叶子节点中查找
(22,100000),找到后返回对应数据(或主键指针然后回表)。 - 这是一次树的下溯定位,复杂度 O(log N)。
2) 范围查(例如 age > 20 AND reward = 100000)
- 首先在树中确定范围的起点(找到第一个满足
age > 20的第一个叶子记录,例如(22,100000))。 - 然后沿着叶子链表顺序向右扫描,依次读取
(age,reward)条目,直到 age 超出范围(或者读取满足的所有叶子记录为止)。 - 在这个扫描过程中,索引可以用于快速跳到起点,但不能跳到某个特定的 reward 值,只能线性扫描所有 age 符合条件的条目并在 CPU 层过滤 reward。
这就能直观说明:当对 (age, reward) 做 age > 20 AND reward = 100000 时:
- 索引可以把扫描缩小到
age > 20的连续区间; - 但是
reward = 100000只是对这个区间内的每条记录做比较,无法直接使用索引第二列做二次定位,因此 optimizer 报告key = age而非key = age,reward。
五、用图(ASCII)一步步展示:为什么 range 阻断后续列
情况 A:age = 22 AND reward = 100000(等值 + 等值)
索引 (age,reward) 的叶子中存在:
... (20,100000), (22,100000), (22,20000), (24,100000) ...
执行:
- 首先定位
(22,100000)→ 直接匹配(定位到叶子并找到值)。 - 因为
age是等值,索引引导定位不会“打开区间”,而是在age= 22 的子集合中继续精确定位reward,可以利用整个联合索引 -> 走索引寻址(很快)。
情况 B:age > 20 AND reward = 100000(范围 + 等值)
-
定位到第一个满足
age > 20的叶子位置,比如(22,100000)。 -
现在必须从这个位置向右顺序读取所有叶子条目:
- 读取
(22,100000)→ reward = 100000 ? yes -> keep - 读取
(22,20000)→ reward != 100000 -> filter out - 读取
(24,100000)→ keep - …直到 age 不再 > 20(其实 age 会一直 > 20,直到表尾,或当是
age BETWEEN 21 AND 30才会遇到终点)。
- 读取
-
这个过程是顺序扫描区间并逐条判断 reward —— reward 没法用于缩小树查找。
因此:范围查询的本质是把索引“打开”为一个连续区间,从该区间必须线性扫描,无法跳到第二列特定值。
六、更多实际例子(包含 EXPLAIN 解释)
假设表 t_user,索引 INDEX idx_age_reward (age, reward)。
示例 1
SELECT * FROM t_user WHERE age = 22 AND reward = 100000;
- 使用索引:
idx_age_reward - 行为:定位到
(22,100000)(点查/范围很小) - EXPLAIN 可能显示:
key = idx_age_reward,Extra: Using where(或Using index如果覆盖索引)。
示例 2
SELECT * FROM t_user WHERE age > 20 AND reward = 100000;
- 使用索引:只能用
age部分(key = idx_age_reward但只是 age 的范围) - 行为:先定位到
age > 20的起点,然后顺序扫描区间并在内存中过滤reward = 100000。 - EXPLAIN 可能显示:
key = idx_age_reward,Extra: Using where(并需要回表,如果不是覆盖索引)。
示例 3(换列顺序索引)
如果索引是 INDEX idx_reward_age (reward, age), 查询 WHERE age > 20 AND reward = 100000:
- 这里
reward = 100000是等值(索引首列),age > 20是后续列的范围 —— 优化器可以利用整个索引:先定位reward=100000的区间,再在该区间做age > 20的顺序扫描(相比情况 B,此时age是后续列的范围,可被利用来缩小扫描范围)。 - 因此,索引列的顺序决定了哪些场景下性能最好。
七、索引与 ORDER BY / GROUP BY 的结合(细节)
-
如果
ORDER BY与索引列的顺序完全一致(并且方向一致),MySQL 可以用索引顺序输出结果,从而避免 filesort。- 例如索引
(a,b),查询WHERE a = ? ORDER BY b—— 可以直接使用索引,因为在a = ?子集中,b已经按升序排列。
- 例如索引
-
如果查询中使用了范围(如
a > ?)且ORDER BY用的是后面的列,仍有可能用索引,但视情况而定(通常ORDER BY在范围内的顺序是索引顺序,可以用于避免 filesort,只要索引顺序符合)。
八、Index Merge(索引合并)与优化器策略
- MySQL 有时会使用 Index Merge(合并多个单列索引),例如
WHERE a = 1 OR b = 2,可以用a索引与b索引分别查出行号再合并。 - 但 Index Merge 并不是万能,且有代价(需要额外的合并/临时表操作)。
- 对于联合索引,通常优先使用联合索引的前缀比合并单索引更高效。
九、如何看 EXPLAIN 输出来验证索引使用情况(实操技巧)
-
EXPLAIN SELECT ...的关键信息:key:实际使用的索引名(NULL 表示没有使用)key_len:使用的索引长度(越短表示用到的列越少)ref:用于索引比较的常量或列rows:估计扫描/读取的行数Extra:Using index(覆盖索引),Using where(有过滤),Using filesort(需要排序),Using temporary(需要临时表)
-
通过
key与key_len可判断联合索引用到了多少前缀列。
十、索引设计实践建议(Checklist & 规则)
- 按照查询来设计索引:先看最常用的 WHERE / JOIN / ORDER BY / GROUP BY,按顺序放索引列。
- 等值列放前,范围列放后:能把等值列放到联合索引前面通常可以提升利用率。
- 使索引覆盖查询:把经常
SELECT的列也放入索引里(但注意索引过宽的代价)。 - 避免在索引列上使用函数或算术运算(会导致索引失效)。
- 注意列的选择性(selectivity) :高选择性的列更适合放在前面(如果都是等值),但更重要的是匹配查询模式。
- 避免重复/冗余索引:例如已有
(a,b),再建(a)是冗余(但 MySQL 有时会保留小索引因覆盖等)。 - 监控
EXPLAIN和慢查询日志,并基于实际数据分布调整索引顺序。 - 考虑 InnoDB 的额外开销:每个二级索引都会额外存储主键列,会占空间。
十一、常见误区与陷阱(并给出改写建议)
-
误区:
(a,b)索引一定能加速WHERE b = ...—— 错误,没有a条件时不能直接使用(除非使用索引覆盖或其他特殊情况)。 -
误区:把所有查询列都放到一个联合索引中没坏处 —— 错误,索引越宽,写操作越慢,占用越多空间。
-
改写建议:
- 如果常有
WHERE b = ? AND a > ?,考虑把索引建为(b, a)。 - 如果常有
WHERE a > ? AND b = ?且不能调整索引,可能考虑INDEX (reward)单列索引(但代价是可能走两个索引合并或回表较多)。
- 如果常有
十二、关于 LIKE 的细节
LIKE 'abc%':是前缀匹配,可以利用索引(等价于>= 'abc' AND < 'abd')。LIKE '%abc'或LIKE '%abc%':不能利用常规索引(会做全表/全索引扫描),除非使用全文索引(FULLTEXT)。
十三、B+ 树更详细的图示(带路径与扫描过程)
假设索引 (age,reward),简化 B+ 树:
[ root ]
/ | \
[30] [50] [70] <-- internal keys
/ | \
node A node B node C
leaf A leaf B leaf C
叶子示例内容(顺序):
leaf A: (18,50000), (20,100000)
leaf B: (22,100000), (22,20000), (24,100000)
leaf C: (30,50000), (36,2000), (39,50000), (49,1999999)
若执行 WHERE age = 22 AND reward = 100000:
- root -> 找到指向 leaf B 的子树
- leaf B 内二分查找
(22,100000)-> 找到返回
若执行 WHERE age > 20 AND reward = 100000:
- root -> 找到 age 首个 > 20 的 leaf(leaf B 开头)
- 在 leaf B 从
(22,100000)开始 顺序遍历:检查每条记录reward是否为 100000。 - 遇到 leaf C 时,继续,直至全部满足
age > 20的条目读取完毕(或到达表尾)。
关键点:第 2 步是顺序读取叶子链,而不是多次从 root 寻找不同 (age,reward) 键。
十四、总结
“联合索引当遇到范围查询 (>、<) 就会停止匹配,也就是 age 字段能用到联合索引,但 reward 不能用?”
答案:是的,你的理解是对的(在典型情况和 MySQL 的执行模型下)。原因是:当联合索引的前列出现范围查询时,索引的排序顺序导致从该列开始只能得到一个连续区间,后续列无法再用于树结构上的定位跳转,只能在线性扫描该区间时作为过滤条件。