MySQL 慢 SQL 排查与性能调优:从发现、定位到验证的实战流程

慢 SQL 优化不是看到 type = ALL 就加索引,而是一个可验证的闭环:先确认影响,再用执行计划定位根因,最后在接近生产的数据上验证收益与风险。

本文以 MySQL 8.0 和 InnoDB 为主。MySQL 5.7 缺少 EXPLAIN ANALYZE,应结合慢查询日志、EXPLAIN 与 Performance Schema 完成同样的判断。

1. 先建立排查路径

一次完整的慢 SQL 排查应回答五个问题:

  1. 哪条 SQL 在什么场景下变慢,影响了多少请求?
  2. 它实际扫描了多少行、返回了多少行,是否使用了预期索引?
  3. 瓶颈来自索引、回表、排序、临时表、锁等待,还是数据库之外的资源竞争?
  4. 应该改索引、改 SQL、改分页方式,还是调整数据模型?
  5. 优化后是否在真实参数和高并发下仍然有效,能否安全回滚?

不要跳过前两步直接建索引。错误索引会增加写放大和存储成本,也可能让优化器在其他查询上选择更差的计划。

2. 第一步:确认问题并收集现场

2.1 明确“慢”的标准

先同时看业务指标和数据库指标:接口 P95/P99 延迟、错误率、QPS、数据库 CPU、IOPS、活跃连接数、锁等待和复制延迟。单条 SQL 耗时高不一定是线上事故;高频 SQL 每次多耗 10ms,也可能比低频 2 秒 SQL 更值得优先处理。

慢查询日志用于找到候选 SQL。线上应由运维配置阈值、采样和日志轮转;不要在高峰期临时将阈值设得过低。排查时至少记录:脱敏后的 SQL 模板、实际绑定参数、执行次数、平均/最大耗时、返回行数和调用入口。

2.2 用性能视图确认高消耗语句

在开启 sys schema 的环境中,可以先查看累计消耗较高的语句:

SELECT query, exec_count, total_latency, avg_latency, rows_examined
FROM sys.statement_analysis
ORDER BY total_latency DESC
LIMIT 10;

这类聚合结果用于排序和筛选,不等于某一次请求的真实执行计划。拿到候选 SQL 后,应使用与线上一致的参数复现。

2.3 先区分 SQL 慢与等待慢

执行时间可能被锁等待、连接池排队或磁盘抖动放大。若 SQL 本身计划合理但延迟突增,应同时检查事务持续时间、死锁/锁等待、连接数和存储指标。不要把所有超时都归因于索引。

3. 第二步:读懂执行计划

3.1 从 EXPLAIN 开始

EXPLAIN
SELECT id, user_id, amount
FROM orders
WHERE status = 'PAID'
  AND created_at >= '2026-07-01'
ORDER BY created_at DESC
LIMIT 20;

优先检查以下字段:

字段 要问的问题
key / possible_keys 是否使用了预期索引?候选索引为何没有被选中?
type 是精准查找、范围扫描,还是大范围扫描?
rows / filtered 优化器估算要读取多少行、过滤掉多少行?
Extra 是否出现 Using filesortUsing temporary 或不必要的回表?

type = ALL 不是自动判错。小表、低频后台任务,或优化器判断全表扫描成本更低时都可能合理。应结合表大小、执行频率、实际扫描行数和延迟判断。

3.2 用 EXPLAIN ANALYZE 验证估算

MySQL 8.0.18+ 可使用:

EXPLAIN ANALYZE
SELECT id, user_id, amount
FROM orders
WHERE status = 'PAID'
  AND created_at >= '2026-07-01'
ORDER BY created_at DESC
LIMIT 20;

它会执行语句并输出实际耗时、循环次数和行数。应在预发或只读副本上优先使用;对代价高的生产查询必须谨慎,因为它不是纯解释命令。重点观察实际行数是否远高于估算,以及时间是否集中在扫描、排序或回表阶段。

4. 第三步:定位常见根因

4.1 索引未命中

常见原因包括:

  1. 对索引列使用函数或表达式,例如 DATE(created_at) = '2026-07-01'
  2. 隐式类型转换,例如数字列与字符串参数比较。
  3. 联合索引不满足最左前缀,或范围条件使后续列难以继续用于筛选/排序。
  4. 统计信息过旧,导致优化器估算偏差。

