有个接口慢了很久,列表页 4 秒。SQL 长这样:
sql
SELECT id, order_no, amount, status, created_at
FROM orders
WHERE city = '北京'
ORDER BY created_at DESC
LIMIT 20;
city 上有索引。EXPLAIN 显示走了 idx_city,type=ref,看起来没毛病。
于是我建议加一个联合索引 (city, created_at)。加完之后,接口变成 6 秒。
同事的表情很精彩。
原因不复杂:新索引让优化器更愿意用索引了,于是它老老实实扫了 20 万条 city='北京' 的索引记录、回表 20 万次、排序、取前 20 条。而旧计划下它可能只是扫了全表。
加索引不一定变快,因为它可能把"顺序扫描"换成了"20 万次随机跳转"。
这篇文章就讲清这一件事:回表到底贵在哪,贵多少,什么时候该躲开它。

一、二级索引的叶子上,没有你要的列
先回到最基础的一张图。
InnoDB 有两类索引,长得几乎一样,区别只在叶子上装什么:
| 聚簇索引(主键) | 二级索引(其他) | |
|---|---|---|
| 数量 | 一张表只有一个 | 可以有多个 |
| 叶子存什么 | 完整的一行 | 索引列 + 主键 |
| 能不能直接拿到所有列 | 能 | 不能 |
| 谁在排序 | 主键 | 索引列,值相同时按主键 |
所以 idx_city 的叶子上,只有 city 和 id 两样东西。
你要 order_no、amount、status、created_at,它一个都没有。
它唯一能给你的,是主键。 拿着主键回到聚簇索引再查一次,这一步就是回表。
那为什么不直接存"行地址"?MyISAM 就是存行地址的。因为 InnoDB 的表会分裂、会重排、页会挪位置------行地址是会变的,主键不会。存主键,代价是每次多一次查找;存地址,代价是每次数据移动都要改所有索引。InnoDB 选了前者。

这张图是全文的骨架:
- 上半部分:二级索引三层,第 1、2、3 次页访问,拿到
id = 4217 - 下半部分:聚簇索引三层,第 4、5、6 次页访问,拿到整行
- 一次走二级索引的查询 = 6 次页访问
而主键查询只要 3 次。
顺便说个常被忽略的细节:二级索引的叶子按「索引列 + 主键」排序 。也就是说,city 相同的记录,在索引里是按 id 升序排的,不是按 created_at。这就是为什么 WHERE city='北京' ORDER BY created_at 需要额外排序------索引里的顺序帮不上忙。
二、回表贵在哪:随机
6 次页访问听起来不多,真正的成本不在次数,在这 6 次里有多少次是随机跳转。
前面讲过,B+ 树三层里:
- 根页 1 页,常驻内存
- 第二层约 1170 页 ≈ 18 MB,基本常驻
- 叶子层占绝大部分空间,能不能命中看 Buffer Pool
单次查询,前两层的两次访问几乎必然是内存命中;叶子层那一次,可能是内存,也可能是磁盘。
回表把这个结构破坏掉了。
原因在于:二级索引扫出来的主键,顺序是乱的。
假设 idx_city 扫出 1000 条记录,对应的主键是:
18, 9021, 431, 77230, 5, 991, 33502, ...
按这个顺序去聚簇索引取行,每一次都是一个独立的、几乎随机的位置。1000 条就是 1000 个随机地址。
对比一下全表扫描:全表扫是按页顺序读的,读完第 N 页读第 N+1 页,InnoDB 的预读还会一次把相邻 64 页拉进来。顺序 IO 和随机 IO,在 SSD 上差 3~5 倍,在机械盘上差两个数量级。
更糟的是,回表时每一行都要完整走三层 B+ 树。Buffer Pool 小的时候,第二层都可能被挤出去,于是"每行 3 次页访问"变成"每行 1~2 次磁盘读"。
所以回表真正的成本公式是:
回表总成本 ≈ 结果行数 × 单次随机读的代价 × (1 - 缓存命中率)
行数越多、缓存越小,这个数字越吓人。
回表 + filesort:两次随机叠加
回表本身还不算最坏。真正让人头疼的是回表和排序叠在一起。
还看开头那条 SQL:
sql
SELECT * FROM orders
WHERE city = '北京'
ORDER BY created_at DESC
LIMIT 20;
idx_city 的叶子按 (city, id) 排序。按 city 找到的 20 万条记录,在索引里是按 id 排的,不是按 created_at。所以 MySQL 只能:
- 扫 20 万条索引记录
- 逐条回表,把 20 万行的
created_at取出来 - 在内存(或磁盘临时文件)里对 20 万行排序
- 取前 20 条
20 万次回表 + 一次 20 万行的排序。
如果排序数据超过 sort_buffer_size,MySQL 还要把中间结果写到磁盘临时文件,做多路归并------这又是几十兆的随机读写。
所以这类查询的耗时,往往是"回表 3 秒 + 排序 2 秒",而不是单纯的回表问题。优化的时候把它当成一个问题看,就会一直调不对。
正确的做法是让索引同时满足过滤和排序:建 (city, created_at) 联合索引,WHERE city='北京' ORDER BY created_at DESC 就能沿索引顺序直接读前 20 条,回表 20 次,排序 0 次。
这也是第三章那个"加了索引反而更慢"的由来。旧索引下优化器可能选择"全表扫描 + filesort"(顺序读 + 排序),新索引下它选择了"索引扫描 20 万条 + 20 万次回表 + 排序"。后者在回表上亏得太多。
所以加索引之前要先问自己:这个索引能同时解决过滤和排序吗? 只能解决一半的索引,有时反而把执行计划带偏。
三、算一笔账:1000 行值多少钱
用具体数字说话。假设 SSD,Random 4K read = 100 μs(这是相当乐观的估计,云盘上经常是 200~500 μs)。

