一句话结论
回表不必然产生随机 IO,乱序主键回表才是。更坑的是:回表太贵时优化器会直接放弃索引去扫全表——执行计划里没索引,不代表索引写错了。
回表就是那趟回头路
图书馆的比喻最省事。聚簇索引是书架,整本书都在架上;二级索引是那本薄薄的检索小册子,上面只写了关键词和书号。
你按关键词翻小册子,找到书号,可小册子上没有正文,还得拿着书号跑回书架取书。这趟往返就是回表。如果小册子上直接写着你想要的那句话,这趟就省了——那就是覆盖索引。
落到 InnoDB 的物理结构上:聚簇索引的叶子节点存的是完整行数据,二级索引(辅助索引、非聚簇索引)的叶子节点只存索引列 + 主键值。
所以按手机号查用户信息时,MySQL 先走手机号索引拿到用户 ID。如果查询要的字段不在手机号索引里,就得拿着这个 ID 回聚簇索引再查一次。这第二次查询英文叫 Lookup。
flowchart LR
A[手机号条件] -->|走二级索引| B[二级索引叶子节点]
B --> C[拿到主键 ID 与索引列]
C --> D{要查的列都在索引里吗}
D -->|是| E[直接返回 覆盖索引]
D -->|否| F[拿主键 ID 回聚簇索引]
F --> G[聚簇索引叶子节点取整行]
G --> H[返回结果 这一步就是回表]
两个被传歪的说法
第一,聚簇索引不完全等于主键索引。 InnoDB 优先用主键做聚簇索引;没有主键,就选第一个非空唯一索引;两者都没有,才自动生成一个隐藏的 ROWID。你可以没有主键,但你一定有聚簇索引。
第二,回表不一定产生随机 IO。 回表读的是一页数据,这一页如果已经在 Buffer Pool 里,就是纯内存操作,磁盘都不用碰。只有当数据量大到 Buffer Pool 装不下、必须落盘读时,随机 IO 的代价才真正显出来。
这两条听起来像抠字眼,但面试里被追问时,能分清的人不多。
随机 IO 来自两个索引排序不一致
二级索引是按索引列有序的,取出来的主键值相对于聚簇索引是乱序的。拿这些乱序主键去聚簇索引捞整行,才形成随机访问。
机械盘上,随机 IO 比顺序 IO 可能差两个数量级;SSD 上差距小一些,但依然存在。更别说顺序 IO 还有预读价值——这一层的收益经常被忽略。
索引没走,可能是优化器主动放弃
这是我见过最高频的误判:索引明明建了,执行计划却不走,第一反应是"索引失效了"。
真实原因往往是回表次数太多。优化器会估算成本,一旦判断回表比全表扫描还贵,它就干脆放弃索引,顺序扫一遍全表。执行计划里没走索引,不一定是索引有问题,可能是回表太贵。
flowchart TD
A[收到查询] --> B[估算走二级索引的成本]
B --> C[估算回表次数与随机 IO 代价]
C --> D{回表成本 大于 全表扫描成本}
D -->|是| E[放弃索引 直接全表扫描]
D -->|否| F[走二级索引并回表]
E --> G[执行计划 key 为空 或 type 为 ALL]
F --> H[执行计划 key 有值 可能带索引条件下推]
MySQL 5.6 之后还有一个补救措施:MRR(Multi-Range Read)。它把需要回表的主键值先收集起来,按主键排序,再批量按序回表。
flowchart LR
A[二级索引取出主键] --> B[主键放进缓冲区排序]
B --> C[按主键顺序批量提交给 InnoDB]
C --> D[聚簇索引顺序读取页面]
D --> E[随机 IO 转成近似顺序 IO]
好,铺垫完了。下面是五种真正能减少回表的手段,各管一段场景。
覆盖索引:让第二次查询直接消失
最常见、最核心的一招。查询要的所有字段都能从索引里直接拿到,不用回表。
ALTER TABLE users ADD INDEX idx_phone_cover (phone, nickname, avatar);
EXPLAIN SELECT nickname, avatar FROM users WHERE phone = '138xxxx0000';
-- Extra: Using index
怎么判断生效了?看执行计划的 Extra:
| Extra | 含义 | 是否免回表 |
|---|---|---|
| Using index | 覆盖索引,所有列都从索引里取 | 是 |
| Using index condition | 索引条件下推 ICP,条件下推了,回表照旧 | 否 |
| Using where | 回表之后再过滤 | 否 |
Using index 和 Using index condition 是两回事,别混着答。
很多回表,是自己写出来的
页面只要 user_id、nickname、头像,SQL 却查了所有字段,命中索引也照样回表。
需要什么字段就查什么字段。尤其是 text、blob、扩展字段这些大字段,别让它们出现在高频列表查询里。Java 这边用 MyBatis 时,差别就在 XML 里——SELECT * 和显式列名,执行计划完全不同。
联合索引:等值在前,范围紧跟,覆盖字段垫底
覆盖索引不等于把所有字段都塞进索引。字段越多,写入成本、存储成本、维护成本越高。它是围绕高频场景定制的,不是字段仓库。
订单按用户查、按状态过滤、按创建时间排序,只展示订单号、金额、状态、创建时间,可以考虑这样一个联合索引:
ALTER TABLE orders ADD INDEX idx_user_status_time (
user_id, status, create_time, order_no, amount
);
顺序规则:等值条件在前,范围条件紧随其后,纯粹用来覆盖的展示字段放最后。
这也是最左前缀原则的用法——跳过前面的字段去查后面的,索引基本白建。
深分页:延迟关联治标,游标分页治本
深分页场景最常见。直接查第 10 万页的完整数据,MySQL 要扫大量索引、产生大量回表。
-- 慢:扫十万行索引 再逐条回表
SELECT id, order_no, amount, status, create_time
FROM orders WHERE user_id = 10086
ORDER BY create_time DESC
LIMIT 100000, 10;
改成两步:先用覆盖索引查出这一页的主键,再拿这些 ID 回表取完整数据。回表次数从成千上万次,压缩到只要的那几十条。
-- 快:子查询只查主键 外层只回表 10 次
SELECT o.id, o.order_no, o.amount, o.status, o.create_time
FROM orders o
JOIN (
SELECT id FROM orders
WHERE user_id = 10086
ORDER BY create_time DESC
LIMIT 100000, 10
) t ON o.id = t.id;
这个写法叫延迟关联。但前提必须说清:子查询里有 ORDER BY create_time,还要分页,create_time 上就得有可用索引(通常就是上面那条联合索引)。否则子查询自己就得排序上百万行,延迟关联一点忙都帮不上。
而且它省掉的只是无效回表。LIMIT 100000, 10 本身仍要扫过前面那上百万行,只是扫索引而不是扫整行。
想彻底不扫,用游标分页——记住上一页最后一条的 create_time:
SELECT id, order_no, amount, status, create_time
FROM orders
WHERE user_id = 10086
AND create_time < '2025-05-01 10:00:00'
ORDER BY create_time DESC
LIMIT 10;
前面那些行根本不碰。
冷热字段拆开:真收益在 Buffer Pool
一张表里既有高频小字段,也有低频大字段。用户表的昵称、头像、状态是高频;个人简介、配置 JSON、备注是低频。挤在一张宽表里,高频查询被迫拖着一堆用不上的数据。
做垂直分表:主表放高频字段,扩展表放低频字段。收益有两层,很多人只看到一层。
- 表层:列表查询更容易被索引覆盖。
- 更实在的一层:高频字段集中到窄表后,单页能装更多行,Buffer Pool 命中率明显提升,高频查询要读的页更少。
第二层才是真收益。
被问到时,按代价从低到高背
如果被问"MySQL 怎么减少回表":
覆盖索引是主干,不写 select * 是习惯,联合索引设计是根基,延迟关联治深分页,垂直分表治宽表。另外记住,执行计划没走索引不一定是索引失效,可能是回表太贵,优化器主动放弃了。
写在最后
这五种手段里,覆盖索引和联合索引设计能靠改 SQL、加索引解决,垂直分表则要动表结构、改写入逻辑。你们线上遇到深分页慢查询,是拆表、加覆盖索引,还是直接从产品层面砍掉翻页功能?评论区聊聊。
有用的话点个赞。