1 缘起
在日常开发中,我们几乎每天都在写 SQL,也习惯性地在遇到性能问题时说一句"加个索引吧"。然而,当数据量从几十万增长到几百万、几千万时,原本运行顺畅的查询突然变慢,Explain 输出里出现的 Using index condition、Using temporary、Using filesort 也让人摸不着头脑。更让人困惑的是:明明已经加了索引,为什么查询还是慢?
随着问题不断积累,我逐渐意识到:
很多性能瓶颈,并不是 SQL 写得不好,而是我们对 InnoDB 索引结构的理解还停留在表面。
当我真正深入到 InnoDB 的内部实现,看到:
聚簇索引的叶子节点存的是整行数据
二级索引的叶子节点只存索引列和主键
二级索引查询必须"回表"
回表意味着随机 IO,而 IO 才是性能的真正杀手
那一刻,我才明白:
理解索引结构,不是数据库优化的高级技巧,而是每个开发者都必须掌握的基础能力。
也正是从那时起,我开始系统整理聚簇索引与二级索引的知识,希望用更直观的方式解释它们的结构、差异与性能影响,让每一个写 SQL 的人都能真正理解:
为什么有的查询飞快,有的查询却慢如蜗牛;
为什么同样的数据量,有的索引有效,有的索引却毫无作用。
这篇文章,就是从这样的思考中诞生的。

2 聚簇索引(Clustered Index)
InnoDB 的主键索引就是 聚簇索引,叶子节点 存储: 整行数据。
2.1 聚簇索引 B+Tree 结构示意图
[Root Page]
|
--------------------------------
| |
[Non-leaf Page] [Non-leaf Page]
| |
----------- -----------
| | | | | |
[Leaf][Leaf][Leaf] [Leaf][Leaf][Leaf]
| | | |
| | | |
↓ ↓ ↓ ↓
================= 聚簇索引叶子页(存整行) =================
| PK=1 | colA | colB | colC | ... | 整行数据 |
| PK=2 | colA | colB | colC | ... | 整行数据 |
| PK=3 | colA | colB | colC | ... | 整行数据 |
============================================================
特点
叶子节点 = 整行数据
主键顺序存储
查询主键不需要回表
3 二级索引(Secondary Index)
二级索引的叶子节点 不存整行数据,只存:
- 索引列值
- 主键值(指向聚簇索引的"指针")
3.1 二级索引 B+Tree 结构示意图
[Root Page]
|
--------------------------------
| |
[Non-leaf Page] [Non-leaf Page]
| |
----------- -----------
| | | | | |
[Leaf][Leaf][Leaf] [Leaf][Leaf][Leaf]
| | | |
↓ ↓ ↓ ↓
================= 二级索引叶子页(不存整行) =================
| idx_col=18 | PK=3 |
| idx_col=18 | PK=7 |
| idx_col=20 | PK=1 |
| idx_col=21 | PK=9 |
==============================================================
3.2 特点
二级索引叶子节点 = 索引列 + 主键
需要根据主键再去聚簇索引查整行(回表)
4 二级索引查询 + 回表过程图
假设执行:age 有二级索引:
SELECT name FROM user WHERE age = 18;
4.1 查询流程
二级索引找到主键, 再根据主键去聚簇索引查整行,这一步就是 回表(Bookmark Lookup)。
二级索引(age)
|
找到叶子节点
|
--------------------------------
| |
(age=18, PK=3) (age=18, PK=7)
| |
↓ ↓
回表到聚簇索引 回表到聚簇索引
| |
↓ ↓
================= 聚簇索引(主键) =================
| PK=3 | name=Tom | age=18 | ... |
| PK=7 | name=Lucy | age=18 | ... |
====================================================
4.2 为什么二级索引需要回表?
因为 InnoDB 只有 一个聚簇索引(主键),整行数据只存储在主键索引的叶子节点。
二级索引为了节省空间,只能存:索引列;主键值(指针)
所以必须回表。
5 回表导致 IO 的原因
5.1 原因 1:二级索引的主键分布随机 → 回表访问的数据页随机
(age=18, PK=3)
(age=18, PK=7)
(age=18, PK=1024)
(age=18, PK=900000)
这些主键对应的行:
- 分布在不同的数据页
- 页之间没有连续性
- 很难被缓存命中
- 随机访问 = 随机磁盘 IO(最慢)
5.2 原因 2:数据页比索引页大得多,无法全部缓存
假设:
每行 200B
100 万行 ≈ 200MB 数据页
Buffer Pool 只有 1GB
虽然索引页(几十 MB)能缓存住,但:
- 数据页无法全部缓存
- 回表时很可能需要从磁盘读数据页
磁盘随机读一次 ≈ 5ms
内存读一次 ≈ 100ns
差距 5 万倍
5.3 原因 3:每次回表都可能触发一次磁盘随机读
一次二级索引查询可能需要:
3 次索引页访问(B+Tree 高度 3)
1 次回表访问(聚簇索引)
如果这些页不在内存:
(3+1)×5𝑚𝑠=20𝑚𝑠(3+1)×5𝑚𝑠=20𝑚𝑠(3+1)×5ms=20ms
每秒只能处理 50 次查询。
6 为什么百万级数据后回表 IO 会爆炸?
- 数据页数量大
- 数据页随机访问
- Buffer Pool 无法缓存全部数据页
- 回表命中率下降
- IO 次数急剧增加
最终表现为:
- 查询变慢
- QPS 降低
- CPU 空闲但磁盘忙(典型 IO 瓶颈)
7 如何减少回表 IO?
| 方案 | 描述 |
|---|---|
| 使用覆盖索引(最有效) | 让查询只用二级索引,不回表 |
| 增大 Buffer Pool | 让更多数据页常驻内存 |
| 避免 SELECT * | 减少回表需要读取的列 |
| 使用更短的主键 | 减少二级索引大小,提高缓存命中率 |
| 分区/分表 | 减少单表数据页数量 |
8 小结
| 项目 | 说明 |
|---|---|
| 回表是什么 | 二级索引查到主键后,再去主键索引查整行 |
| 为什么需要回表 | 二级索引不存整行数据 |
| 为什么回表慢 | 需要随机访问聚簇索引页 |
| IO 与回表关系 | 回表 = 随机 IO,数据量大时 IO 成瓶颈 |
| 百万级后性能下降原因 | 数据页无法全部缓存,回表命中率下降 |