MySQL 学习(十一)MySQL 查索引还要「再查一次表」?——从 B+ 树到聚簇索引、二级索引与回表
文章目录

一篇用虚构示例讲清 InnoDB 索引与回表的入门笔记。全文只使用
student表示例,不涉及任何业务表或项目代码。
写在前面:你要搞懂的一条主线
在 InnoDB 里,可以把一张表想象成 两(或多)棵 B+ 树:
- 聚簇索引:叶子节点里放着 完整的一行数据(按主键排序)。
- 二级索引:叶子节点里只有 索引列 + 主键,没有你要的其他列。
查询时,如果只用了二级索引,却还需要 name 这类不在索引里的列,就要拿着主键 再到聚簇索引里取一次完整行——这一步就叫 回表。
下面按「存储结构 → 索引类型 → 查询路径 → 如何避免回表 → 多索引怎么选」的顺序展开。
第 1 章:问题从哪来——「有索引为什么还慢?」
假设有这样一张学生表:
CREATE TABLE student (
id INT PRIMARY KEY, -- 主键,InnoDB 下即聚簇索引
name VARCHAR(50),
age INT,
class_id INT,
INDEX idx_age (age) -- 在 age 上的普通索引(二级索引)
);
你执行:
SELECT name, age FROM student WHERE age = 18;
直觉上:age 有索引,应该很快。但有时你会发现:索引确实用上了,磁盘/缓冲池的读取次数却并不低。
原因在于:idx_age 这棵索引树里,并没有 name 这一列。索引帮你快速找到了「哪些学生的 age 是 18」,但若要拿到 name,往往还要再查一次主表数据——也就是回表。
本章 takeaway:索引不是整张表的复印件;它只保存「排序用的键」和「找到那一行所需的地址(主键)」。
第 2 章:B+ 树——索引的「目录」长什么样
2.1 为什么用 B+ 树,而不是二叉树?
表数据可能有百万、千万行。索引要支持:
- 等值查询:
age = 18 - 范围查询:
age BETWEEN 18 AND 20 - 排序:
ORDER BY age
B+ 树的特点很适合这些场景:
| 特点 | 含义 | 带来的好处 |
|---|---|---|
| 多叉、树矮 | 每个节点有很多孩子,树的高度低 | 从根到叶通常只需几次磁盘 IO |
| 数据都在叶子 | 非叶子节点只存「指路」的键 | 内存里能缓存更多「目录」节点 |
| 叶子形成链表 | 同一层的叶子按顺序串起来 | 范围扫描时顺着链表走,不用反复回根 |
可以把它想成 一本厚字典的拼音目录:中间页告诉你「去哪个字母区间」,真正的词条(数据)都在最后的叶子页上,而且叶子页是连续编号的,翻起来很顺。

