摘要:网上大量教程只讲最左匹配口诀,很少讲底层 B+ 树、索引下推 ICP、回表、顺序/随机 IO、BufferPool 之间的完整链路。本文结合 Explain 执行计划、底层存储原理,把面试高频坑一次性讲透。
前置准备
建表与复合索引:
CREATETABLEtest(idINTPRIMARYKEYAUTO_INCREMENT,aINT,bINT,cINT);-- 创建联合索引 idx(a,b,c)CREATEINDEXidx_abcONtest(a,b,c);复合索引idx(a,b,c)的 B+ 树叶子节点,排序规则是:优先按 a 排序;a 相等,按 b 排序;b 相等,按 c 排序;叶子行末尾附带主键 id。
上图演示了复合索引叶子页的排序逻辑。从图中可以看到:a 相同的行被分到同一组;a 相同后按 b 排序;只有 (a,b) 都相同时,c 才有序。行尾的主键 id 是后续回表的“钥匙”。
因果链:因为 InnoDB 的 B+ 树叶子行按 (a,b,c) 全局有序 → 所以索引定位时可以从 a 开始二分查找 → 但因为排序优先级是 a→b→c 依次递减 → 所以一旦 a 或 b 出现范围查询,c 在叶子内部就不再有序。
一、什么是最左匹配(最左前缀原则)
核心:复合索引要从索引定义的最左侧字段开始,匹配连续前缀;SQL 的 where 条件书写顺序不影响索引命中,优化器会自动调整条件顺序。
✅ 可以有效使用索引前缀:
wherea=1wherea=1andb=2wherea=1andb=2andc=3whereb=2anda=1-- where 条件顺序打乱,优化器重排,依旧命中索引❌ 无法使用索引(缺失最左前缀):
whereb=2whereb=2andc=3wherec=5误区:不是 where 子句写了 a/b/c 字段就一定能用索引,必须要有索引定义的最左起始列。
重点:遇到范围查询,后面字段无法做索引 seek
> < >= <= between属于范围条件。一旦复合索引匹配中遇到范围查询,范围之后的字段不能再利用索引有序性做快速定位(seek)。
wherea=10andb>20andc=5- a:等值匹配,索引 seek(B+ 树二分定位)
- b:范围条件,索引 range 扫描
- c:不能走索引 seek。因为 b 范围之后,c 在 B+ 树叶子节点内部是无序的
上图完整演示了
where a=10 and b>20 and c=5的执行过程:a 先等值 seek 定位;b 范围扫描命中连续 6 行;但这 6 行里的 c 值(5,12,5,3,40,5)完全无序,无法 seek 定位,只能逐行判断。
但是!c 不是完全失效,它可以交给索引下推 ICP 在二级索引页内过滤,这点是很多博客遗漏的关键点。
补充:in 算不算范围?
in(1,2,3)不属于破坏索引的范围条件,MySQL 内部等价多个 or 等值,不会打断后面索引字段匹配,后续字段依旧可以 seek。
补充:MySQL 8.0 索引跳跃扫描(Skip Scan)
MySQL 8.0.13+ 引入 Skip Scan 优化。当缺失最左前缀时(如where b=2 and c=3),优化器可能通过扫描 a 的所有不同值,在每个 a 值下分别做 b,c 的 seek来利用索引,代价是扫描次数 = a 的 distinct 数量。Explain 中type=range+Using index for skip scan。
二、索引下推 ICP(Index Condition Pushdown)
ICP 全称:索引条件下推,MySQL 5.6 之后支持,Explain Extra 字段显示Using index condition。
没有 ICP 时代执行流程
问题:很多主键对应的行本来就不满足 c 条件,白白执行大量回表随机 IO。
开启 ICP 后流程
✔ ICP 本质:在二级索引页过滤数据,减少需要回表的主键数量,从而减少回表 IO。
⚠ 注意:ICP 只是过滤,不能对 c 做索引 seek,不能利用 c 的有序性快速定位区间。
上图对比了有无 ICP 的核心差异:无 ICP 时 6 行全部回表,到 Server 层才发现 3 行不满足 c=5,回表白做;开启 ICP 后,存储引擎在索引页内先过滤 c=5,只剩 3 次回表,随机 IO 直接减半。
覆盖索引与 ICP 区分
Explain Extra:
Using index:覆盖索引,直接从二级索引拿到全部查询字段,完全不需要回表。Using index condition:ICP,部分条件索引层过滤,仍然需要回表。
三、回表是什么?聚簇索引 vs 二级索引
InnoDB 中:
- 聚簇索引(主键索引):叶子节点存储完整整行数据;表数据本身就是主键 B+ 树。
- 二级索引(普通/复合索引):叶子节点存储索引列 + 主键 id,没有完整行。
回表:拿到二级索引叶子的主键 id,再去主键 B+ 树查找完整行数据的过程。
如果查询需要的全部字段都在二级索引内,不需要读取完整行,就是覆盖索引,避免回表。
-- 覆盖索引,Extra: Using index,无需回表selecta,b,cfromtestwherea=10andb>20;key_len:判断复合索引用到多少字段
key_len表示实际用到索引的字节长度。以a INT, b INT, c INT为例:
| 查询条件 | key_len | 含义 |
|---|---|---|
where a=10 | 5 | 只用 a(INT 4 字节 + nullable 1 字节) |
where a=10 and b>20 | 10 | 用到 a、b(b 范围仍计入 key_len) |
where a=10 and b=2 and c=5 | 15 | 用到 a、b、c 全部 |
面试技巧:看到
key_len=10就知道只用到前两个字段;key_len=5说明只走了 a,b 没参与索引定位。
四、表空间与数据页组织:页从哪里来?
在讲 IO 类型之前,必须先回答一个问题:回表时访问的"页",在磁盘上是怎么存的?
InnoDB 的数据最终持久化在表空间(tablespace)中。开启innodb_file_per_table=ON时,每张表对应一个独立的.ibd文件,这就是该表的独立表空间。
表空间内部是层级组织:
| 层级 | 大小 | 作用 |
|---|---|---|
| 页(Page) | 16KB | 最小读写单位,存实际数据行 |
| 区(Extent) | 1MB = 64 页 | 空间分配的最小单位,保证区内页物理连续 |
| 段(Segment) | 变长 | 逻辑概念,如叶子节点段、非叶子节点段 |
因果链:
因为 表空间按 extent 成片分配(一次分 64 页,物理连续) → 所以 同一段时期内分配的页,物理位置大概率相邻 → 但因为 增删改导致页分裂、合并、回收再分配 → 所以 逻辑上页号相邻的两页,磁盘位置可能已经分散 → 因此 判定顺序/随机 IO 不能看物理位置,只能看页号访问次序关键认知:B+ 树叶子节点的"逻辑有序"≠"物理有序"。表空间决定了页的物理落脚处,而页号只是表空间内的逻辑编号。理解这一点,才能真正理解下一节的"顺序/随机 IO"。
五、顺序 IO、随机 IO,不要再记死口诀!
网上流传:二级索引扫描 = 顺序 IO,回表 = 随机 IO。这句话只是绝大多数场景的经验总结,不是铁律定义。
InnoDB 判定顺序/随机访问模式
InnoDB 看不到磁盘物理扇区,看的是表空间页号的访问序列:
- 顺序访问模式(顺序 IO):页号持续递增向后访问;触发 InnoDB 线性预读 read-ahead。哪怕磁盘物理页不连续,只要访问次序连续向后,就视为顺序访问。
- 随机访问模式(随机 IO):页号跳跃无序访问,无法触发预读,机械磁盘会产生昂贵寻道开销。
关键点:顺序 IO、随机 IO 是访问模式,不是索引自带属性。
示例 1:聚簇索引(主键)
-- 主键连续读取,页号递增,顺序 IOselect*fromtestwhereidbetween1000and2000;-- 主键乱序跳跃读取,页号到处跳,随机 IOselect*fromtestwhereidin(1001,7,3900,56);普通回表为什么大多是随机 IO:
二级索引筛选出来的主键 id 集合,排序规则跟随(a,b,c),主键 id 是乱序打散的;拿着一堆无序 id 访问聚簇索引,页号来回跳,产生随机 IO。
MRR 优化:把回表随机 IO 转为顺序 IO
MRR(Multi-Range Read)多范围读优化,MySQL 官方专门解决回表大量随机 IO 的方案:
- 先从二级索引拿到一批待回表主键 id;
- 在内存缓冲区,把主键 id 从小到大排序;
- 按主键升序访问聚簇索引,页号递增,随机 IO 变成顺序 IO。
Explain 会看到 Extra:
Using MRR。MRR 充分证明,回表本身不等于随机 IO,访问主键的次序决定 IO 类型。
上图演示了 MRR 的核心机制:二级索引按 (a,b,c) 序吐出主键 57,3,812,21,406(乱序),直接回表时页号来回跳 = 随机 IO;MRR 先在缓冲区排成升序 3,21,57,406,812,再按页号递增顺序访问 → 顺序 IO + 触发预读。
重要结论
如果需要访问的数据页全部命中 Buffer Pool 内存,不存在磁盘 IO,顺序 IO、随机 IO 没有性能差异。顺序/随机 IO 概念只针对磁盘访问场景。
六、延伸理解:BufferPool、脏页,帮你看懂 SQL 底层 IO 行为
1. BufferPool LRU 冷热分区
InnoDB 使用改良 LRU 链表,分为 young 热区、old 冷区。新页默认进入 old 区头部;只有在 old 区停留超过
innodb_old_blocks_time(默认 1000ms)后再次被访问,才会移到 young 区头部。这避免了全表扫描一次性冲掉全部热点缓存。
2. 脏页生命周期
3. 脏页是否可读?
脏页完全可以对外查询。
脏页定义:Buffer Pool 内存页被修改,内存版本 > 磁盘持久化版本;磁盘存旧数据。
查询优先读取 Buffer Pool 内存中的页,不管它是不是脏页;后台 Page Cleaner 线程异步刷脏页,刷脏不会阻塞读写,刷盘成功脏页变成干净页,该页依旧留在 BufferPool。
4. 脏页刷盘成功后,为什么不直接删除,要用 LRU 淘汰?
很多人误区:脏页落盘完毕就没用了,直接清掉。
- Redo Log 只负责崩溃恢复,业务运行时 select不会读取 redo log 拿业务数据,redo log 只是操作流水,没有完整数据页结构。
- BufferPool 是缓存,遵循局部性原理:刚访问过的页大概率还会再次访问。刷脏完成变成干净页,仍然是热点数据,留在内存可以避免重复从磁盘加载。
- LRU 淘汰触发时机:BufferPool 内存用尽,要加载新的数据页时,才淘汰最久未访问的冷页。
- 淘汰脏页:先刷脏页落盘,再释放内存;
- 淘汰干净页:直接丢弃,磁盘已有副本。
七、Explain 关键字段回顾(做索引分析必看)
| 字段 | 含义 | 面试关注点 |
|---|---|---|
type | 访问类型 | ref=等值索引查找;range=范围索引扫描;ALL=全表扫描 |
key | 实际使用索引 | 确认是否走了预期索引 |
key_len | 实际用到索引字节长度 | 判断复合索引用到多少字段(INT nullable = 5 字节) |
Extra | 额外信息 | Using index=覆盖索引无回表;Using index condition=ICP;Using MRR=多范围读优化;Using filesort=需额外排序 |
八、高频踩坑总结(面试速记)
- 最左匹配要求索引定义的连续最左前缀,where 条件书写顺序无关,优化器自动调整。
- 遇到
> < between范围查询,后面字段不能索引 seek,但可被 ICP 过滤;in 不会打断索引匹配。 - ICP 减少回表数量,但不能替代索引 seek;覆盖索引直接消除回表。
- 顺序 IO / 随机 IO 看页面访问次序,不是索引类型;MRR 可以把回表随机 IO 转为顺序 IO。
- 全部页命中 BufferPool,磁盘 IO 消失,顺序随机 IO 性能无差别。
- 脏页可读,刷脏不等于驱逐页面;LRU 只有内存不足才淘汰冷页;redo log 只管崩溃恢复,业务查询不会读取 redo log。
- 复合索引设计原则:等值条件放前面,范围条件尽量放在索引最后。
九、因果链总收束:一条链串起所有概念
因为 复合索引叶子按 (a,b,c) 全局有序 → 所以 查询可以从 a 开始二分 seek → 因为 排序优先级 a→b→c 依次递减 → 所以 b 范围后 c 在叶子内部无序 → 因为 c 无序无法 seek,只能逐行判断 → 所以 引入 ICP 在引擎层过滤,减少回表 → 因为 二级索引吐出主键 id 跟随 (a,b,c) 排序,主键乱序 → 所以 回表需要访问聚簇索引页 → 因为 聚簇索引页存储在表空间中,页号由表空间分配 → 所以 回表默认是随机 IO(页号跳跃) → 因为 MRR 把主键排序后再访问 → 所以 回表变成顺序 IO + 触发预读 → 因为 页全部命中 BufferPool 时不存在磁盘 IO → 所以 顺序/随机 IO 概念只在磁盘层有意义这条链上的每一个环节,都是前一个环节的必然推论。拿掉任何一节,后面的结论都不成立。
参考资料
- 《高性能 MySQL》第 5 章 — 索引设计
- MySQL 官方文档 — Index Condition Pushdown
- MySQL 官方文档 — Multi-Range Read Optimization
- MySQL 官方文档 — InnoDB Buffer Pool
- MySQL 官方文档 — InnoDB Read-Ahead
- MySQL 索引下推 ICP 详解 — 博客园
- MySQL MRR 优化详解 — 博客园
- InnoDB Buffer Pool LRU 冷热分区 — CSDN