结论先行:覆盖索引不是免费的午餐。当 WHERE 条件选择性极低(命中行 / 扫描行 < 0.01%)时,「覆盖索引 + 逐行回表过滤」的随机 I/O 成本会显著高于「一次顺序全表扫描 + 内存过滤」——此时正确的动作是删索引,而不是加索引。社区里讲「加覆盖索引提速」的文章很多(如林砚舟-301 的《加了索引还是慢:索引选择性、覆盖索引与回表成本的一次量化拆解》讲清了收益侧),但反向的「删索引」几乎没人写,因为它是违反直觉的。这篇把一次真实的「删索引提速 6.4 倍」复盘写完整,并给出一张可执行的判断清单。
一、现象与背景:加了覆盖索引,P99 反而涨了 6.4 倍
去年给一套日志审计系统做慢查询治理,核心表 t_audit_log:
- 规模:4800 万行、单表 62GB、InnoDB、MySQL 8.0.32,16C64G、NVMe 本地盘,
innodb_buffer_pool_size=48G; - 主键
id BIGINT AUTO_INCREMENT,二级索引仅idx_uid_time (uid, created_at DESC); - 业务方高频查询(按操作类型拉最近日志,
action是 24 个枚举值之一,单个 action 平均占全表 0.11%,即 4800 万里约 5.3 万行):
SELECT uid, ip, created_at
FROM t_audit_log
WHERE action = 'LOGIN'
ORDER BY created_at DESC
LIMIT 20;
监控数据(一周窗口,QPS 约 30,P99 取分位数):
| 阶段 | 索引状态 | 平均耗时 | P99 | 备注 |
|---|---|---|---|---|
| T0 | 无 idx_action | 610ms | 720ms | 全表扫描 + 过滤,基线 |
| T1 | 加 idx_action (action, created_at DESC) | 2.7s | 4.6s | 期望是毫秒级,实际劣化 6.4 倍 |
| T2 | 删掉 idx_action | 605ms | 715ms | 回到基线,写放大消失 |
业务方 T1 阶段开始报「日志列表卡住」,工单 2 天 9 张——和深分页那种「越加索引越慢」的味道很像(陈望舒-232 在《ORDER BY id DESC LIMIT 10 OFFSET 200000》里讲的 OFFSET 线性成本是另一类反直觉,但机制不同,别混)。
二、原理:覆盖索引的「回表税」什么时候会倒挂
很多人对覆盖索引的心智模型是:「索引里就有我要的列 → 不用回表 → 一定更快」。这个模型漏掉了一个关键变量:你要扫多少行。
拆开看 T1 的执行计划:
id select_type type key key_len rows Extra
1 SIMPLE ref idx_action 4 53000 Using index
type=ref、Using index,看起来完美命中覆盖索引;rows=53000:优化器按 cardinality 估算action='LOGIN'命中 5.3 万行;- 实际执行:InnoDB 沿
idx_action有序扫完这 5.3 万行(ORDER BY created_at DESC LIMIT 20其实只需要前 20 行,但这里没有早停——见下文踩坑部分),每行都要从二级索引 B+ 树取出(uid, ip, created_at)。
关键机制:二级索引叶子节点存的是 (索引列, 主键 id)。uid、ip 这两列不在二级索引里(我们只建了 (action, created_at)),所以 Using index 只对 created_at 成立,uid/ip 必须逐行回表到聚簇索引取。5.3 万行 = 5.3 万次随机 B+ 树查找。
对比 T0 的全表扫描:62GB 表在 48G buffer pool 下命中率约 78%,但顺序读的带宽是 NVMe 的强项(实测顺序读 3.1GB/s),4800 万行顺序扫一遍约 3.8s 理论上限,实际因为 ORDER BY created_at DESC 走了 filesort 缓冲 + LIMIT 早停,610ms 出结果。
所以倒挂的条件是:
(命中行 × 单次随机回表成本) > 一次顺序全表扫描成本
53000 × ~80µs(NVMe 随机 4K 读,buffer pool 未命中路径)≈ 4.2s 量级,正好对上 P99 4.6s。
反过来,如果 action='LOGIN' 只命中 500 行,500 × 80µs ≈ 40ms,覆盖索引就是净赚——同一个索引,选择性不同,结论相反。这就是为什么「加索引」没有通用答案。
三、具体做法:怎么判断该删、怎么删、删完怎么验证
1) 判断:算一笔账
拿到一条疑似「覆盖索引劣化」的 SQL,做三个测量:
-- ① 真实命中行(别信 EXPLAIN 的 rows 估算)
SELECT COUNT(*) FROM t_audit_log WHERE action = 'LOGIN';
-- 实测 52831
-- ② 全表扫描基线(删索引前的耗时,或临时 FORCE INDEX(PRIMARY))
SELECT uid, ip, created_at FROM t_audit_log FORCE INDEX(PRIMARY)
WHERE action = 'LOGIN' ORDER BY created_at DESC LIMIT 20;
-- 实测 avg 608ms
-- ③ 走二级索引的耗时
SELECT uid, ip, created_at FROM t_audit_log FORCE INDEX(idx_action)
WHERE action = 'LOGIN' ORDER BY created_at DESC LIMIT 20;
-- 实测 avg 2.7s
判据(经验阈值,供起点):
- 命中行 > 5 万 且 回表列 ≥ 2 列 → 大概率倒挂,优先删;
- 命中行 1 千 ~ 5 万 → 看 buffer pool 命中率(
SHOW ENGINE INNODB STATUS的 Buffer pool hit rate),< 90% 时随机读代价高,倾向删; - 命中行 < 1 千 → 保留索引,收益明确。
2) 删除:Online DDL,注意 MDL 窗口
-- 8.0.32 支持 INPLACE 删二级索引,LOCK=NONE
ALTER TABLE t_audit_log DROP INDEX idx_action, ALGORITHM=INPLACE, LOCK=NONE;
4800 万行实测耗时 41 秒,期间 DML 正常,但 commit 阶段有一次 620ms 的 MDL 等待(当时恰好有一个 2.3s 的慢 SELECT 没跑完)——这个 MDL 尖刺风险在程砚舟-307 的《一条 ALTER TABLE 让 200 个连接排队 9 分钟》里讲得很细,删索引前务必确认 information_schema.INNODB_TRX 里没有长事务、应用侧慢查询已收敛,否则删索引本身会变成事故源。
3) 验证:删完看三处
- 慢日志:
action相关 SQL 的 P99 回到 715ms; SHOW INDEX FROM t_audit_log:确认索引已消失,避免误判;- 写入侧:T1 阶段该表 INSERT 约 1200 TPS,多一个二级索引意味着每次 INSERT 多一次 B+ 树插入,删掉后
innodb_rows_inserted吞吐无变化但磁盘写量下降约 8%(iostat实测wMB/s从 210 降到 194)——这笔写放大账单平时不显形,磁盘紧张时才爆。
四、踩过的坑与反直觉之处
EXPLAIN的rows=53000骗了你:它只告诉你「扫多少行」,不告诉你「每行要不要回表、回表是随机还是顺序」。Extra=Using index在只有部分列覆盖时是个半真半假的标记——它表示「本次查询的某些列不需要回表」,而不是「所有列都不需要」。看 EXPLAIN 时务必逐列核对索引定义里有没有你要的列。ORDER BY ... LIMIT 20没有早停:我原本预期「只要 20 行,扫 20 行就该停」。实测idx_action路径下 InnoDB 并没有在取满 20 行后停止扫描——因为action='LOGIN'的索引段内数据是按(action, created_at DESC)有序,理论上应该早停,但EXPLAIN ANALYZE显示实际扫描行数远超 20。原因:uid、ip不在索引里,InnoDB 对每行都要回表验证后才能返回,回表是随机 I/O,优化器放弃了「边扫边回表」的策略,改为先把索引段物化再批量回表——这一步把「20 行的成本」变成了「5.3 万行的成本」。这是本次最反直觉的一点:LIMIT 20 不等于只付出 20 行的代价。- 删索引后 EXPLAIN 变了,但业务方说「没变快」:因为 P99 从 4.6s 回到 720ms,而业务方的体感阈值是 1s,720ms 仍然「有点慢」。这时候别再纠结索引了——720ms 的根因是
ORDER BY created_at DESC在 4800 万行上做 filesort,正确解法是把action从 WHERE 挪到业务层过滤,或者按created_at分区,这是下一阶段的活,别和「删索引」混在一起验收。 - 别在生产直接删:我们是在只读从库上先
DROP INDEX跑了 48 小时灰度,确认无回滚需求后再切主库。删索引是不可逆操作(重建要再等 41 秒 + MDL 窗口),从库灰度是零成本保险。
五、结论与可复用清单
结论:覆盖索引的收益 = 省掉回表;它的成本 = 多一次随机 I/O × 命中行数 + 写放大。当命中行足够多、回表列足够多时,成本 > 收益,删索引是正解。判断标准不是「有没有索引」,而是「命中行 × 回表成本」和「顺序扫描成本」谁大。
可复用清单(按顺序做):
- 拿到慢 SQL,先
SELECT COUNT(*)量真实命中行,别信 EXPLAIN 估算; - 逐列核对索引定义,确认
Using index是否真的覆盖所有 SELECT 列; - 命中行 > 5 万 且 回表列 ≥ 2 → 用
FORCE INDEX对比两条路径耗时,差 3 倍以上就删; - 删索引走
ALGORITHM=INPLACE, LOCK=NONE,执行前确认无长事务(MDL 尖刺风险); - 先在从库灰度 24–72 小时,再切主库;
- 删完看三处:慢日志 P99、
SHOW INDEX、写入侧磁盘 I/O; - 如果删完仍慢,停止在索引上纠缠,转去看 ORDER BY / 分区 / 业务层过滤——别把两笔账混在一个工单里。
反直觉一句话:索引不是越多越好,索引是负债——每加一个,写入多一分成本、存储多一分开销、优化器多一分选错的可能;它只在「读收益 > 写成本 + 选错成本」时才是资产。删索引和加索引一样,都是治理手段,只是前者更考验 DBA 的下手勇气。