MySQL 回表为什么这么慢?5 种优化手段逐个拆解

一句话结论

回表不必然产生随机 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]

好,铺垫完了。下面是五种真正能减少回表的手段,各管一段场景。

覆盖索引:让第二次查询直接消失

最常见、最核心的一招。查询要的所有字段都能从索引里直接拿到,不用回表。

sql 复制代码
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 * 和显式列名,执行计划完全不同。

联合索引:等值在前,范围紧跟,覆盖字段垫底

覆盖索引不等于把所有字段都塞进索引。字段越多,写入成本、存储成本、维护成本越高。它是围绕高频场景定制的,不是字段仓库。

订单按用户查、按状态过滤、按创建时间排序,只展示订单号、金额、状态、创建时间,可以考虑这样一个联合索引:

sql 复制代码
ALTER TABLE orders ADD INDEX idx_user_status_time (
  user_id, status, create_time, order_no, amount
);

顺序规则:等值条件在前,范围条件紧随其后,纯粹用来覆盖的展示字段放最后。

这也是最左前缀原则的用法------跳过前面的字段去查后面的,索引基本白建。

深分页:延迟关联治标,游标分页治本

深分页场景最常见。直接查第 10 万页的完整数据,MySQL 要扫大量索引、产生大量回表。

sql 复制代码
-- 慢:扫十万行索引 再逐条回表
SELECT id, order_no, amount, status, create_time
FROM orders WHERE user_id = 10086
ORDER BY create_time DESC
LIMIT 100000, 10;

改成两步:先用覆盖索引查出这一页的主键,再拿这些 ID 回表取完整数据。回表次数从成千上万次,压缩到只要的那几十条。

sql 复制代码
-- 快:子查询只查主键 外层只回表 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:

sql 复制代码
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、加索引解决,垂直分表则要动表结构、改写入逻辑。你们线上遇到深分页慢查询,是拆表、加覆盖索引,还是直接从产品层面砍掉翻页功能?评论区聊聊。

有用的话点个赞。

相关推荐
第七页独白1 小时前
汽车零件厂如何通过 QMS 真正落地 IATF 16949——QMS软件系统:品质检验-内审稽核-8d客诉管理:全星质量管理软件系统
java·前端·数据库
晚安日记wanna1 小时前
索引建了却不走?7 个失效原因逐层排查
数据库·mysql·性能优化
工作10年+,存储芯片行业1 小时前
Linux NVMe 中断排查与性能优化:CPU 亲和性
linux·运维·服务器·windows·性能优化·ssd·pcie
KID1412号1 小时前
PostgreSQL入门
数据库·postgresql
Elastic 中国社区官方博客1 小时前
使用 Lucene 搜索你的 Bean — 索引
java·大数据·数据库·elasticsearch·搜索引擎·全文检索·lucene
ShineWinsu3 小时前
对于Redis:主从复制的解析
linux·数据库·c++·redis·缓存·面试·主从复制
许彰午3 小时前
03-Linux环境准备依赖包内核参数与用户组
linux·运维·服务器·数据库
阳光九叶草LXGZXJ3 小时前
达梦数据库-报错-16-MERGE INTO提示:无效的列名[XX]
linux·运维·数据库·sql·学习
墨天梦3 小时前
B10_文件SharedPreferences与SQLite
jvm·数据库·sqlite