2.2 索引本质上是什么?
索引 = 按某一列(或多列)排序的一棵 B+ 树。
- 你查
WHERE age = 18,引擎在这棵树上定位到age = 18的叶子。 - 叶子上记的不是「整行学生档案」,而是能继续找到这一行的线索——具体是什么线索,取决于这是聚簇索引还是二级索引(下一章讲)。
2.3 一个小例子:idx_age 的叶子里有什么?
在 idx_age 的 B+ 树 叶子 上,可能看到类似:
(age=17, id=2)
(age=18, id=3)
(age=18, id=7)
(age=19, id=12)
...
注意:这里有 age 和 id,没有 name。
这就是为什么后面会出现「回表」——索引只给了你 门牌号(id),没给你住户姓名(name)。
第 3 章:聚簇索引——表数据本身就在一棵 B+ 树里
3.1 聚簇索引名称的由来
在 MySQL 里,「聚簇索引」这个名字的核心含义是:把索引和数据行「聚」在一起存放。
1)为什么叫「聚簇」?
- 英文是 clustered index,cluster 有「聚集、成团、成簇」的意思。
- 聚簇索引的叶子节点直接存放数据行,而不是只存指向别处的指针。
- 主键索引的结构与表数据绑定在一起,各行按主键顺序「聚集」在 B+ 树的叶子层。
2)和普通(二级)索引的区别
| 类型 | 叶子节点里有什么 |
|---|---|
| 普通索引(二级索引) | 主键值或行定位信息;完整数据行另存于聚簇索引 |
| 聚簇索引 | 完整数据行;查到叶子页就等于查到数据 |
3)直观类比
- 普通索引像书的目录:只告诉你正文在第几页。
- 聚簇索引像目录和正文合订本:翻到这一页就直接看到内容。
4)在 InnoDB 中的具体表现
- InnoDB 的主键索引就是聚簇索引。
- 若没有显式主键,InnoDB 会按顺序选择聚簇键:
- 第一个非空唯一索引;
- 若仍没有,则生成隐藏的
row_id(6 字节)作为聚簇索引键。
一句话:之所以叫聚簇索引,是因为表的数据记录与主键索引聚集在同一棵 B+ 树里,叶子节点直接保存数据行。
3.2 三个容易混在一起的词
| 术语 | 一句话解释 |
|---|---|
| 主键(Primary Key) | 唯一标识一行;在 InnoDB 里通常也充当聚簇索引的排序键。 |
| 聚簇索引键(Clustered Index Key) | 决定「这一行在聚簇 B+ 树里排在哪」的键;常见就是主键 id。 |
| 聚簇索引(Clustered Index) | 那棵「叶子 = 完整行数据」的 B+ 树;表的主数据按这棵树的顺序存储。 |
在 InnoDB 中,可以暂时把三者理解成:主键 ≈ 聚簇索引键,聚簇索引 = 按主键组织的那棵「主数据树」。
入门阶段先记住:有主键
id时,聚簇索引就是按id排序;无主键时的选择规则见 3.1 节。
3.3 聚簇索引的叶子长什么样?
student 表按主键 id 的聚簇索引,叶子节点里存的是 整行:
id=1 → (name='李四', age=17, class_id=2)
id=2 → (name='王五', age=17, class_id=1)
id=3 → (name='张三', age=18, class_id=1)
id=7 → (name='赵六', age=18, class_id=3)
...
关键对比(请记住):
- 聚簇索引的叶子 ≈ 数据行本身(或行的主要部分)
- 二级索引的叶子 ≠ 整行,只有索引列 + 主键
3.4 类比:按门牌号排序的公寓楼
- 门牌号 = 主键
id(聚簇索引键) - 每个房间里的全部信息 = 一行完整数据(
name, age, class_id…) - 公寓楼按门牌号物理排列 = 聚簇索引按主键顺序存数据
你拿着 id=3 去找人,直接进 3 号房就能拿到张三的全部信息 —— 一次命中,不用再去别处抄档案。

第 4 章:二级索引——另一棵 B+ 树,叶子只带「门牌号」
4.1 二级索引是什么?
在 age 列上建的 idx_age,是 独立于聚簇索引的另一棵 B+ 树:
- 排序键是
age(非主键); - 叶子上通常是:
age的值 + 主键id(聚簇索引键)。
idx_age 的叶子(示例):
(age=18, id=3)
(age=18, id=7)
(age=19, id=12)
注意:这里没有
name。
二级索引的作用:按 age 快速找到对应学生的 id 列表。
4.2 两棵树如何配合?
查 SELECT name FROM student WHERE age = 18 时,逻辑上是:
步骤 1:在 idx_age(二级索引)上找 age=18 → 得到 id 列表 [3, 7, ...]
步骤 2:对每个 id,到聚簇索引(主数据树)上取完整行 → 读出 name
步骤 2 就是 回表:索引树 → 主数据树,多走一趟。

第 5 章:回表——定义、步骤与判断方法
5.1 正式定义
回表(Table Lookup / 回表查询) 是指:
- MySQL 通过 二级索引 找到了满足条件的行的 主键值(聚簇索引键),但查询需要的列 不在该二级索引里,于是还要拿着主键,到 聚簇索引(主表数据) 中再读取完整那一行。
可以记成两步:
- 先查索引:在二级索引的 B+ 树里定位,拿到
id(主键); - 再查表:用这些
id到聚簇索引里取完整行(如name等列)。
5.2 同一个表,三种查询对比
| SQL | 主要使用的结构 | 是否回表 | 原因 |
|---|---|---|---|
SELECT id FROM student WHERE age = 18 |
idx_age |
否 | 需要的 id、age 都在 idx_age 叶子里。 |
SELECT name FROM student WHERE age = 18 |
idx_age → 聚簇索引 |
是 | name 不在 idx_age 里,必须用 id 再取整行。 |
SELECT * FROM student WHERE id = 3 |
聚簇索引 | 否 | 直接按主键在聚簇树上取整行,不经过二级索引。 |
5.3 回表的成本直觉
若 age = 18 匹配 1000 行,且查询需要回表:
- 二级索引上可能扫描 1000 个叶子条目(得到 1000 个
id); - 聚簇索引上大致要进行 1000 次「按主键找行」(实际实现会做一定优化,但直觉上仍是「匹配越多,回表越多」)。
因此:高选择性(过滤后剩很少行)时回表尚可接受;低选择性(一次命中成千上万行)且要很多列时,回表成本会非常明显。
5.4 一句话记忆
二级索引给你的是 地址(主键);回表是按地址去 主楼(聚簇索引)取包裹(完整行)。

