
title: 一条 SQL 从 7.8 秒到 90 毫秒执行计划里被看漏的 rows、filtered 和回表topic: 数据库慢查询优化索引、执行计划、覆盖索引batch: 5round: 3我们订单中心有张 2400 万行的order_item表运营导出「某用户近 30 天订单明细」的接口平时还好大促后越跑越慢最慢一次 7.8 秒连接池被这块查询占满连带把下单都拖慢了。DBA 第一反应是「加索引啊」我们加了没用——因为加错了列。最后靠EXPLAIN里的rows、filtered和「回表」三个信号才定位真问题。这篇文章把那次优化拆开代码和 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_id88231这个用户本身就有 86 万条记录大客户而status2没索引只能在回表后逐行过滤。看执行计划被看漏的三个字段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; -- 输出里重点看typeref, keyuser_idx, rows862000, filtered0.50, ExtraUsing where; Using filesort逐行解释这三个容易被忽略的信号-rows862000不是「返回 862000 行」而是「优化器估算要扫 862000 行才能拿到结果」。它告诉你索引选得不好——明明只要 50 条却扫了近百万。-filtered0.50表示扫出来的行里只有约 50% 满足status2条件。剩下 50% 是在引擎层回表后过滤掉的这部分完全浪费 IO。-ExtraUsing 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 万降到约 50LIMIT 50索引有序直接取前 50filtered接近 100Using 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 先EXPLAIN看rows、filtered、Extra三样再决定加什么索引而不是凭「哪个字段在 WHERE 里就加哪个」。加错单列索引是新手最常犯的——它确实能「用上索引」但rows仍然巨大、filtered仍然低性能不会本质改善。覆盖索引是终极手段但要评估写入和空间代价别给写频繁的表无脑上宽索引。思考题如果create_time是范围查询、status是等值为什么把status放在create_time后面反而能让它进覆盖索引联合索引里「范围列之后的列」到底还能不能用