一个慢 SQL 打垮了整个服务?从 EXPLAIN 到索引优化全实战

本文是 SQL 与数据库排障系列的第一篇 叙事框架:现象 → 排查过程 → 根因 → 修复 → 预防

问题现象

某日上午 9:20 左右,监控突然告警:订单服务的 P95 响应时间飙升至 32.7 秒,数据库连接池使用率瞬间冲到 97.4%(487/500),订单查询接口超时率达到 23.6%。

告警群截图

值班小A 第一时间直连数据库,发现 Threads_connected 已经 487,离 max_connections 的 500 阈值只剩 13 个连接。更可怕的是,Threads_running 高达 463,意味着几乎所有线程都在执行查询,没有一个空闲。

连接池被打满

SHOW PROCESSLIST 显示大量 Sending data 和 Creating sort index 状态的查询,SQL 内容高度一致——都是对 orders 表进行的多条件组合查询。

排查过程

第一步:开启慢查询日志

小A 首先检查了慢查询日志配置:

SHOW VARIABLES LIKE 'slow_query_log%';
SHOW VARIABLES LIKE 'long_query_time';

结果发现慢查询日志是关闭状态,而且 long_query_time = 10(默认值 10 秒)。这意味着即使一条 SQL 执行了 9 秒,也不会被记录——而这波故障中,绝大多数查询都在 15 秒以上。

立即调整:

SET GLOBAL slow_query_log = ON;
SET GLOBAL long_query_time = 0.5;
SET GLOBAL log_queries_not_using_indexes = ON;

很快,慢查询日志抓到了罪魁祸首:

慢查询日志

慢日志的关键信息:

  • Query_time: 18.42s — 一条查询运行了 18 秒
  • Rows_examined: 2,976,832 — 扫描了近 300 万行
  • Rows_sent: 20 — 只返回了 20 条

SQL 内容如下:

SELECT id, order_no, user_id, store_name, order_status,
       total_amount, create_time
FROM orders
WHERE order_status = 1
  AND create_time > '2024-06-01'
  AND store_name LIKE '%旗舰店%'
ORDER BY create_time DESC
LIMIT 20;

第二步:EXPLAIN 分析执行计划

拿到 SQL 后,小A 立刻执行 EXPLAIN:

优化前 EXPLAIN

           id: 1
  select_type: SIMPLE
        table: orders
         type: ALL
possible_keys: idx_create_time,idx_order_status
          key: NULL
         rows: 2976832
     filtered: 1.23
        Extra: Using where; Using filesort

几个致命的信号:

  1. type=ALL — 全表扫描,MySQL 逐行读取 300 万行数据去匹配条件
  2. key=NULL — 优化器决定不使用任何索引
  3. rows=2,976,832 — 扫描了近 300 万行
  4. Extra 含 Using filesort — 排序无法走索引,使用磁盘文件排序
EXPLAIN FORMAT=JSON 的结果:
{
  "query_block": {
    "ordering_operation": {
      "using_filesort": true,
      "table": {
        "access_type": "all",
        "rows_examined_per_scan": 2976832,
        "filtered": "1.23"
      }
    }
  }
}

第三步:为什么单列索引没用?

orders 表上有这几个索引:

  • idx_user_id (user_id)
  • idx_create_time (create_time)
  • idx_order_status (order_status)
  • idx_store_id (store_id)

单看每个条件都有自己的索引。但这条查询涉及了三个字段的组合条件:

WHERE order_status = 1          -- 等值条件,有 idx_order_status
  AND create_time > '...'       -- 范围条件,有 idx_create_time
  AND store_name LIKE '%旗舰店%' -- 左模糊,有 idx_store_id

MySQL 优化器面临一个选择:

  • idx_order_status → 找到所有 status=1 的行(约 60 万行)→ 回表检查 create_time 和 store_name
  • idx_create_time → 找到时间范围内的行(约 200 万行)→ 回表检查 order_status 和 store_name
  • 全表扫描 → 顺序 IO,但避免多次回表的随机 IO

优化器估算后发现:三个单列索引各自过滤后仍需大量回表,回表的随机 IO 成本综合下来反而高于全表扫描的顺序 IO。所以它放弃使用任何索引,选择了 type=ALL。

根因分析

核心问题可以拆解为三层:

1. 索引层面:缺少复合索引

三个条件分别有自己的单列索引,但没有一个索引能同时覆盖 order_status(等值)和 create_time(范围)。order_status=1 过滤后约 20% 数据,create_time > '...' 过滤后约 67%,两个条件叠加有优化的空间。

复合索引最左前缀原则:将等值条件(order_status)放在复合索引左侧,范围条件(create_time)放在右侧,可以最大化过滤效率。