| 读法 | 页访问次数 | 磁盘随机读 | 耗时 |
|---|---|---|---|
| 覆盖索引(不回表) | 3000(全在索引上) | 0~几 次 | 约 1.2 ms |
| 回表,90% 命中 Buffer Pool | 6000 | 100 次 | 约 11 ms |
| 回表,全部未命中 | 6000 | 1000 次 | 约 100 ms |
| 全表扫描(1 GB,冷缓存) | 6.4 万页 | 顺序读 | 约 1 s |
| 全表扫描(1 GB,全在内存) | 6.4 万页 | 0 | 约 150 ms |
同样 1000 行结果,最快和最慢差 80 倍(1.2 ms vs 100 ms)。
但注意最后两行。到了 1000 行这个量级,全表扫描(1 秒)反而是最慢的------它的成本是常数,和你要几行无关,所以只在"要的行特别多"的时候才划算。
这条分界线落在哪里,是下一节的内容。
把这张表记住,后面所有优化手段,本质上都是在做同一件事:减少随机跳转的次数,或者把随机变成顺序。
云上的随机读更贵
上面用的是 100 μs。这个数字在本地 NVMe 上还算合理,但在云数据库上要打个折扣。
主流云盘的性能是按 IOPS 配额卖的:一块 500 GB 的 ESSD 可能只给 5000 IOPS,折下来每块盘每秒最多 5000 次随机读,也就是"每 200 μs 一次"------这已经是最理想的情况,实际还要排队。
更关键的是计费。很多云盘的收费模式是"IOPS + 吞吐 + 容量"三项分开算,随机读打满 IOPS 配额,账单直接翻倍。
于是一个很现实的现象:本地测出来"回表 1000 次只要 100 毫秒",上了云变成 300 毫秒,而且账单上多出一截。
所以云上做优化,回表的优先级比本地更高。在云上,减少一次随机读省下的不只是时间,还有钱。
四、优化器的临界点:10%~30%
既然回表这么贵,为什么不干脆永远走全表扫描?
因为行数少的时候,回表还是划算的。

