索引建了却没走:MySQL 在回表上算的那笔账

InnoDB 的二级索引叶子节点只存索引列和主键,查到主键后还要回聚簇索引取整行,这一步叫回表。回表是随机 I/O,行数一多,优化器有时宁可全表扫描也不走索引,于是出现“索引明明建了却没用上”。复合索引只能从最左列开始定位,范围条件之后的列无法继续缩小扫描范围;索引下推能把过滤提前到存储引擎,却仍可能要回表。理解了覆盖索引、最左前缀和代价估算,才能判断该加什么索引、为什么 EXPLAIN 里没有 Using index。


title: 索引建了却没走:MySQL 在回表上算的那笔账 date: 2026-11-05T10:00:00+08:00 type: kb tags: [科普] status: published summary: InnoDB 的二级索引叶子节点只存索引列和主键,查到主键后还要回聚簇索引取整行,这一步叫回表。回表是随机 I/O,行数一多,优化器有时宁可全表扫描也不走索引,于是出现“索引明明建了却没用上”。复合索引只能从最左列开始定位,范围条件之后的列无法继续缩小扫描范围;索引下推能把过滤提前到存储引擎,却仍可能要回表。理解了覆盖索引、最左前缀和代价估算,才能判断该加什么索引、为什么 EXPLAIN 里没有 Using index。 ---

是什么

InnoDB 的表数据本身就组织在一棵 B+ 树里,这棵树叫聚簇索引,有主键时主键就是它的键,叶子节点存放完整行。其余索引叫二级索引,叶子节点只存“索引列 + 主键”,不存整行。于是通过二级索引查一条记录要跑两棵树:先在二级索引里找到主键,再拿主键回聚簇索引取整行,后一步就是回表。

可以把它想成图书馆的两套目录:书名目录卡上只写书名和索书号,读者要看书,得先查卡片、再按索书号去书架取书。卡片查得再快,取书那一步也省不掉。回表之所以贵,是因为按主键取行落在不同的数据页上,往往是一次次随机 I/O,而不是顺着一段连续地址读下去。

典型场景是“明明有索引,慢查询日志里还是几百万行扫描”:要么回表行数太多,要么复合索引没用上最左前缀,要么查询列根本不在索引里。

核心原理

先看 B+ 树。非叶子节点只存键和子页指针,叶子节点按键有序并用链表相连;InnoDB 默认一页 16KB,所以一棵千万级表的主键树通常只有三四层,定位一行只需要几次页访问。二级索引同样是 B+ 树,只是叶子上的“值”变成了主键,而不是整行。

这就决定了回表的代价:二级索引扫描本身是顺序的,但每拿到一个主键,就要去聚簇索引的另一处取行,页与页之间可能毫无相邻关系。扫一万行、回表一万次,和顺序扫一万行,成本完全不是一个量级。反过来说,如果二级索引已经把候选行过滤到几十条,回表几十次完全值得;决定成本的是回表行数,而不是“有没有回表”这四个字。

覆盖索引是第一条出路。如果查询需要的列全都在二级索引里,包括 InnoDB 自动附带的主键,那么读完索引就能返回,不需要回表,EXPLAIN 的 Extra 会出现 Using index。注意 Using index 说的是“只靠索引就取到了数据”,不是“用到了索引”;后者看 key 列。

CREATE INDEX idx_status_time ON orders (status, created_at);

-- 只取索引里有的列:Extra 通常出现 Using index
EXPLAIN SELECT status, created_at FROM orders
WHERE status = 1 ORDER BY created_at DESC LIMIT 20;

-- 多取一个 amount:通常要回表,Using index 不再出现
EXPLAIN SELECT status, created_at, amount FROM orders
WHERE status = 1 ORDER BY created_at DESC LIMIT 20;

这段例子还顺带解释了排序:status 是等值条件时,同一个 status 内的记录按 created_at 有序,ORDER BY created_at 可以顺着索引读;一旦 status 变成范围条件,全局顺序被打破,排序往往就要另起炉灶。

第二条是最左前缀。复合索引 (a, b, c) 的排序规则是“先按 a,再按 b,再按 c”,像电话簿先按姓、再按名。因此它能直接定位的前缀只有 a、(a,b)、(a,b,c);只给 b 或 c,就像只知道名字不知道姓,没法在电话簿里直接翻到。范围条件也会截断后续列:WHERE a > 1 AND b = 2 时,b 无法再用于缩小索引区间,但还可以在索引条目上做过滤。