2. SQL 层面:LIKE 左模糊注定无法走索引

AND store_name LIKE '%旗舰店%'

前缀模糊查询(%keyword%)无法使用 B+Tree 索引的有序性进行快速定位,这个条件在全表扫描的基础上做字符串匹配,进一步加剧了性能问题。

3. 运维层面:慢查询日志未配置

  • long_query_time 默认 10 秒,远高于合理阈值(通常 0.5~1 秒)
  • slow_query_log 默认关闭
  • 没有慢查询监控告警

这意味着这条 SQL 在上线后逐步变慢的过程中,完全处于无人察觉的状态,直到连接池被打满才被发现。

补充:为什么测试没发现?

测试环境订单表只有几千条数据,全表扫描和索引扫描的性能差异几乎可以忽略。当数据量增长到 300 万行时,O(n) 和 O(log n) 的差距才被指数级放大。

修复方案

方案一:创建复合索引(核心)

ALTER TABLE orders ADD INDEX idx_status_time (order_status, create_time);

这个索引的设计思路:

  • order_status 是等值查询(=),放在最左侧,精确匹配
  • create_time 是范围查询(>),放在右侧,在等值结果内做范围扫描
  • 复合索引本身按 (order_status, create_time) 排序,ORDER BY create_time DESC 可以直接走索引的有序性,消除 filesort

加完索引后验证:

加索引后 EXPLAIN

         type: ref
          key: idx_status_time
         rows: 48726
        Extra: Using index condition; Using where; Using filesort
  • type=ref — 不再是全表扫描,走索引等值查找
  • rows=48,726 — 从 297 万降到 4.87 万,减少 98.4%
  • key=idx_status_time — 成功用上了复合索引

实际查询耗时从 18.42 秒降到了 0.06 秒,快了 300 多倍。

方案二:覆盖索引(进一步优化)

ALTER TABLE orders ADD INDEX idx_status_time_cover (
  order_status, create_time,
  id, order_no, user_id, store_name, total_amount
);

将所有查询列都包含到索引中,避免回表。此时 Extra 中会出现 Using index(覆盖索引),性能更进一步。

方案三:LIKE 模糊查询改造(长期方案)

如果 store_name LIKE '%关键词%' 是业务刚需,有几种改造思路:

  1. 冗余标记字段:在业务写入时解析出"是否含关键词",存为 TINYINT 精确字段
  2. 全文索引:MySQL 全文索引支持 MATCH AGAINST,比 LIKE %keyword% 高效
  3. ES 搜索引擎:对复杂度高的搜索需求,拆到 Elasticsearch

验证结果

加索引后,Threads_connected 从 487 迅速回落到 203,接口响应恢复正常。

优化前后 EXPLAIN 对比

字段 优化前 优化后
type ALL ref
rows 2,976,832 48,726
Query_time 18.42s 0.06s
Extra Using filesort Using index condition

Handler 状态对比也印证了索引的生效:Handler_read_key 从接近于 0 变为 48,728,说明索引被有效使用。

避坑建议

  1. 慢查询日志必须配long_query_time 设 0.5~1 秒,接入监控告警
  2. SQL Review 卡口:上线前必须贴 EXPLAIN,type=ALL 的 SQL 禁止上线
  3. 复合索引优先单列索引:业务查询往往是多条件组合,单列索引覆盖能力有限
  4. 关注 Extra 中的 Using filesort:排序不走索引往往是性能杀手
  5. 测试数据量不能太小:几千行 vs 百万行,执行计划可能完全不同

附:完整命令清单

-- 查看连接数和慢查询配置
SHOW GLOBAL STATUS LIKE '%Threads_connected%';
SHOW GLOBAL STATUS LIKE '%Threads_running%';
SHOW VARIABLES LIKE 'slow_query_log%';
SHOW VARIABLES LIKE 'long_query_time';

-- 开启慢查询日志(生产谨慎设置,低阈值会增加 IO 开销)
SET GLOBAL slow_query_log = ON;
SET GLOBAL long_query_time = 0.5;
SET GLOBAL log_queries_not_using_indexes = ON;

-- EXPLAIN 分析
EXPLAIN SELECT ... \G
EXPLAIN FORMAT=JSON SELECT ... \G

-- 加索引
ALTER TABLE orders ADD INDEX idx_status_time (order_status, create_time);
ALTER TABLE orders ADD INDEX idx_status_time_cover (order_status, create_time, id, order_no, user_id, store_name, total_amount);

-- 更新统计信息
ANALYZE TABLE orders;

-- 查看索引使用情况
SHOW STATUS LIKE 'Handler_read%';