文章目录
- MySQL 回表、覆盖索引与索引下推(ICP):三个必须连起来才算懂的慢查询根因
- 一、回表到底在做什么
- 1.1 根因:二级索引的叶子节点没有完整数据
- 1.2 回表贵在哪:随机 IO
- 1.3 怎么判断一条 SQL 有没有回表
- 二、覆盖索引:让二级索引自己把事办完
- 2.1 定义与例子
- 2.2 覆盖索引也不是"零成本"
- 2.3 什么时候值得为覆盖而扩索引
- 三、索引下推 ICP:把过滤搬到引擎层
- 3.1 ICP 解决的问题
- 3.2 怎么用、怎么确认
- 3.3 ICP 的适用条件(很重要)
- 3.4 ICP ≠ 谓词下推
- 四、把三者放在一起看
- 4.1 同一个查询的三种形态
- 4.2 定量验证:EXPLAIN ANALYZE(8.0.18+)
- 4.3 MRR:让回表从随机变成相对顺序
- 五、降低回表成本的四个实用手段
- 5.1 扩展组合索引做覆盖(第一选择)
- 5.2 延迟关联(Deferred Join)
- 5.3 让更多条件命中索引列,给 ICP 创造机会
- 5.4 控制二级索引的数量与宽度
- 六、三个常见误区
- 七、小结
MySQL 回表、覆盖索引与索引下推(ICP):三个必须连起来才算懂的慢查询根因
线上有条 SQL:
EXPLAIN明明显示走了索引、type=range、rows只有几千,
但实际执行要 8 秒。这时候再盯
key字段已经没意义了 —— 真正的开销藏在
“扫到索引记录之后还要做什么”里。本文用同一张表把回表 → 覆盖索引 → 索引下推串成一条因果链讲清楚:
回表为什么贵、覆盖索引为什么能救命但救不了所有场景、
ICP 又把哪些过滤从 Server 层挪到了引擎层。
一、回表到底在做什么
1.1 根因:二级索引的叶子节点没有完整数据
InnoDB 只有一棵索引树的叶子节点存整行数据,那就是聚簇索引(通常是主键)。
其它所有索引都是二级索引,叶子节点里存的不是数据而是主键值。
聚簇索引 B+Tree(按 id 排序) ┌─────────────────────────────────────────┐ │ id=1 │ id,name,phone,amount,status,... │ ← 整行数据 │ id=2 │ id,name,phone,amount,status,... │ └─────────────────────────────────────────┘ 二级索引 idx_phone B+Tree(按 phone 排序) ┌──────────────────┐ │ phone='138..' │ 7 │ ← 叶子只有 (索引列, 主键值) │ phone='139..' │ 3 │ └──────────────────┘所以这条 SQL 的执行被劈成了两段:
SELECTname,amountFROMordersWHEREphone='13800001111';-- idx_phone 里只有 phone 和 id-- name、amount 不在 idx_phone 里 → 必须拿着 id 回聚簇索引再查一次这个"拿着主键值再回主键树捞一次"的动作,就是回表。
1.2 回表贵在哪:随机 IO
一次回表不是"免费再多读一点",它的代价来自两处:
第一,额外的 B+Tree 查找。虽然聚簇索引树也很浅,但每条记录都要独立定位一次。
第二,也是更致命的:随机 IO。
二级索引是按phone排序的,而回表要按id去找。
phone 相邻的两条记录,它们的 id 通常并不相邻—— 于是回表访问聚簇索引时,
磁盘访问模式从"顺序扫描"退化成了"随机跳动"。
| 访问方式 | InnoDB 页順序 | IO 类型 | 大致代价 |
|---|---|---|---|
| 全主键自增顺序扫描 | 连续 | 顺序 IO | 最快 |
| 二级索引 → 回表 | 跳跃 | 随机 IO | 慢一到两个数量级(机械盘尤其明显) |
| 覆盖索引(不回表) | 连续 | 顺序 IO | 快 |
这就解释了开篇那个现象:rows只有几千,但每次 row 都可能触发一次随机 IO,
几千次随机 IO 轻松吃掉好几秒。
1.3 怎么判断一条 SQL 有没有回表
最直接的方式是看EXPLAIN的Extra:
| Extra 信息 | 是否回表 | 含义 |
|---|---|---|
Using index | ❌ 不回表 | 走了覆盖索引 |
Using index condition | ✅ 回表,但用了ICP | 先在下层过滤,再回表 |
Using where | ✅ 通常回表 | 条件在 Server 层再判断一遍 |
Using where; Using index | ❌ 不回表 | 覆盖索引 + 条件无法下推到索引里 |
注意最后一种:即使有Using where,只要同时出现Using index,就没有回表。
想做定量观察,可以用 Handler 统计做前后对比(8.0.18 起还有更好的工具,见 4.2):
FLUSHSTATUS;SELECTname,amountFROMordersWHEREphone='13800001111';SHOWSESSIONSTATUSLIKE'Handler_read%';需要留意的是:MySQL 没有直接暴露"回表次数"的计数器。Handler_read_next统计的是沿索引叶子顺序读取的记录条数,
真正的回表次数要用EXPLAIN ANALYZE(8.0.18+)或对比读写页数来推算。
二、覆盖索引:让二级索引自己把事办完
2.1 定义与例子
覆盖索引不是一种索引类型,而是一种查询状态:
当查询需要的所有列都包含在某条索引里时,MySQL 就不需要回表。
继续上面的例子,把索引从(phone)扩成(phone, name, amount):
ALTERTABLEordersDROPINDEXidx_phone,ADDINDEXidx_phone_nm(phone,name,amount);EXPLAINSELECTname,amountFROMordersWHEREphone='13800001111';-- type: ref-- Extra: Using index ← 不再回表索引变成这样:
idx_phone_nm B+Tree ┌─────────────────────────────────┐ │ phone='138..' │ name │ amount │ → 查询要的列全在这儿了 └─────────────────────────────────┘再看一个对比实验:
| SQL | 走的索引 | Extra | 是否回表 |
|---|---|---|---|
SELECT id, phone FROM orders WHERE phone=? | idx_phone | Using index | ❌ |
SELECT name FROM orders WHERE phone=? | idx_phone | NULL(或Using index condition) | ✅ |
SELECT name FROM orders WHERE phone=?(扩索引后) | idx_phone_nm | Using index | ❌ |
第一条为什么也不需要回表?因为二级索引叶子本来就包含主键值,
所以SELECT id天然被任何二级索引覆盖。这是一个非常实用的技巧。
2.2 覆盖索引也不是"零成本"
三个必须知道的边界:
边界一:覆盖扫描仍可能需要回表判断可见性。
二级索引记录里没有事务 ID,InnoDB 是在索引页上维护了一个PAGE_MAX_TRX_ID:
只有当所有活跃事务 ID 都小于它时,"这一页全部可见"才能成立,
否则仍要回到聚簇索引去确认某行的可见性。好在这是页级判断,不是每行都做,
所以绝大多数情况下覆盖扫描依然远快于逐行回表。
边界二:索引列越多,写放大越严重。
把(phone)扩成(phone, name, amount),意味着每次UPDATE name都要同步更新这棵树。
覆盖索引本质是用写放大换读加速,在读多写少的表上才划算。
边界三:覆盖索引救不了"扫得多"。
如果WHERE phone LIKE '138%'本身命中 50 万行,即使不回表,
扫 50 万条索引记录的开销也不小 —— 这时候要优化的是过滤条件,不是回表。
2.3 什么时候值得为覆盖而扩索引
| 场景 | 建议 |
|---|---|
高频SELECT只取 2~3 个列,且表很宽(几十列) | 强烈推荐扩展为覆盖 |
统计类查询(COUNT(*)、聚合 sum) | 推荐,效果通常立竿见影 |
被 ORM 无脑SELECT *的查询 | 先改 SQL,别指望用覆盖索引兜底 |
| 高频 UPDATE 的热点表 | 谨慎,先压测写性能 |
索引列里打算塞长VARCHAR(如 500 字节) | 不建议,换成分成两段查询(延迟关联) |
三、索引下推 ICP:把过滤搬到引擎层
3.1 ICP 解决的问题
假设有表:
CREATETABLEorders(idBIGINTPRIMARYKEY,shop_idBIGINTNOTNULL,statusTINYINTNOTNULL,amountDECIMAL(10,2)NOTNULL,buyerVARCHAR(32)NOTNULL,KEYidx_shop_status(shop_id,status,amount)-- 只覆盖到 amount)ENGINE=InnoDB;执行:
SELECT*FROMordersWHEREshop_id=88ANDstatusLIKE'1%'ANDamount>100;这里status LIKE '1%'是非等值(前缀模糊 + 类型不匹配时也常见),
它在组合索引里虽然能限定范围,但无法精确到行。amount > 100因为前面有非等值列,也用不了索引的有序性。
没有 ICP 时的流程是:
1. 存储引擎在 idx_shop_status 上定位 shop_id=88 的索引记录 2. 每读到一条索引记录 → 立刻回表捞整行 3. 把整行返回给 Server 层 4. Server 层判断 status LIKE '1%' AND amount > 100 → 不满足就丢弃问题在第 2 步:回表之后才被丢掉的大量整行读取,全是浪费。
开启 ICP 后:
1. 引擎读到一条索引记录(先不回表) 2. 引擎层直接用索引记录里的 status / amount 判断能否满足条件 3. 满足条件才回表捞整行 4. 剩余无法用索引列判断的条件,交给 Server 层status和amount都在这条组合索引里,
所以判断完全不需要访问整行数据—— 这就是 ICP 的全部价值。
3.2 怎么用、怎么确认
ICP 从MySQL 5.6引入,默认开启:
SELECT@@optimizer_switchLIKE'%index_condition_pushdown=on%';-- 1 表示开启SEToptimizer_switch='index_condition_pushdown=off';-- 临时关闭做对比实验EXPLAIN里看Extra:
SEToptimizer_switch='index_condition_pushdown=off';EXPLAINSELECT*FROMordersWHEREshop_id=88ANDstatusLIKE'1%'ANDamount>100;-- Extra: Using whereSEToptimizer_switch='index_condition_pushdown=on';EXPLAINSELECT*FROMordersWHEREshop_id=88ANDstatusLIKE'1%'ANDamount>100;-- Extra: Using index condition| 开关 | Extra | 含义 |
|---|---|---|
| 关闭 | Using where | 条件在 Server 层过滤,回表次数多 |
| 开启 | Using index condition | 条件在引擎层用索引列过滤,回表次数减少 |
3.3 ICP 的适用条件(很重要)
不是所有查询都能享受 ICP,它有明确的边界:
| 条件 | 要求 |
|---|---|
| 访问方法 | 必须是range/ref/eq_ref/ref_or_null |
| 索引类型 | InnoDB 下只针对二级索引;聚簇索引不适用(整行已经在缓冲池里,没啥可省的) |
| 是否需要回表 | 必须需要回表。如果已经是覆盖索引(Using index),ICP 无从谈起 |
| 存储引擎 | InnoDB、MyISAM 均支持,含分区表 |
| 引擎层的下发条件 | 不能引用子查询、不能引用存储函数、不能涉及触发器 |
| 虚拟生成列 | 不支持在虚拟生成列上建的二级索引走 ICP |
最后一条容易被忽略:只要是覆盖索引,Extra 就不会出现Using index condition,
因为压根不用读整行。这也是为什么很多人在优化到覆盖之后发现"ICP 怎么不生效了" —— 那是变好了,不是变坏了。
3.4 ICP ≠ 谓词下推
这两个词经常被混用,其实层次完全不同:
| 概念 | 发生在哪 | 做了什么 |
|---|---|---|
| 索引条件下推 ICP | Server 层 → 存储引擎层 | 把WHERE 条件下推,让引擎在回表前就用索引列过滤 |
| 谓词下推(派生表/视图) | 外层查询 → 派生表内部 | 把外层条件下推到派生表(derived_merge、条件下推到 UNION 分支等) |
| 分区裁剪 | 优化器 → 分区层 | 根据 WHERE 裁掉不需要访问的分区 |
一句话记忆:ICP 是"Server → 引擎",谓词下推是"外层查询结构 → 内层查询结构"。
四、把三者放在一起看
4.1 同一个查询的三种形态
| 形态 | Extra | 回表次数 | 典型优化动作 |
|---|---|---|---|
| 普通二级索引查询 | Using where | 索引扫到多少条就回多少次 | — |
| 开启 ICP | Using index condition | 减少(不满足条件的不再回表) | 把更多条件塞进索引列 |
| 覆盖索引 | Using index | 归零 | 扩展索引包含所有查询列 |
从下往上看就是优化路径:先用 ICP 减少无效回表,再想办法做成覆盖索引彻底消灭回表。
4.2 定量验证:EXPLAIN ANALYZE(8.0.18+)
EXPLAINANALYZESELECT*FROMordersWHEREshop_id=88ANDstatusLIKE'1%'ANDamount>100\G-> Index range scan on orders using idx_shop_status ... (cost=.. rows=..) (actual time=0.032..412.771 rows=1283 loops=1)
关键看actual time和rows的实际值与预估值的落差。
预估 rows 和实际 rows 差一个数量级时,通常说明统计信息失真了(那是统计信息/直方图那篇的话题)。
MySQL 5.7 没有EXPLAIN ANALYZE,可用EXPLAIN FORMAT=JSON看read_cost/eval_cost做定性判断。
4.3 MRR:让回表从随机变成相对顺序
ICP 关心"要不要回表",MRR(Multi-Range Read)关心"回表的顺序"。
二级索引扫出来的一批主键值通常是无序的。MRR 的做法是:
先把主键值收集到缓冲区,排序后再按主键顺序去聚簇索引读,
把随机 IO 尽量整理成接近顺序的 IO。
SEToptimizer_switch='mrr=on,mrr_cost_based=off';-- 强制走 MRR 做对比实验EXPLAINSELECT*FROMordersWHEREshop_idBETWEEN1AND100;-- Extra: Using index condition; Using MRRMRR 默认mrr_cost_based=on(由优化器判断划算才用),一般不用手动干预。
五、降低回表成本的四个实用手段
5.1 扩展组合索引做覆盖(第一选择)
-- 高频查询:SELECT logic_id, score FROM t WHERE uid=? AND status=?ALTERTABLEtDROPINDEXidx_uid,ADDINDEXidx_uid_st(uid,status,logic_id,score);-- Extra: Using index注意别把宽 字段(VARCHAR(1000)、TEXT)塞进索引,
InnoDB 单条索引记录有 3072 字节限制,而且会让缓冲池利用率暴跌。
5.2 延迟关联(Deferred Join)
当覆盖索引不可行(比如必须SELECT *),可以先在索引里精确定位主键,
再拿主键回表 —— 让回表次数从"扫描行数"降到"结果行数"。
-- 原始写法:回表 100020 次SELECT*FROMordersWHEREshop_id=88ANDstatus=1ORDERBYcreated_atLIMIT100000,20;-- 延迟关联:只在二级索引里扫描,最后只对 20 行回表SELECTo.*FROMorders oJOIN(SELECTidFROMordersWHEREshop_id=88ANDstatus=1ORDERBYcreated_atDESCLIMIT100000,20)kONo.id=k.id;内层派生表完全走索引(Using index),回表只对最终 20 行发生。
5.3 让更多条件命中索引列,给 ICP 创造机会
-- 不好:buyer 不在索引里,无法参与任何下推SELECT*FROMordersWHEREshop_id=88ANDbuyerLIKE'王%';-- 好:把 buyer 加进组合索引,虽然 LIKE 不能精确定位,但可以参与 ICP 过滤ALTERTABLEordersADDINDEXidx_shop_buyer(shop_id,buyer);判断是否生效的标准:逐列核对EXPLAIN的key_len,key_len覆盖到哪一列,ICP 就能用到哪一列。
5.4 控制二级索引的数量与宽度
每多一条二级索引,写路径就多一棵树。定期清理冗余索引是性价比最高的优化之一:
-- 找出从未被使用过的索引(MySQL 8.0)SELECTobject_schema,object_name,index_nameFROMperformance_schema.table_io_waits_summary_by_index_usageWHEREindex_nameISNOTNULLANDcount_star=0ANDobject_schemaNOTIN('mysql','sys','performance_schema');⚠️ 这个统计来自内存,重启后清零,判断前要确认实例已运行足够长时间。
六、三个常见误区
误区 1:只要 Extra 里有Using where就一定回表了
——不一定。看的是是否同时有Using index。Using where; Using index表示走了覆盖索引、只是部分条件在索引里无法直接判定,这种不回表。
误区 2:ICP 开启了就一定会生效
——ICP 有明确前提:必须是二级索引、必须需要回表、访问方法是 range/ref/eq_ref/ref_or_null。
如果 SQL 本身就全表扫描,或者已经覆盖索引了,ICP 都不会出现。
误区 3:所有查询都应该做成覆盖索引
——覆盖索引的成本是写放大 + 索引体积。
如果你为了一个每天执行 10 次的查询把一条VARCHAR(512)拖进索引,
而这张表每秒有几十次写,那基本是亏的。覆盖索引适合高频、窄结果集的查询。
七、小结
- 回表 = 拿着二级索引叶子里的主键值,回聚簇索引再查一次;贵在随机 IO
- 判断是否回表看
Extra:Using index不回表,Using index condition用了 ICP,Using where通常是回表(除非同时有Using index) - 任何二级索引都天然覆盖
(索引列 + 主键),所以SELECT id永远不需要为了 id 回表 - 覆盖索引把回表次数降到 0,代价是写放大与索引体积;NVMe 上随机 IO 惩罚变小,收益会打折扣,但依然可观
- ICP(5.6 起默认开启)把可用索引列判断的 WHERE 条件下推到引擎层,回表前先过滤,直接减少回表次数
- ICP 的硬约束:仅二级索引、必须需要回表、访问方法为 range/ref/eq_ref/ref_or_null;一旦覆盖索引生效,ICP 自然消失
- ICP ≠ 谓词下推:ICP 是 Server → 引擎,谓词下推是外层查询 → 派生表/内层
- MRR从另一个角度帮忙:把回表的主键排序后再读,随机 IO 变接近顺序 IO
- 实用手段排序:扩索引做覆盖 > 延迟关联减少回表行数 > 增加 ICP 可下推列 > 清理冗余索引
- MySQL 8.0.18+ 用
EXPLAIN ANALYZE的actual rows/time做定量验证,5.7 只能用FORMAT=JSON的 cost 定性判断