两条线:
- 全表扫描:成本几乎恒定,跟"你要几行"无关。它要把整张表读一遍(或者读到满足条件为止)。
- 二级索引 + 回表:成本跟结果行数成正比。行数翻倍,成本翻倍。
两条线必然有一个交点。交点左边走索引,右边走全表。
这个交点大概在哪里?算一下就知道了。
100 万行的表、1 GB,全表扫一遍约 1 秒 。回表的平均成本是多少?不是每次都 100 μs------扫出来的行往往在相邻的页里,Buffer Pool 命中率不低,实测平均在 5~10 μs/行。
于是:
回表成本 ≈ 命中行数 × 7 μs
全表成本 ≈ 1 s(常数)
交点:命中行数 ≈ 1 s ÷ 7 μs ≈ 14 万行 = 全表的 14%
这就是"10% 上下"这个经验值的由来。但它不是铁律,实际会落在 5%~30% 之间,取决于四个因素:
| 因素 | 影响 |
|---|---|
| 行宽 | 行越窄,全表扫得越快,临界点左移(更早放弃索引) |
| 索引选择性 | 选择性越好,命中行越少,越靠交点左侧 |
| Buffer Pool 命中率 | 命中率越高,回表越便宜,临界点右移 |
| 是否需要排序 | 走索引能免掉排序时,优化器更愿意用它 |
这也解释了一个常见的困惑:"为什么我加了索引,它还是走全表?"
因为对它来说,那不是"没走索引",那是"算完账发现全表更便宜"。
反过来,如果你确信它选错了,FORCE INDEX 是最差的选择:
sql
-- 别这样
SELECT * FROM orders FORCE INDEX (idx_city) WHERE city = '北京';
因为索引的统计信息是采样估算的,可能不准;但更常见的情况是优化器是对的 ------只是它没有告诉你"回表 20 万次"这件事。真要判断,得看 Handler 计数器(第七节)。
正确的做法不是 force,而是把回表次数降下来:加覆盖索引,或者改成分页。
五、把随机变顺序:MRR
回表的随机性有个前提:我们按二级索引的顺序去回表。
那如果先按主键排序,再按顺序回表呢?

这就是 MRR(Multi-Range Read)干的事,逻辑只有三步:
- 二级索引扫出的主键,先塞进
read_rnd_buffer(默认 256 KB) - 缓冲区满了或扫完了,把里面的主键排序
- 按排好的顺序批量回表
效果:原本 10 次随机跳转,变成一次范围内的顺序读。SSD 上快 2~4 倍,机械盘上快一个数量级。
sql
-- 默认开启(8.0),可以确认一下
SHOW VARIABLES LIKE 'optimizer_switch';
-- 输出里应该有 mrr=on,mrr_cost_based=on
-- 想强制试一下效果(只对当前会话)
SET optimizer_switch = 'mrr=on,mrr_cost_based=off';
mrr_cost_based=on 的意思是"让优化器自己算 MRR 划不划算"。问题是它的估算经常偏保守,尤其是结果集大小估不准的时候。所以有些团队会在明确的大范围扫描场景下临时关掉它。
MRR 也不是万能的,三个情况下用不上:
- 结果集很小(排序开销盖过收益)
- 已经是覆盖索引(根本不需要回表)
- 优化器算出来不划算(代价模型说了算)
read_rnd_buffer 调大就更好吗
很多人看到 MRR 就去调 read_rnd_buffer_size,然后发现没变化。
原因在于:这个缓冲区是每会话分配的,不是共享池。默认 256 KB,能装大约 1.8 万个主键(8 字节 + 开销)。也就是说,一次回表批次最多排 1.8 万个主键。
调大它的收益边界很清楚:
| 结果集行数 | 调大有用吗 | 说明 |
|---|---|---|
| < 1 万 | 没用 | 默认就装得下 |
| 1 万 ~ 10 万 | 有一点 | 减少批次、减少一遍排序 |
| > 10 万 | 有风险 | 每会话几百 MB,并发一高就 OOM |
真正该做的是别让它一次回表 10 万次。缓冲区只是让同样多的随机读更集中,不是让它们消失。
还有一个隐蔽的限制:MRR 只在 range / ref 访问且需要回表时才生效。如果你的查询被优化器判成全表扫描,调这个参数一点用都没有。先看 EXPLAIN 的 type 和 Extra,再决定要不要动参数。
六、深分页:10 万次回表的现场
回表最惨烈的场景,是深分页。
sql
SELECT * FROM orders
WHERE city = '北京'
ORDER BY id
LIMIT 100000, 10;
你只要 10 行,但它必须:
- 扫过 100010 条
city='北京'的索引记录 - 对前 100000 条逐条回表 (因为它要
SELECT *,而你没法只取 10 行就停------万一前 10 万行里有被过滤掉的?这里没有别的过滤条件,但只要 SQL 里有SELECT *,MySQL 就得老老实实回表再判断) - 丢掉前 10 万行
- 返回 10 行

