本文摘要:接口加索引后从 80ms 涨到 1.4s,并发稍高就拖垮 Pod。用 EXPLAIN ANALYZE 定位 3 万次回表根因,以覆盖索引消除回表。
一、问题与结论
复现环境:JDK 17、Spring Boot 3.2.5、MySQL 8.0.36、mysql-connector-j8.4.0、HikariCP 默认 10 连接。表orders约 200 万行,其中user_id = 42 AND status = 1恰好命中 3 万行。
结论先给:变慢不是索引失效,而是idx_user_status命中 3 万行后逐行回表取remark列。3 万次聚簇索引查找把单查询推到秒级,50 并发把连接池打满,最后连健康检查一起失败。
故障时序与日志:
T+0.1s MySQL 走 idx_user_status 命中 30000 行,逐行回表取 remark T+1.5s 返回 30000 行 ≈ 6MB JSON,Jackson 序列化,CPU 短暂飙高2025-03-11 14:02:18.341 INFO c.e.o.OrderController : 查询耗时: 1427ms, 返回 30000 条 2025-03-11 14:02:48.402 ERROR c.z.h.p.HikariPool : HikariPool-1 - Connection is not available, request timed out after 30000ms. 2025-03-11 14:03:05.119 WARN c.e.o.OrderController : /actuator/health -> 503 (liveness probe 失败,Pod 重启)放大关系是一道除法:50 并发 ÷ 10 连接 = 5 轮排队,末位等待 5 × 1.4s ≈ 7s;持续流量让队列继续增长,一旦等待超过connection-timeout30s 就抛上面那行异常。连接池耗尽后/actuator/health同样拿不到连接,K8s 判定不健康并重启 Pod。日志中的耗时数字来自故障记录,未在本机基准复测。
二、排查与选择依据
先看执行计划的真实行数,再决定改哪里。传统EXPLAIN只给估算值,EXPLAIN ANALYZE(8.0.18+)会输出实际行数与实际耗时。
EXPLAINANALYZESELECTid,user_id,status,amount,remarkFROMordersWHEREuser_id=42ANDstatus=1;判读要点(结构为真实输出格式,数字为示意):
-> Filter: ((orders.user_id = 42) and (orders.status = 1)) (actual time=0.04..1287 rows=30000 loops=1) -> Index range scan using idx_user_status on orders (actual time=0.03..601 rows=30156 loops=1)rows=30000且Extra里没有Using index,就是回表 3 万次;把remark从SELECT中去掉后Extra变为Using index,耗时掉一个量级。接着用SHOW STATUS LIKE 'Innodb_buffer_pool_read%';查Innodb_buffer_pool_reads,它不为 0 就说明部分回表落到了磁盘随机 I/O,这是 200ms 与 1.4s 的分水岭。
替代方案与取舍
| 方案 | 选择条件 | 代价 | 边界(什么时候不用) |
|---|---|---|---|
| 覆盖索引(本文方案) | 返回列可控、查询稳定 | 索引变大、写入变慢 | 写多读少、返回列经常变化时不用 |
LIMIT或游标分页 | 产品允许只看前 N 条 | 改接口契约,前端要适配 | 必须全量导出时不用 |
分批回表(先取主键列表再IN查) | 行数大且确实要全量 | 多一次网络往返,代码复杂度上升 | 命中行数 < 1000 时收益不明显 |
| Redis 缓存结果 | 查询热点稳定、可容忍短暂不一致 | 3 万行 JSON ≈ 6MB 大 key,失效策略要单独设计 | 写入频繁或要求强一致时不用 |
不该用本文方案的场景:remark属于高频变更字段、或表以写入为主时,加宽索引等于把代价转嫁给所有INSERT/UPDATE,此时优先分页或分批回表。
三、关键原理
三个事实决定上面的判断:
- 二级索引叶子节点只存「索引列值 + 主键值」,不存完整行。查询要的列不在索引里,就必须拿主键回聚簇索引重新定位,每行一次,3 万行就是 3 万次 B+ 树查找。
EXPLAIN的Extra: Using index表示覆盖索引,所需列全在索引里,零回表。- ICP(
Using index condition)把部分WHERE条件下推到存储引擎层,在回表前先过滤,能减少回表次数,但只要还缺列就消不掉回表。
二级索引 idx_user_status 叶子: (42, 1, pk=1001), (42, 1, pk=1002), ... ↓ 每个 pk 回聚簇索引取 remark 聚簇索引叶子: (1001, ..., remark='xxx'), ...四、可运行示例
环境:本地 MySQL 8.0.36 实例,JDK 17 + Spring Boot 3.2.5。输入:GET /api/orders?userId=42&status=1。
步骤 1:建表。
CREATEDATABASEdemo;USEdemo;CREATETABLEorders(idBIGINTUNSIGNEDAUTO_INCREMENTPRIMARYKEY,user_idBIGINTUNSIGNEDNOTNULL,statusTINYINTUNSIGNEDNOTNULLDEFAULT0,amountDECIMAL(12,2)NOTNULLDEFAULT0,created_atDATETIMENOTNULLDEFAULTCURRENT_TIMESTAMP,remarkVARCHAR(255)DEFAULTNULL,INDEXidx_user_status(user_id,status))ENGINE=InnoDB;步骤 2:灌数据。用数字序列表或存储过程循环插入,保证总行数 ≥ 200 万且user_id = 42, status = 1恰好 3 万行,最后执行ANALYZE TABLE orders;。
步骤 3:跑上一节的EXPLAIN ANALYZE,再把SELECT中的remark去掉做对比。
步骤 4:Spring Boot 侧请求接口并计时。
@GetMappingpublicList<OrderVO>list(@RequestParamLonguserId,@RequestParamIntegerstatus){longstart=System.currentTimeMillis();List<OrderVO>result=orderMapper.selectByUserAndStatus(userId,status);log.info("查询耗时: {}ms, 返回 {} 条",System.currentTimeMillis()-start,result.size());returnresult;}预期输出:带remark的查询无Using index、秒级耗时;去掉remark后Extra: Using index、几十毫秒。
实际输出:需在目标机器复测。本文未执行基准测试,秒级与毫秒级均为量级推算,未验证。
常见失败:背景数据灌不满。information_schema.columns自连接的实际行数取决于实例里的表数量,常远小于预期,回表代价被低估会让整个结论不成立;改用数字序列表即可。
五、验证结果与边界
验证顺序:EXPLAIN ANALYZE看rows与Extra→Innodb_buffer_pool_reads判断是否落盘 → 并发压测确认Connection is not available消失。三条都对上才算复现。
可迁移到其他项目的四条结论:
- 回表次数 ≈ 索引命中行数,先看
Extra有没有Using index,再谈索引写法。 - 并发放大用「单查询秒数 × 并发 ÷ 连接数」估算,与框架无关。
- 回表在 buffer pool 命中时是内存操作,命中几千行以内基本无感,别为小结果集加宽索引。
- MySQL 8.0.3 起二级索引按主键排序,回表访问更局部化、随机 I/O 更少,但回表次数本身不变。
适用边界:只适合返回列可控、读多写少的查询。分库分表不解决单表回表,调整锁粒度也与本问题无关,不要混为一谈。
思考
- 覆盖索引把写入代价转嫁给所有
INSERT/UPDATE,你的系统更怕读慢还是写慢? - 返回列越来越多时,分页、分批回表和加宽索引,哪个的维护成本更低?
参考资料
- InnoDB Index Overview(MySQL 8.0)
- EXPLAIN Output Format(MySQL 8.0)
- Index Condition Pushdown Optimization(MySQL 8.0)