一条 SQL 从 7.8 秒到 90 毫秒:执行计划里被看漏的 rows、filtered 和回表
2026/8/15 3:20:59 网站建设 项目流程

title: 一条 SQL 从 7.8 秒到 90 毫秒:执行计划里被看漏的 rows、filtered 和回表
topic: 数据库慢查询优化:索引、执行计划、覆盖索引
batch: 5
round: 3


我们订单中心有张 2400 万行的order_item表,运营导出「某用户近 30 天订单明细」的接口,平时还好,大促后越跑越慢,最慢一次 7.8 秒,连接池被这块查询占满,连带把下单都拖慢了。DBA 第一反应是「加索引啊」,我们加了,没用——因为加错了列。最后靠EXPLAIN里的rowsfiltered和「回表」三个信号才定位真问题。

这篇文章把那次优化拆开,代码和 SQL 都是生产里能直接抄的。

事故现场:一条看起来人畜无害的查询

SELECT order_id, sku_id, quantity, amount, create_time FROM order_item WHERE user_id = 88231 AND create_time >= '2026-07-15' AND status = 2 ORDER BY create_time DESC LIMIT 50;

这条 SQL 慢在哪?user_id有索引,但你EXPLAIN一下会发现:优化器选了user_id索引,但rows显示扫了 86 万行,因为user_id=88231这个用户本身就有 86 万条记录(大客户),而status=2没索引,只能在回表后逐行过滤。

看执行计划:被看漏的三个字段

EXPLAIN SELECT order_id, sku_id, quantity, amount, create_time FROM order_item WHERE user_id = 88231 AND create_time >= '2026-07-15' AND status = 2 ORDER BY create_time DESC LIMIT 50; -- 输出里重点看:type=ref, key=user_idx, rows=862000, filtered=0.50, Extra=Using where; Using filesort

逐行解释这三个容易被忽略的信号:
-rows=862000不是「返回 862000 行」,而是「优化器估算要扫 862000 行才能拿到结果」。它告诉你索引选得不好——明明只要 50 条,却扫了近百万。
-filtered=0.50表示扫出来的行里只有约 50% 满足status=2条件。剩下 50% 是在引擎层回表后过滤掉的,这部分完全浪费 IO。
-Extra=Using filesort说明排序没用上索引,ORDER BY create_time触发了额外排序,数据量大时这个排序会落盘,直接把 RT 顶上去。

第一刀:联合索引,别只加单列

问题核心是status过滤和create_time排序都没进索引。建一个「最左匹配」的联合索引:

-- user_id 等值 + create_time 范围 + status 等值,顺序按区分度排 ALTER TABLE order_item ADD INDEX idx_user_time_status (user_id, create_time, status);

逐行解释建索引的顺序讲究:
- 第 3 行把user_id放最左,因为它是等值查询,能最快缩小范围;create_time次之,承接范围查询;status放最后做等值过滤。
- 但注意:范围查询(create_time >=)后面的列(这里是status)在联合索引里用不上索引过滤,只能当「覆盖」用。所以单靠这个索引,status还是要回表后过滤,filtered还是低。
- 真正的杀手锏是「覆盖索引」:把查询要返回的列也放进索引,引擎不用回表。

第二刀:覆盖索引,消灭回表

-- 把 SELECT 里要的列全部塞进索引,引擎在索引页里就能拿到所有数据,不回主表 ALTER TABLE order_item ADD INDEX idx_cover (user_id, create_time, status, order_id, sku_id, quantity, amount);

逐行解释:
- 第 3 行把order_id, sku_id, quantity, amount也加进索引,这样SELECT要的列在索引 B+ 树的叶子节点上全都有,引擎不需要拿着主键再回order_item主表取数据——这就是「覆盖索引」,Extra 里的Using index就是它的标志。
- 消灭回表后,rows从 86 万降到约 50(LIMIT 50,索引有序直接取前 50),filtered接近 100,Using filesort也消失了(create_time在索引里有序)。
- 代价是索引变宽、写入变慢、占用更多磁盘。2400 万行的表,这个索引约多占 1.2GB,但查询从 7.8 秒降到 90 毫秒。

索引不是越多越好:一张取舍表

方案RT写入影响空间适用
无合适索引7.8s不可接受
单列 user_id 索引~1.2s够用但有余量
联合索引~300ms一般场景
覆盖索引90ms较高读多写少的核心查询

我的取舍:覆盖索引只给「读多写少、RT 敏感、被高频调用」的查询上。像订单导出这种一天几万次的查询,值得;但那些一天几次的报表查询,宽索引的写入代价不划算,普通的联合索引就够了。