按 100 μs 一次算,10 万次回表 = 10 秒。
优化方案一:延迟关联。
sql
SELECT o.* FROM orders o
JOIN (
SELECT id FROM orders
WHERE city = '北京'
ORDER BY id
LIMIT 100000, 10
) t ON o.id = t.id;
关键在于子查询里只 select id 。这样它就是覆盖索引(idx_city 的叶子上有 id),10 万次"查 id"全在索引里完成,不回表。外层只对最终的 10 个 id 回表。
耗时从 10.8 秒降到 0.12 秒。
优化方案二:游标分页。
sql
SELECT * FROM orders
WHERE city = '北京' AND id > 900000
ORDER BY id
LIMIT 10;
记住上一页最后一个 id,下一页从它之后开始。复杂度 O(1),代价是不能跳页。
优化方案三,也是最好的方案:业务上不许翻到第 5000 页。 大多数产品根本不需要这个能力,限制最大页数比任何 SQL 优化都便宜。
顺便说:JOIN 里的隐形回表
回表不只发生在单表查询里。JOIN 的时候,它换了个名字叫"被驱动表的随机访问"。
sql
SELECT o.id, u.name
FROM orders o
JOIN users u ON u.id = o.user_id
WHERE o.city = '北京';
如果 users 是驱动表、orders 是被驱动表,走 idx_city,那就是上文的回表场景。
如果 orders 是驱动表,MySQL 会做嵌套循环:扫出 20 万条订单,对每一条去 users 里按主键查一次------20 万次点查。
这时候 users 的主键点查是"一次三层树遍历"。20 万次 = 60 万次页访问。即便是内存命中,也要 60 万 × 100 纳秒 = 60 毫秒,加上真实的磁盘读,很容易上秒。
三个缓解办法:
- 让被驱动表走索引 (
users.id是主键,已经是最优),并把users表尽可能小、常驻内存 - 用
EXPLAIN看rows的乘积,20 万 × 1 就是 20 万次访问 - 能用
IN就用IN:WHERE id IN (subquery)时优化器有机会把子查询结果排序去重,减少重复访问
在 EXPLAIN 的 Extra 里,Using join buffer (Block Nested Loop) 说明连接字段没索引,这时候被驱动表是全表扫描------那就不只是回表了,是每一行都扫一遍全表。
七、把回表次数数出来:Handler 计数器
上面所有分析都建立在"回表 N 次"这个假设上。怎么验证?
用 Handler 系列计数器。它是 Server 层统计"向引擎要了多少次数据"的计数器,每次执行完都能看。