例如,将函数条件改为可利用索引的范围条件:

-- 不利于 created_at 索引
WHERE DATE(created_at) = '2026-07-01'

-- 有利于范围扫描
WHERE created_at >= '2026-07-01'
  AND created_at < '2026-07-02'

4.2 扫描过多与回表成本

若查询只需少量列,却使用 SELECT *,二级索引命中后仍可能频繁回表。可以考虑让查询列被联合索引覆盖,但不要为了覆盖索引无限添加列。索引设计应同时评估读收益、写入成本和存储空间。

4.3 排序、分组与临时表

Using filesort 不必然有害,但对大结果集排序会带来明显 CPU 与磁盘临时空间成本。检查 WHEREORDER BYGROUP BY 是否能由同一联合索引支持,并避免在高基数结果集上先取全量再排序。

4.4 锁与长事务

更新/删除缓慢时,执行计划只是部分原因。长事务会持有锁并阻碍清理,进而放大延迟。此时要检查事务边界、批处理大小和并发写入,而不是只增加索引。

5. 第四步:选择优化方案

5.1 优先修正索引与查询条件

对于固定过滤条件和稳定排序,可以用联合索引匹配访问路径。例如查询经常按状态过滤、按创建时间倒序返回,可评估:

CREATE INDEX idx_orders_status_created_at
ON orders (status, created_at DESC);

创建前应检查现有索引是否已经覆盖该前缀,并在预发环境对比建索引前后的 EXPLAIN ANALYZE。生产环境的大表 DDL 还要评估锁策略、变更窗口和回滚方案。

5.2 深分页:优先游标分页

偏移量分页会扫描并丢弃 OFFSET 之前的数据:

SELECT id, amount
FROM orders
WHERE status = 'PAID'
ORDER BY id
LIMIT 100000, 20;

对连续浏览场景,推荐记录上一页最后一个 ID:

SELECT id, amount
FROM orders
WHERE status = 'PAID'
  AND id > :last_id
ORDER BY id
LIMIT 20;

需要为 status, id 的访问路径准备合适索引。若产品必须支持跳转页码,可评估延迟关联,但仍需显式 WHERE 和稳定 ORDER BY,并确认子查询能走覆盖索引。

5.3 COUNT(*) 与聚合

InnoDB 的精确 COUNT(*) 通常需要扫描索引,表很大时不应在高频接口中实时执行全量统计。可根据业务允许的准确性选择预聚合表、异步计数、缓存或近似统计。先明确一致性要求,再选择方案。

5.4 FORCE INDEX 只能作为受控兜底

FORCE INDEX 适用于已经证明优化器因统计信息或特定复杂条件选错计划的场景。使用前后必须比较真实参数下的执行计划和延迟,并监控数据分布变化。它不是替代索引设计的常规手段,错误强制会在数据增长后恶化性能。

6. 大表归档与删除

归档和删除的目标是避免长事务、锁等待和复制延迟。推荐按主键或时间范围分批处理,每批控制行数和事务时间,并在每批后观察主库负载与副本延迟。

执行前应准备备份、校验规则和停止条件;执行后验证归档数据完整性、在线表行数变化及磁盘回收策略。不要把一次性大 DELETE 当作常规维护手段。

7. 第五步:验证收益并防止回归

一次优化至少应完成以下检查:

  1. 用相同参数对比优化前后的执行计划、扫描行数和实际耗时。
  2. 在接近生产的数据量与并发下压测,观察 P95/P99、CPU、IO、锁等待和复制延迟。
  3. 灰度发布,保留快速回滚路径,避免索引或 SQL 变更一次覆盖全部流量。
  4. 将慢查询模板、阈值、告警和上线检查纳入长期监控。

优化的最终标准不是让某个字段“看起来更好”,而是在可接受的写入成本下,让关键业务请求稳定地完成更少的无效工作。

8. 上线前检查清单

  • 已确认慢 SQL 的调用入口、真实参数和影响范围。
  • 已用 EXPLAINEXPLAIN ANALYZE 找到瓶颈。
  • 已评估索引、SQL 改写和数据模型的取舍。
  • 已在接近生产的数据上完成验证。
  • 已准备灰度、监控和回滚方案。