二级索引与回表:一次回表到底多贵

有个接口慢了很久,列表页 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 只能:

  1. 扫 20 万条索引记录
  2. 逐条回表,把 20 万行的 created_at 取出来
  3. 在内存(或磁盘临时文件)里对 20 万行排序
  4. 取前 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)干的事,逻辑只有三步:

  1. 二级索引扫出的主键,先塞进 read_rnd_buffer(默认 256 KB)
  2. 缓冲区满了或扫完了,把里面的主键排序
  3. 按排好的顺序批量回表

效果:原本 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 行,但它必须:

  1. 扫过 100010 条 city='北京' 的索引记录
  2. 对前 100000 条逐条回表 (因为它要 SELECT *,而你没法只取 10 行就停------万一前 10 万行里有被过滤掉的?这里没有别的过滤条件,但只要 SQL 里有 SELECT *,MySQL 就得老老实实回表再判断)
  3. 丢掉前 10 万行
  4. 返回 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,那里写着真相。

下一篇聊覆盖索引:它为什么能免回表,以及为什么"索引列越多越好"是错的。

相关推荐
CarIise1 小时前
JDBC 与数据库事务核心
数据库·笔记
见闻小天地1 小时前
新手入门:印刷包装设备的伺服和PLC运动控制方案各管什么?七步选型讲清楚
运维·网络·数据库·人工智能
科威网络1 小时前
小程序开发性能优化:分包、预加载与启动时间
性能优化·微信小程序·小程序
海鸥-w2 小时前
Redis 配置与排错说明
java·数据库·redis·bootstrap
jaysee-sjc2 小时前
【苍穹外卖】Day12:工作台数据看板与 Excel 报表导出
java·开发语言·数据库·spring boot·spring·微信·excel
sunburn-3 小时前
数据库聚合查询完整教程:从基础函数到实战统计
java·数据库·mysql·数据库开发
小蒜学长3 小时前
基于 Spring Boot 的英语单词背诵系统的设计与实现(代码+数据库+LW)
java·数据库·spring boot·后端
中电金信3 小时前
开发更高效、监控更实时、运维更省心,源启数据开发平台构筑一站式数据开发新底座
网络·数据库·人工智能
泡海椒3 小时前
深度解析JQuick-SQL 谓词下推与投影下推优化机制
数据库·sql