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%';
相关推荐
@insist1231 小时前
系统集成项目管理工程师-信息和文档管理
网络·数据库·软考·系统集成项目管理工程师·软考中项·软件水平考试
残 风2 小时前
PostgreSQL 存储机制详解
数据库·postgresql·开源·数据库开发
JacksonMx3 小时前
Java CompletableFuture 异步编程实战:从入门到架构师避坑指南
linux·数据库·python
tachibana23 小时前
性能指标的口径选择
数据库·人工智能·架构·大模型·llm
机器人梦想家4 小时前
具身机器人视觉服务的异步回调与并发控制
数据库·算法·机器人
菀亓4 小时前
ubuntu系统mysql安装
mysql
小妖同学学AI4 小时前
开源向量数据库的王者:Milvus 深度解析,专为 AI 应用打造的云原生搜索引擎!
数据库·开源·milvus
程序员贺加贝5 小时前
别被 saveBatch 骗了:一次 MyBatis-Plus 批量插入性能排查
mysql
l1258655 小时前
# RAG上线评估指标体系:六大核心指标与压测实战全解析
数据库·人工智能·python·mysql·langchain·milvus
宠友信息5 小时前
IM系统开发技术路线分析,即时通讯源码助力快速搭建聊天应用
java·spring boot·redis·websocket·mysql·uni-app·vue