第三条是索引下推(Index Condition Pushdown,ICP)。对 (zipcode, lastname, firstname) 这样的索引,WHERE zipcode='95054' AND lastname LIKE '%etrunia%' 里的 lastname 无法形成索引区间;没有 ICP 时,存储引擎把所有 zipcode 匹配的行都回表取出来,再交给 Server 层过滤。有 ICP 时,lastname 条件下推到存储引擎,直接在索引条目上判断,不匹配就不回表。它减少的是回表次数,不是消除回表,EXPLAIN 的 Extra 显示 Using index condition。

最后是优化器的算账。走二级索引要付“扫描索引 + 大量回表”的钱,全表扫描虽然读的行多,却是顺序读。优化器根据统计信息估算两者成本,回表比例高、区分度低时,它会主动放弃索引。这不是优化器坏了,而是它算出来全表扫描更便宜。MRR(Multi-Range Read)则尝试把回表主键排序后再批量读,把随机 I/O 变得更接近顺序 I/O,属于同一笔账里的优化项。

常见考点与误区

  • “回表就是再查一次二级索引”:不对。回表是拿二级索引里的主键去聚簇索引取整行,走的是另一棵树,代价主要来自随机 I/O。答成“再查一次索引”会漏掉最关键的聚簇索引结构。
  • “最左前缀是 WHERE 里列的书写顺序”:不对。优化器会重排谓词,书面顺序不影响;真正限制的是索引定义里的列序。WHERE b=2 通常用不上 (a,b) 的定位能力,只能退化为扫整个索引。范围列之后的列不能继续缩小扫描区间,但仍可能被 ICP 用来过滤。
  • “索引下推等于覆盖索引”:混淆最常发生在这里。覆盖索引是“不用回表”,ICP 是“少回表”;前者看查询列是否都在索引里,后者看过滤条件能否在存储引擎内先执行。
  • “建了索引就一定会走”:走不走由代价模型决定。区分度低的列、返回行数占比很高的查询、统计信息过期,都可能让优化器选全表扫描。EXPLAIN 的 rows 是估算值,不是实际值;MySQL 8.0.18 起可以用 EXPLAIN ANALYZE 看真实行数与耗时。
  • “在索引列上套函数只是稍微慢一点”:WHERE DATE(created_at)='2026-11-05'、WHERE phone=13800000000(字符串列与数字比较)这类写法通常会让索引区间无法建立,直接从“定位”退化成“逐行算”。改成范围条件或保持类型一致,才谈得上用索引。
  • “主键随便选,反正有索引”:二级索引的叶子都带主键,主键越宽,所有二级索引越大;随机 UUID 还会让插入落在 B+ 树各处,引发页分裂与碎片。这不是“能不能用”的问题,是整张表的写放大问题。

实践建议

  • 先看慢查询的 rows examined 与 rows sent 比例,再看 EXPLAIN 的 key、rows、Extra。比例悬殊通常意味着回表或过滤太晚,而不是“索引没建”。
  • 高频查询优先做覆盖索引:只 SELECT 真正需要的列,把过滤列和排序列按“等值在前、范围在后”放进复合索引,再用 EXPLAIN 验证排序是否真的免了 filesort。不要为了覆盖把大字段塞进索引。
  • 复合索引的列序从实际 SQL 反推,不要背口诀。等值条件多、区分度高的列放前面;范围条件放后面;ORDER BY 的列要和索引顺序匹配,否则排序仍要额外开销。
  • 控制索引数量。每个二级索引都要在写入时维护,还占缓冲池;定期清理重复索引和长期未使用的索引,比继续加索引更能提升整体吞吐。
  • 主键尽量窄、尽量有序。自增主键是最稳的选择;必须用 UUID 时,用 UUID_TO_BIN(uuid, 1) 让生成值按时间有序,或改用有序 UUID,避免随机写导致的页分裂。
  • 大表加索引前先在影子库验证执行计划与锁时间,使用 Online DDL 并避开业务高峰;统计信息失真时先 ANALYZE TABLE,再判断是否真的需要新索引。

一句话总结:索引只负责把行找得更快,回表、列序和代价估算才决定它到底值不值得走。

相关阅读

← 返回文章志