# 联合索引(复合索引)详解 + B+ 树扫描过程

67 阅读12分钟

联合索引(复合索引)详解 + B+ 树扫描过程


一、先给出结论(摘要)

  • 最左前缀规则:联合索引 (a, b, c) 可以被认为同时存在索引 (a)(a,b)(a,b,c)。查询能用到哪个前缀,取决于条件使用情况。
  • 等值优先、范围阻断:在前缀中,如果遇到一个 范围查询><BETWEENLIKE 'x%' 除外的 LIKEIN 有时也按范围处理),从该列开始 后面的列无法用于索引定位(无法做索引跳转) ,只能作为过滤条件在扫描时做过滤。
  • 范围扫描 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 = 3a 是等值 → 继续;b 是范围 → 从 b 开始,c 无法用于索引跳转;系统会扫描 a=10b>5 的所有索引记录并过滤 c=3
  • 注意:范围条件包括 > < >= <= BETWEEN,以及一些 LIKE(有前缀的 LIKE 'abc%' 仍是前缀匹配、可利用索引)。

4. 覆盖索引(Covering Index)

  • 如果查询的 SELECT 列以及 WHEREORDER BYGROUP 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:估计扫描/读取的行数
    • ExtraUsing index(覆盖索引),Using where(有过滤),Using filesort(需要排序),Using temporary(需要临时表)
  • 通过 keykey_len 可判断联合索引用到了多少前缀列。


十、索引设计实践建议(Checklist & 规则)

  1. 按照查询来设计索引:先看最常用的 WHERE / JOIN / ORDER BY / GROUP BY,按顺序放索引列。
  2. 等值列放前,范围列放后:能把等值列放到联合索引前面通常可以提升利用率。
  3. 使索引覆盖查询:把经常 SELECT 的列也放入索引里(但注意索引过宽的代价)。
  4. 避免在索引列上使用函数或算术运算(会导致索引失效)。
  5. 注意列的选择性(selectivity) :高选择性的列更适合放在前面(如果都是等值),但更重要的是匹配查询模式。
  6. 避免重复/冗余索引:例如已有 (a,b),再建 (a) 是冗余(但 MySQL 有时会保留小索引因覆盖等)。
  7. 监控 EXPLAIN 和慢查询日志,并基于实际数据分布调整索引顺序。
  8. 考虑 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

  1. root -> 找到指向 leaf B 的子树
  2. leaf B 内二分查找 (22,100000) -> 找到返回

若执行 WHERE age > 20 AND reward = 100000

  1. root -> 找到 age 首个 > 20 的 leaf(leaf B 开头)
  2. 在 leaf B 从 (22,100000) 开始 顺序遍历:检查每条记录 reward 是否为 100000。
  3. 遇到 leaf C 时,继续,直至全部满足 age > 20 的条目读取完毕(或到达表尾)。

关键点:第 2 步是顺序读取叶子链,而不是多次从 root 寻找不同 (age,reward) 键。


十四、总结

“联合索引当遇到范围查询 (>、<) 就会停止匹配,也就是 age 字段能用到联合索引,但 reward 不能用?”

答案:是的,你的理解是对的(在典型情况和 MySQL 的执行模型下)。原因是:当联合索引的前列出现范围查询时,索引的排序顺序导致从该列开始只能得到一个连续区间,后续列无法再用于树结构上的定位跳转,只能在线性扫描该区间时作为过滤条件。