第 6 章:覆盖索引——如何故意避免回表
6.1 什么是覆盖索引?
当 SELECT 用到的列 + WHERE 用到的列,已经全部包含在某个索引里时,优化器可以只扫描这棵索引,不必再访问聚簇索引取行——这叫 索引覆盖(Covering Index),也就 不需要回表。
例如建立联合索引:
INDEX idx_age_name (age, name)
则:
SELECT name FROM student WHERE age = 18;
可能只需扫描 idx_age_name:age 用于过滤,name 直接在索引叶子里,不必再按 id 回聚簇索引。
若业务经常是「多个条件一起过滤」,只在各列上建孤立单列索引往往不够理想——第 7 章会说明优化器通常只选其中一个;此时设计良好的 联合索引 往往更能一次缩小候选集(是否还能覆盖,仍取决于 SELECT 列)。
6.2 代价与权衡
- 好处:减少回表,降低 IO,对大结果集尤其明显。
- 代价:索引变宽,占更多磁盘;写入时要维护更多索引页。
这是典型的 用空间换时间,需按查询模式设计,不是索引越多越好。
第 7 章:多个单列二级索引同时出现在 WHERE 里——先走哪个?

前面几章默认「WHERE 只命中一个二级索引」。实际开发里更常见的是:多个条件、每个条件上各自有一个单列二级索引。这时很多人会问:
age、name、is_delete都有索引,MySQL 会不会三个都走?还是先走某个、再走其他?
核心结论先放在这里:
这三个是独立的单列二级索引,不是联合复合索引。
在常见路径下,优化器不会同时走多个索引,只会选其中一个它认为最优的索引;其余条件在回表拿到完整行之后再过滤。
(Index Merge 等特殊策略入门阶段可先不纠结;把「单索引 + 回表 + 过滤」这条主路径记牢即可。)
7.1 表结构与 SQL(延续 student 示例)
在第 1 章的表上,再补两个单列索引和软删标记,变成:
CREATE TABLE student (
id INT PRIMARY KEY,
name VARCHAR(50),
age INT,
class_id INT,
is_delete TINYINT DEFAULT 0,
INDEX idx_age (age), -- 单列二级索引
INDEX idx_name (name), -- 单列二级索引
INDEX idx_is_delete (is_delete) -- 单列二级索引
);
注意:这里是 三棵独立的二级索引树,不是 INDEX idx_age_name_delete (age, name, is_delete) 这种联合索引。
查询:
SELECT * FROM student
WHERE age = 18
AND name = '张三'
AND is_delete = 0;
7.2 完整执行步骤拆解
步骤 1:优化器估算——各索引大概要扫多少行
MySQL 会结合 索引统计信息 / 直方图,分别估算「若只走这个索引,大概命中多少行」:
| 候选索引 | 条件 | 估算命中行数(记为 N) |
|---|---|---|
idx_age |
age = 18 |
N1(例如几百行) |
idx_name |
name = '张三' |
N2(例如几乎唯一,约 1 行) |
idx_is_delete |
is_delete = 0 |
N3(例如全表 90%,很大) |
估算越准,越容易选对「最省代价」的那条路。
步骤 2:选择最优索引(核心规则)
优化器挑选 估算行数更少、整体代价更低 的那个索引,作为本次查询真正用来扫描的索引。
沿用上面的直觉数字:
name = '张三'几乎唯一 → N2 ≈ 1age = 18几百行 → N1 较大is_delete = 0覆盖绝大部分数据 → N3 极大
→ 最终选 idx_name 执行查询。
不是「三个索引轮流都扫一遍」,也 不是「先扫 age 再扫 name」。
正常是:只选一个最优单列索引。
步骤 3:只扫描被选中的那条二级索引
以选中 idx_name 为例:
- 去
idx_name这棵二级索引 B+ 树,找到所有name = '张三'的叶子条目; - 叶子节点存的是:索引列
name+ 主键id(和第 4 章一致); - 此时手里只有一批主键 ID,还没有
age、is_delete,更没有整行。
对应前面已出现过的叶子形态:
idx_name 的叶子(示例):
(name='张三', id=3)
(name='李四', id=1)
...
步骤 4:回表——拿主键去聚簇索引取完整行
对每个候选 id,到 聚簇索引(主键索引) 上读取完整行,得到:
id=3 → (name='张三', age=18, class_id=1, is_delete=0)
...
这一步就是第 5 章讲的 回表:二级索引只给了门牌号,完整档案要回主楼取。
因为是 SELECT *,所需列不可能只靠 idx_name 覆盖(见第 6 章),一定要回表。
步骤 5:内存里过滤「没用上索引」的条件
回表拿到完整行后,在 Server 层用剩余条件过滤:
age = 18 AND is_delete = 0
- 符合:保留,进入结果集;
- 不符合:丢弃,不返回给客户端。
也就是说:age、is_delete 并没有各自再走一遍自己的索引树;它们在「已经回表拿到的行」上做普通条件判断。
步骤 6:汇总返回客户端
所有通过过滤的行组成结果集,返回。
7.3 用一张图串起来
整条路径可以看成五步:估算 → 只选一个最优索引 → 扫二级索引拿 id → 回表取整行 → 内存过滤剩余条件。

