MySQL 慢 SQL 优化实战:从 EXPLAIN type=ALL 到复合索引,查询耗时从 18s 降到 0.06s

叙事框架:现象 → 排查过程 → 根因 → 修复 → 预防

问题现象

本文记录了一次因慢 SQL 导致数据库连接池被打满的线上故障排查过程。故障表现为订单服务 P95 响应时间飙升至 32.7 秒,连接池使用率达到 97.4%。通过开启慢查询日志定位到一条扫描 297 万行的 SELECT 查询,EXPLAIN 分析显示 type=ALL(全表扫描)、key=NULL(未使用索引)、Extra=Using filesort(磁盘排序)。orders 表虽有单列索引但优化器选择了全表扫描。根因分析后发现:三个查询条件跨三个卐�列索引,优化器认为回表成本高于全表扫描。解决方案是创建复合索引 idx_status_time(order_status, create_time),优化后 type 变为 ref,扫描行数降至 4.87 万,查询耗时从 18.42 秒降至 0.06 秒。

排查过程

第一步:开启慢查询日志

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

sql 复制代码
SHOW VARIABLES LIKE 'slow_query_log%';
SHOW VARIABLES LIKE 'long_query_time';

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

立即调整:

sql 复制代码
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 内容如下:

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:

bash 复制代码
           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 --- 排序无法走索引,使用磁盘文件排序
json 复制代码
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)

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

bash 复制代码
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 左模糊注定无法走索引

sql 复制代码
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) 的差距才被指数级放大。

修复方案

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

sql 复制代码
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

加完索引后验证:

bash 复制代码
         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 多倍。

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

sql 复制代码
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,接口响应恢复正常。

字段 优化前 优化后
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 百万行,执行计划可能完全不同

附:完整命令清单

sql 复制代码
-- 查看连接数和慢查询配置
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%';
相关推荐
白露与泡影14 小时前
Redis 缓存实战:雪崩、穿透、击穿一次讲透
数据库·redis·缓存
赵渝强老师15 小时前
【赵渝强老师】使用Oracle可传输的表空间迁移数据
数据库·oracle
Csvn15 小时前
Day 3:LIKE 与模式匹配 — 让查询学会"模糊搜索"
后端·sql
IpdataCloud16 小时前
跨区服玩家延迟高怎么办?用IP离线库自动分配到最近服务器
数据库·tcp/ip·github·php·ip
wuqingshun31415917 小时前
MYSQL默认的事务隔离级别是什么?为什么选择这个级别?
数据库·mysql
空中湖17 小时前
电池简史(五):极限之梦与终章——锂硫、锂空气、量子电池,以及你该知道的电池投资与生活指南
数据库·程序人生
蓝胖子酱18 小时前
在 Electron 中用 electron-store 实现加密存储与自定义格式文件
前端·javascript·数据库
snow@li19 小时前
Redis:数据库语法速查表 / 全景梳理
数据库·redis·缓存
Devlive 开源社区19 小时前
Nebula 1.6.0 发布:跨区域桶自动路由、秒传、文本/PDF 预览一次到位
数据库·sql
leonshi19 小时前
数据库关联表查询出重复数据的原因分析与解决
sql·重复数据