MySQL 单点详解(03):聚簇索引与二级索引,回表为什么贵、覆盖索引怎么省掉它
写在前面
上一期讲清了 B+ 树为什么矮、为什么有序,这一期往下走一步:树上的叶子到底放了什么。
面试官的常见问法:
- 什么是回表?为什么说它慢?
- 覆盖索引是什么?怎么判断用上了?
- 为什么建议少写
select *?
所有答案都指向同一句:
主键索引的叶子里放的是整行,二级索引的叶子里放的是主键。
所以按二级索引查,拿到主键之后还得再查一次主键索引。这第二次查找就叫回表。
回表不是多一次内存查找,是多下一次树、多一次随机读盘。代价全在这里。
一、一张表,两种索引
InnoDB 把数据直接挂在主键索引上,这种表叫索引组织表。
意思是:没有独立的数据文件,数据就是主键索引的叶子。
| 维度 | 聚簇索引(主键) | 二级索引(普通、唯一) |
|---|---|---|
| 叶子存什么 | 整行数据 | 索引列 + 主键值 |
| 一个表有几个 | 只有一个 | 可以有多个 |
| 能否直接拿到行 | 能 | 不能,必须回表 |
| 叶子上的顺序 | 按主键有序 | 按索引列有序 |
| 树的数量 | 一棵 | 每个索引一棵 |
所以一张表有几个索引,就有几棵 B+ 树,但只有一棵树的叶子上有完整数据。
一条查询走哪棵树,取决于 where 里用的是哪个索引。
用主键查,走聚簇索引,一次到位;用普通列查,先走那棵二级索引,再补一次回表。
其余树的叶子都是"目录条目",只够用来定位,不够用来返回。
主键是谁也不是随便定的,有明确的挑选顺序:
| 顺序 | 选谁当聚簇索引 | 说明 |
|---|---|---|
| 1 | 显式声明的主键 | 最常见 |
| 2 | 第一个非空唯一索引 | 没写主键时 |
| 3 | 隐式 6 字节 row_id | 两者都没有,全局共享一个计数器 |
第三条要留意:隐式 row_id 是全局的,不是每张表一个。所有没主键的表共用同一个自增,删了数据也不回收。
二、回表到底贵在哪
举个具体场景:表里一千万行,name 上有索引,执行
sql
select * from t where name = 'tom';
假设 name 不唯一,命中 100 行。
| 步骤 | 动作 | 页访问 |
|---|---|---|
| 1 | 在 name 索引上从根走到叶,定位到 'tom' | 约 3 页(树高 3) |
| 2 | 沿叶子往后扫,收集命中的 100 个主键 | 多数在上一步读过的页里 |
| 3 | 用这 100 个主键逐个回表 | 约 100 次随机页读 |
前面 3 次是顺序的、可预读的;后面 100 次是散开的、跳着读的。
这就是回表贵的真正原因:不是多查了一次,是多了一百次随机 IO。
如果表的行宽大,一页只放十几行,这 100 次几乎每次都要真读盘。
命中缓存时会快很多,但缓存不是保证,写多的库随时会把页刷掉。
回表还是一行一行来的,凑不成批。命中越多,这些随机读就越难合并成顺序读。
所以"范围查询 + 大结果集"最吃亏:索引扫得飞快,回表把它拖垮。
判断一条 SQL 贵不贵,先看它要回表多少行,而不是看它扫了多少行。
三、覆盖索引:干脆不回表
如果查询要的列,索引里已经全有了,就不用回表。
这种索引叫覆盖索引 ,explain 里表现为 Extra: Using index。
还是那张表,name 上有索引,比较三种写法:
| 查询 | 能否覆盖 | 代价 |
|---|---|---|
select * from t where name='tom' |
否 | 命中 100 行就回表 100 次 |
select id, name from t where name='tom' |
能,叶子本来就有 id | 只读索引,不回表 |
select name from t where name='tom' |
能 | 只读索引,不回表 |
注意第二条:为什么 id 不用回表?
因为二级索引的叶子存的就是"索引列 + 主键",主键天然在索引里。
这也是上一期说"主键要短"的第二个理由:主键越长,所有二级索引跟着一起变胖。
select * 之所以被反复提醒,原因也在这儿:它要的列一多,覆盖就不可能成立,必然回表。
四、怎么让索引覆盖住
反过来说,要返回的列里只要有一列不在索引上,覆盖就不成立,只能老老实实回表。
覆盖不是自动的,得靠索引设计:
- 把高频查询要返回的列,一起放进联合索引,例如
(name, age); - 顺序上把等值条件放前面,范围条件放后面,这样前缀还能用上;
- 别为了让索引覆盖,把十几列都塞进一个索引。
再补一层:联合索引 (name, age) 遇到 where name = ? and age > ?,age 也能在索引上直接判断,不用等回表。这就是下一节要说的索引下推。
最后一条要展开说,因为很容易过头:
| 做法 | 收益 | 代价 |
|---|---|---|
| 索引多带 1 到 2 个高频列 | 省掉回表,查询快几倍 | 索引变大,写入稍慢 |
| 索引带 5 个以上列 | 覆盖范围变大 | 索引膨胀、页变少、缓存命中率下降 |
| 每个查询建一个覆盖索引 | 单条查询最优 | 写入放大严重,维护成本高 |
索引是拿写入换读取的生意。读多写少的表可以多建,写密集的表要克制。
五、看执行计划时盯哪几个字段
explain 的输出字段很多,真正影响判断的就三个:key、rows、Extra。
key 是 NULL,说明压根没用上索引;rows 是估算值,不是精确行数,别拿它当准数看。
| Extra 常见值 | 含义 | 怎么理解 |
|---|---|---|
Using index |
覆盖索引 | 没回表,最好 |
Using index condition |
索引下推 | 条件下推到引擎层先过滤,少回表 |
Using where |
server 层过滤 | 数据取上来之后才判断 |
Using filesort |
需要额外排序 | 索引没帮上 order by |
Using temporary |
用了临时表 | group by、distinct 常见 |
Using index condition 值得单独记一句:它是 5.6 之后引入的索引下推。
本来条件是"先回表、再判断",它改成"在索引上先判断、不合格的就不回表了"。
所以同一条 SQL,有没有下推,回表次数可能差一个数量级。
六、面试官会往下问的三层
第一层:为什么二级索引的叶子存主键,不存行地址?
因为行地址会变。页分裂、行迁移之后,原来的地址就失效了,所有二级索引都得跟着改。
存主键则稳定,行挪了位置,主键不变,索引不用动。
MyISAM 存的就是行地址,因为它的索引和数据分成两个文件,它没有聚簇索引这一说。
追问一句:为什么聚簇索引只能有一个?因为整行数据只存了一份,存两份就不叫聚簇了。
第二层:没有主键会怎么样?
InnoDB 会退而求其次:先找第一个非空唯一索引,都没有就用隐式 6 字节 row_id。
问题是这个 row_id 全局共享,所有无主键的表都挤在一个计数器上,而且没法用它做业务查询。
第三层:覆盖索引是不是越多越好?
不是。索引本身要占空间、要在写入时维护,还要跟数据页抢 buffer pool。
覆盖索引解决的是"这条查询",代价是"所有写入"。判断标准还是读写比例。
顺便一个常被追问的点:select count(*) 为什么慢?
因为 InnoDB 不存总行数,得找一棵最小的索引扫一遍。这也是覆盖索引的一种用法------扫索引比扫整行便宜。
收尾
回表的本质是"第二次下树",覆盖索引的本质是"让第二次不必发生"。
理解了叶子上放什么,索引优化里大半的经验规则都能自己推出来。