Java 侧怎么把慢 SQL 提前抓出来

光靠 DBA 人肉 EXPLAIN 不够,我们在应用层也加了三道 Java 关卡,让慢 SQL 在上线前就被拦下。

第一道:MyBatis 拦截器,自动记录超过阈值的查询。

@Intercepts({@Signature(type = StatementHandler.class, method = "query", args = {Statement.class, ResultHandler.class})}) public class SlowSqlInterceptor implements Interceptor { private static final long SLOW_MS = 1000; // 超过 1 秒算慢 SQL @Override public Object intercept(Invocation inv) throws Throwable { long start = System.currentTimeMillis(); try { return inv.proceed(); // 执行原查询 } finally { long cost = System.currentTimeMillis() - start; if (cost >= SLOW_MS) { // 拿到 BoundSql 才能拿到真实 SQL(? 占位符已代入) BoundSql sql = ((StatementHandler) inv.getTarget()).getBoundSql(); log.warn("SLOW SQL {}ms: {}", cost, sql.getSql()); } } } }

逐行解释:
- 第 2 行@Signature拦截StatementHandler.query,所有 MyBatis 查询都走这里,覆盖面全。
- 第 9 行inv.proceed()执行原查询,前后用System.currentTimeMillis()包住,拿到真实耗时。
- 第 13 行getBoundSql().getSql()取到的是参数已代入的完整 SQL,比 Mapper 里的#{}占位符好排查——你能直接复制去 EXPLAIN。
- 我们把超过 1 秒的 SQL 打到 WARN 日志再接告警,慢查询从「用户投诉才发现」变成「当天就能看到」。

第二道:用 JdbcTemplate 跑 EXPLAIN,把执行计划做成自动化巡检。

public ExplainResult explain(JdbcTemplate jt, String sql) { // 在业务 SQL 前拼 EXPLAIN,让 MySQL 返回计划而不真正执行,零风险 return jt.queryForObject("EXPLAIN " + sql, (rs, i) -> { ExplainResult r = new ExplainResult(); r.setType(rs.getString("type")); // 访问类型,ALL 最差 r.setRows(rs.getLong("rows")); // 估算扫描行数 r.setExtra(rs.getString("Extra")); // Using filesort/Using temporary 都是信号 return r; }); }

逐行解释:
- 第 3 行在 SQL 前拼EXPLAIN,让 MySQL 返回执行计划而不真正执行,零风险。
- 第 6 行type是访问类型,ALL表示全表扫描,ref/range表示用上索引,巡检脚本看到ALL就报警。
- 第 7 行rows和上面讲的一样,是优化器估算扫描行数,这个值异常大就要查索引。

第三道:建表就在实体上把索引声明清楚,别等上线补。

@Entity @Table(name = "order_item", indexes = { @Index(name = "idx_cover", columnList = "user_id,create_time,status") }) public class OrderItem { @Id private Long orderId; @Column private Long userId; @Column private LocalDateTime createTime; @Column private int status; }

逐行解释:
- 第 3 行@Index(columnList = "user_id,create_time,status")用 JPA 把联合索引写进实体,迁移脚本自动建,避免「代码里查 A 列、库里忘建 A 列索引」的低级事故。
- 我们把核心查询的索引都在实体上声明,CI 里跑一次 schema 校验,索引缺失直接红。

这三道关加上前面讲的覆盖索引,我们慢查询数量三个月降了 80%,其中应用层拦截拦下了大部分「还没上生产的潜在慢 SQL」。

复盘真实数字

  • 加覆盖索引后,该接口 P99 从 7.8s 降到 90ms,连接池占用峰值从 92% 降到 18%。
  • 索引多占 1.2GB 磁盘,写入order_item的 TPS 掉了约 7%(从 4200 到 3900),对我们有余量,可接受。
  • 我们顺手用EXPLAIN扫了其他 12 条慢 SQL,其中 5 条是同样的「加错列 + 回表」问题,统一加覆盖索引后整体慢查询数降了 60%。

我的取舍:先 EXPLAIN 再动手

我的习惯是:任何慢 SQL 先EXPLAINrowsfilteredExtra三样,再决定加什么索引,而不是凭「哪个字段在 WHERE 里就加哪个」。加错单列索引是新手最常犯的——它确实能「用上索引」,但rows仍然巨大、filtered仍然低,性能不会本质改善。覆盖索引是终极手段,但要评估写入和空间代价,别给写频繁的表无脑上宽索引。

思考题

如果create_time是范围查询、status是等值,为什么把status放在create_time后面反而能让它进覆盖索引?联合索引里「范围列之后的列」到底还能不能用?

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

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

立即咨询