☰
MySQL 加了索引反而更慢:3 万行回表让 Spring Boot 接口从 80ms 涨到 1.4s
2026/11/5 5:33:37 网站建设 项目流程

本文摘要:接口加索引后从 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,此时优先分页或分批回表。

三、关键原理

三个事实决定上面的判断:

  1. 二级索引叶子节点只存「索引列值 + 主键值」,不存完整行。查询要的列不在索引里,就必须拿主键回聚簇索引重新定位,每行一次,3 万行就是 3 万次 B+ 树查找。
  2. EXPLAIN的Extra: Using index表示覆盖索引,所需列全在索引里,零回表。
  3. 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消失。三条都对上才算复现。

可迁移到其他项目的四条结论:

  1. 回表次数 ≈ 索引命中行数,先看Extra有没有Using index,再谈索引写法。
  2. 并发放大用「单查询秒数 × 并发 ÷ 连接数」估算,与框架无关。
  3. 回表在 buffer pool 命中时是内存操作,命中几千行以内基本无感,别为小结果集加宽索引。
  4. MySQL 8.0.3 起二级索引按主键排序,回表访问更局部化、随机 I/O 更少,但回表次数本身不变。

适用边界:只适合返回列可控、读多写少的查询。分库分表不解决单表回表,调整锁粒度也与本问题无关,不要混为一谈。

思考

  1. 覆盖索引把写入代价转嫁给所有INSERT/UPDATE,你的系统更怕读慢还是写慢?
  2. 返回列越来越多时,分页、分批回表和加宽索引,哪个的维护成本更低?

参考资料

  • InnoDB Index Overview(MySQL 8.0)
  • EXPLAIN Output Format(MySQL 8.0)
  • Index Condition Pushdown Optimization(MySQL 8.0)

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询