三个最关键的:
| 计数器 | 含义 | 怎么用 |
|---|---|---|
Handler_read_next |
按索引顺序读下一条 | 扫过多少条索引记录 |
Handler_read_key |
按某个键定位一行 | 近似等于回表次数 |
Handler_read_rnd_next |
全表顺序读下一页 | 有没有走全表 |
实测一段:
sql
FLUSH STATUS;
SELECT * FROM orders WHERE city = '北京' LIMIT 100000, 10;
SHOW SESSION STATUS LIKE 'Handler_read%';
会看到类似这样的结果:
Handler_read_key 100011 <-- 回表了 10 万次
Handler_read_next 100011 <-- 索引扫了 10 万条
Handler_read_rnd_next 0 <-- 没走全表
再把 SELECT * 换成 SELECT id:
sql
FLUSH STATUS;
SELECT id FROM orders WHERE city = '北京' LIMIT 100000, 10;
SHOW SESSION STATUS LIKE 'Handler_read%';
Handler_read_key 0 <-- 完全没有回表
Handler_read_next 100011
Handler_read_key 从 100011 掉到 0,这就是"覆盖索引生效"最直接的证据,比 EXPLAIN 里的 Using index 更硬------因为它是实际发生的次数,不是计划里的承诺。
八、四种手段,各自省掉哪一段
把前面的手段放在一起对比:
| 手段 | 原理 | 省掉什么 | 代价 |
|---|---|---|---|
| 覆盖索引 | 索引里包含所有需要的列 | 完全免回表 | 索引变大、写入变慢 |
| 延迟关联 | 子查询只取主键,外层再回表 | 把 N 次回表降到 10 次 | 多写一层子查询 |
| MRR | 主键排序后批量回表 | 把随机 IO 变成顺序 IO | 额外排序、内存占用 |
| 索引下推(ICP) | 在索引层就过滤掉不匹配的行 | 减少回表次数 | 5.6+ 默认开启,无需干预 |
注意 ICP 和其他三个的区别:它不减"每次回表的成本",它减"要回表的行数"。
举个能看懂的例子的:WHERE city='北京' AND name LIKE '张%',索引是 (city, name)。没有 ICP 时,引擎按 city 找到 1 万条记录,逐条回表,回到 Server 层才判断 name;有 ICP 后,name 的过滤在索引层就做掉了,可能只剩 300 条需要回表。
1 万次回表变 300 次,这就是 ICP 的价值。
一个完整的复盘:4 秒 → 60 毫秒
把前面的手段串起来,看一个真实的过程。
原始 SQL(列表页,按时间倒序,第 1 页):
sql
SELECT id, order_no, amount, status, created_at
FROM orders
WHERE city = '北京' AND status IN (1, 2)
ORDER BY created_at DESC
LIMIT 20;
表 2000 万行,city='北京' 约 200 万行,status IN (1,2) 占 90%。索引只有一个 idx_city。
EXPLAIN:type=ref,rows=2000000,Extra=Using where; Using filesort,实际耗时 4.1 秒。
第 1 步:看计数器,确认问题在回表。
sql
FLUSH STATUS;
-- 执行上面的 SQL
SHOW SESSION STATUS LIKE 'Handler_read%';
Handler_read_key = 2000000。两百万次回表,问题确认。
第 2 步:建联合索引,让排序也走索引。
sql
ALTER TABLE orders ADD INDEX idx_city_ctime_status (city, created_at, status);
这里顺序有讲究:city 是等值条件放最前;created_at 是排序字段放中间;status 放最后,让它进入索引做过滤。
改完后 rows=2000000 变成了 rows=20,Extra 里 Using filesort 消失了。耗时 380 毫秒。
第 3 步:让索引覆盖查询。
现有索引里有 city, created_at, status,但查询还要 id, order_no, amount。id 是主键(索引叶子自带),order_no 和 amount 没有,所以还要回表 20 次------不过只有 20 次,可以忽略。
真正的收益在别处:如果只需要 id, created_at, status(比如先出列表骨架,详情另查),这个索引就能完全覆盖,回表 0 次:
sql
SELECT id, status, created_at
FROM orders
WHERE city = '北京' AND status IN (1, 2)
ORDER BY created_at DESC
LIMIT 20;
耗时 12 毫秒。
第 4 步:分页不再是 LIMIT offset。
翻到第 100 页时,LIMIT 2000, 20 又要扫 2020 条。改成游标分页:
sql
SELECT id, order_no, amount, status, created_at
FROM orders
WHERE city = '北京' AND created_at < '2026-10-20 12:00:00'
ORDER BY created_at DESC
LIMIT 20;
耗时 60 毫秒(第 1 页 12 毫秒,第 100 页 60 毫秒,基本持平)。
整体结果:
| 阶段 | 耗时 | Handler_read_key |
|---|---|---|
| 原始 | 4100 ms | 200 万 |
| 加联合索引 | 380 ms | 20 |
| 覆盖查询列 | 12 ms | 0 |
| 游标分页(第 100 页) | 60 ms | 20 |
从 4.1 秒到 12 毫秒,三个改动:换索引列顺序、砍掉不必要的列、把 offset 换成游标。没有一条是"调参数"。
这里最值钱的一步其实是第 1 步------用计数器确认了问题在回表。如果没有这一步,很容易去怀疑连接池、怀疑网络、怀疑磁盘,绕一大圈。
九、五个判断,决定要不要管回表
不是所有回表都值得优化。按这五个问题过一遍:
① 结果集有多大?
几十行以内的回表,忽略它。回表真正变成问题时,量级通常在上千行。
② 这些列真的都需要吗?
SELECT * 是最常见的元凶。把不需要的列去掉,很多查询会自然变成覆盖索引。
③ 主键是不是宽?
二级索引叶子存主键。主键 36 字节 vs 8 字节,同样的索引,体积差 30% 以上,能装的条目少,扫描时读的页多。
④ 有没有排序?
如果排序能用上索引顺序,省掉的不只是回表,还有一次 filesort。这时候即便回表贵一点,也值得。
⑤ 缓存够不够?
Buffer Pool 装得下热数据时,回表就是内存操作,100 纳秒的事。装不下的时候,才是 100 微秒。先看命中率,再谈优化。
十、三句话收尾
第一,二级索引的叶子只有索引列和主键。 这不是设计缺陷,是权衡:主键稳定,行地址不稳定。代价就是回表。
第二,回表贵在随机,不在次数。 1000 次随机读 = 100 毫秒;而这 1000 行如果本来就挨在连续的 63 个页里,顺序读只要 1 毫秒。所有优化手段都是在把随机变顺序、或者把次数变少。
第三,临界点在 10%~30%。 低于它,索引划算;高于它,全表扫描才是对的。别用 FORCE INDEX 跟优化器较劲,去看 Handler_read_key,那里写着真相。
下一篇聊覆盖索引:它为什么能免回表,以及为什么"索引列越多越好"是错的。