对应关系再对照一下:
7.4 和「联合索引」的关键对比
| 对比点 | 三个单列二级索引(本章) | 联合索引 INDEX (name, age, is_delete) |
|---|---|---|
| 索引树数量 | 三棵独立的二级索引树 | 通常一棵联合索引树 |
| WHERE 多条件 | 一般 只选一个 索引扫,其余条件回表后过滤 | 可能在 同一棵 索引树上用多个列缩小范围 |
SELECT * |
几乎总要回表 | 仍要回表拿其它列,但定位阶段可能更准 |
所以:列上「都建了索引」,并不等于「这次查询会用上全部索引」。若要少回表,还要和 第 6 章的覆盖索引 一起考虑(联合索引列是否覆盖 SELECT)。
7.5 本章 takeaway
- 多个单列二级索引 ≠ 联合复合索引。
- 常见执行路径:优化器选一个最优索引 → 二级索引取主键 → 回表取整行 → 剩余条件过滤。
- 选择性高的条件(如几乎唯一的
name)更容易被选中;像is_delete = 0这种低选择性条件,单独走索引往往不划算。
第 8 章:总流程图与自测
8.1 查询走哪条路?(总览)
8.2 自测题(建议先闭卷再对答案)
Q1. 主键、聚簇索引键、聚簇索引在 student 表里分别对应什么?
- 主键:
id - 聚簇索引键:一般就是
id - 聚簇索引:按
id组织、叶子存完整行数据的那棵 B+ 树
Q2. idx_age 的叶子上有哪些列?没有什么?
- 有:
age,以及主键id - 没有:
name、class_id等其他列
Q3. 什么情况下会回表?什么情况下不会?
参考答案- 会:使用二级索引,且 SELECT 还需要索引里不存在的列
- 不会:只查索引里已有的列(覆盖索引);或直接用主键在聚簇索引上查
Q4. B+ 树叶子链表对 WHERE age BETWEEN 18 AND 20 有什么帮助?
找到起始叶子后,可以沿链表顺序扫描到结束边界,而不必反复从根节点查找。
Q5. WHERE age=18 AND name='张三' AND is_delete=0,三个字段各自有单列二级索引时,会不会三个索引都走?流程是什么?
- 一般不会三个都走;优化器用统计信息估算后,通常 只选一个最优单列索引(例如选择性最好的
idx_name) - 流程:扫描该二级索引拿主键 id → 回表读完整行 → 在内存里过滤其余条件(如 age、is_delete)→ 返回
- 这与「建一个
(name, age, is_delete)联合索引」不是一回事
8.3 30 秒口述检验
如果你能不看笔记说出下面这段话,说明已经掌握主线:
InnoDB 表数据按主键存在聚簇索引的 B+ 树里;
age上的二级索引是另一棵树,叶子只有age和id。查name时要先用age找到id,再按id去聚簇索引取整行,第二次就是回表。
若 WHERE 里多个单列二级索引条件同时出现,优化器通常只选一个最优索引扫树,回表后再过滤其余条件——「列上都有索引」不等于「这次查询把所有索引都用上」。
附录:术语速查表
| 中文 | 英文 | 在本文明中的含义 |
|---|---|---|
| B+ 树 | B+ Tree | 索引背后的多叉平衡树,数据在叶子且叶子相连 |
| 聚簇索引 | Clustered Index | 叶子存整行,表数据按主键顺序组织 |
| 二级索引 | Secondary Index | 非主键索引,叶子多为「索引列 + 主键」 |
| 主键 / 聚簇索引键 | PK / Clustered Key | 定位一行在聚簇树中位置的唯一键,常为 id |
| 回表 | Table Lookup | 二级索引拿到主键后,再查聚簇索引取完整行 |
| 覆盖索引 | Covering Index | 查询列全在索引内,无需回表 |
| 单列二级索引 | Single-column Secondary Index | 只包含一列的二级索引;多个条件时常只选其一 |
整理完毕,完结撒花~ 🌻
更多推荐




所有评论(0)