慢 SQL 排查与优化
1. 完整回答
慢 SQL 指执行时间超过预期的 SQL 语句,可能导致接口响应变慢、数据库资源占用升高,严重时还会影响整个系统的吞吐量。
对于慢 SQL,通常按照定位问题、分析执行计划、针对性优化、验证效果这几个阶段处理。
首先,通过慢查询日志、应用监控或者 Performance Schema 定位具体 SQL,重点关注执行时间、执行次数、扫描行数和锁等待情况。对于执行频率高、累计耗时大的 SQL,会优先处理。
找到 SQL 后,使用 EXPLAIN 分析执行计划,重点看 type、key、rows 和 Extra,判断有没有全表扫描、索引使用不合理、额外排序或者临时表等问题。必要时使用 EXPLAIN ANALYZE 对比预估行数和实际执行情况。
接下来根据原因优化。如果是索引问题,可以结合查询条件和排序字段设计联合索引,尽量减少扫描行数和回表次数;如果是 SQL 本身的问题,可以优化查询条件、减少返回字段,或者通过游标分页解决深分页性能问题。
如果执行计划正常,但 SQL 偶尔变慢,还需要排查锁等待、长事务、数据库 CPU、磁盘 I/O 和连接数等因素。
最后,在接近生产环境的数据量和负载下验证优化前后的执行时间、扫描行数以及资源消耗,确认没有影响其他查询或写入性能后再上线。
整体思路就是:先定位真正的耗时环节,再减少无效的数据访问和资源消耗,最后通过实际指标验证优化效果。
2. 核心原理与关键概念
2.1 核心原理
慢 SQL 的底层原因通常集中在两个方面:执行过程中处理的数据过多,或者等待资源的时间过长。
以 MySQL InnoDB 为例,排查流程可以分成五步。
步骤一:发现慢 SQL → 确定影响范围
通过慢查询日志、Performance Schema 或应用监控定位问题。
重点关注:
- 执行耗时:单次执行的平均耗时、最大耗时和 P95 延迟。
- 执行频率:单次较快但执行量巨大的 SQL,也可能造成较高负载。
- 扫描行数:
Rows_examined明显高于Rows_sent,说明可能进行了大量无效扫描。
可以使用 mysqldumpslow 或 pt-query-digest 对慢查询日志进行聚合分析,也可以查看 sys.statement_analysis。
步骤二:分析执行计划 → 找到性能瓶颈
使用 EXPLAIN 查看优化器选择的访问路径,判断索引、连接、排序和临时表的使用情况。
| 字段 | 含义 | 关注点 |
|---|---|---|
type |
表访问方式 | ALL 为全表扫描,index 为全索引扫描,range 为索引范围扫描 |
possible_keys |
候选索引 | 有哪些索引可能参与查询 |
key |
实际选用的索引 | 是否符合预期 |
rows |
预计检查的行数 | 是否扫描过多数据 |
filtered |
预计条件过滤比例 | 过滤效果是否合理 |
Extra |
额外执行信息 | 关注 Using filesort、Using temporary 等 |
EXPLAIN 中的行数主要是估算值。需要确认实际扫描情况时,可以使用 EXPLAIN ANALYZE,查看实际耗时、返回行数和循环次数。
注意:EXPLAIN ANALYZE 会实际执行语句,应优先在测试环境验证,避免直接对生产环境中的高开销 SQL 使用。
步骤三:判断原因 → 区分扫描开销和等待开销
扫描开销主要包括全表扫描、大范围索引扫描、频繁回表、排序、临时表和低效关联。
等待开销主要包括行锁等待、元数据锁等待、磁盘 I/O 和系统资源竞争。
这一步决定后续优化方向,避免所有问题都通过添加索引解决。
步骤四:针对性优化 → 降低实际执行成本
- 索引优化:设计联合索引、提高过滤效率、尽量利用覆盖索引。
- SQL 优化:避免无效扫描、减少返回字段、优化排序和分页。
- 事务优化:缩短事务时间、及时提交事务、减少锁冲突。
- 架构优化:在单条 SQL 已合理优化后,根据瓶颈考虑缓存、预聚合、归档或读写扩展。
步骤五:验证效果 → 持续监控
优化前后对比执行计划、实际耗时、扫描行数、CPU 和 I/O 消耗,并关注并发场景下的整体吞吐量。
2.2 关键概念
| 概念 | 核心含义 |
|---|---|
| 慢查询日志 | 记录满足慢查询条件的 SQL,用于发现历史慢查询 |
| 执行计划 | 优化器选择的执行路径,包括表访问方式、索引和连接策略 |
| 联合索引 | 由多个字段组成的索引,可以同时服务于过滤、排序等操作 |
| 回表 | 通过二级索引取得主键后,再访问聚簇索引读取完整数据 |
| 覆盖索引 | 查询需要的列均能从选用的索引中获取,从而避免额外读取完整数据行 |
| 索引下推 | 在存储引擎读取二级索引时先判断部分条件,减少不必要的回表 |
| 锁等待 | SQL 因其他事务持有冲突锁而被阻塞 |
3. 高频面试追问
3.1 明明创建了索引,为什么 SQL 还是很慢?
创建索引不代表查询一定高效,关键要看索引能减少多少数据访问。
常见原因有四种。
第一,索引选择性较差。比如 status 字段只有几个状态值,查询某个状态可能仍然需要扫描大量记录。
第二,发生大量回表。即使通过二级索引定位了记录,如果查询字段没有被索引覆盖,仍然需要根据主键访问聚簇索引。
第三,索引只能帮助过滤部分数据,排序、分组或者关联操作仍然可能产生较大开销。
第四,SQL 本身执行效率正常,但存在锁等待或系统资源竞争。
因此,排查时需要结合实际扫描行数、回表次数、执行计划和等待事件综合判断。
继续追问:优化器选择了不合适的索引怎么办?
先检查数据分布和统计信息,确认优化器有没有错误估算扫描行数。统计信息不准确时,可以通过 ANALYZE TABLE 更新相关统计信息,再重新观察执行计划。
如果问题仍然存在,可以调整联合索引设计。索引提示,例如 FORCE INDEX,适合作为经过验证后的特定优化手段,因为数据分布发生变化后,强制指定的索引也可能变得不合适。
3.2 EXPLAIN 中 type=ALL,就一定需要优化吗?
不一定,ALL 表示全表扫描,但全表扫描并不总是性能问题。
如果表很小,直接扫描整张表可能比访问索引更高效。
另外,如果查询需要返回表中大部分数据,使用二级索引可能产生大量随机回表操作,成本反而比顺序扫描更高。
因此,判断重点是实际扫描量、数据规模和执行耗时。
同样,type=index 表示全索引扫描,虽然访问了索引,但仍然可能需要扫描大量索引记录。
EXPLAIN 主要用来分析访问路径,最终优化效果需要通过实际执行数据确认。
3.3 联合索引应该如何设计?
联合索引需要结合查询条件、排序方式和数据分布设计,通常优先考虑等值过滤字段,再考虑范围和排序字段。
例如订单查询经常使用以下条件:
SELECT id, status, create_time
FROM orders
WHERE user_id = 1001 AND status = 1
ORDER BY create_time DESC
LIMIT 20;
可以考虑建立 (user_id, status, create_time) 联合索引。
其中,user_id 和 status 用于等值过滤,create_time 可以支持索引有序读取,减少额外排序。
它的底层依据是 B+ 树按照联合索引的字段顺序组织数据,只有前面的字段确定后,后续字段才能充分利用有序性。
具体字段顺序还需要结合其他查询模式、字段选择性和执行计划判断。索引设计的最终目标是减少实际访问的数据量,同时控制索引维护成本。
3.4 联合索引遇到范围查询后,后面的字段就完全失效了吗?
不会完全失效,但后续字段通常难以继续用于缩小索引扫描范围。
例如存在联合索引 (a, b, c):
WHERE a = 1 AND b > 10 AND c = 5
a 可以进行等值定位,b 用于确定扫描范围。
由于 b 已经是范围条件,c 通常无法继续缩小 B+ 树需要扫描的索引区间。
但 c 仍然可能通过索引下推参与过滤,减少回表次数。如果查询满足覆盖索引条件,也可能直接从索引获取数据。
因此,需要区分索引范围定位能力和索引过滤能力,不能直接认为范围条件之后的字段完全没有作用。
3.5 哪些 SQL 写法会导致索引利用不充分?
主要是破坏了索引列原有的有序比较方式,或者让优化器认为使用索引成本过高。
常见情况包括:
- 对索引列使用函数:例如
DATE(create_time) = '2026-10-01',通常可以改成时间范围查询。 - 对索引列进行计算:例如
age + 1 = 20,可以改为age = 19。 - 前置模糊匹配:例如
LIKE '%abc',普通 B+ 树索引通常无法直接进行前缀范围定位。 - 隐式类型转换:例如字符串字段与数字比较,可能导致索引列发生转换。
- 联合索引缺少有效前缀:可能无法直接使用索引定位目标范围。
需要注意,这些情况并非一定导致全表扫描。MySQL 可能选择全索引扫描、索引下推或其他优化方式,部分函数表达式也可以通过函数索引优化。
因此,最终仍然需要检查实际执行计划。
3.6 Using filesort 和 Using temporary 一定代表 SQL 很慢吗?
不一定,它们表示执行过程中存在额外操作,具体性能影响取决于数据量和执行成本。
Using filesort 表示 MySQL 需要执行额外排序。这里的 filesort 只是排序操作名称,数据量较小时可能完全在内存中完成。
Using temporary 表示执行过程中使用了内部临时表,常见于某些分组、去重和复杂查询。
优化时可以先检查 WHERE 与 ORDER BY 是否能够使用同一个合适的联合索引。
如果查询本身必须排序或聚合,应该重点减少进入排序和聚合阶段的数据量,同时观察是否产生磁盘临时表。
3.7 为什么深分页会越来越慢?应该怎么优化?
深分页的主要问题是数据库需要处理大量前置记录,最终却只返回很少的数据。
例如:
SELECT * FROM orders
ORDER BY id
LIMIT 100000, 20;
即使按照主键索引顺序读取,数据库通常仍然需要跳过前面的十万条记录,才能返回目标数据。
如果业务按照上一页继续加载,可以使用游标分页,也叫键集分页。
例如按照主键递增分页:
SELECT * FROM orders
WHERE id > 100000
ORDER BY id
LIMIT 20;
这样可以通过索引直接定位到游标附近,减少跳过大量记录的开销。
如果按照创建时间排序,通常需要结合创建时间和唯一 ID 构造稳定的排序游标,避免时间重复导致分页不稳定。
游标分页适合连续翻页、消息列表和订单流水等场景,但无法方便地直接跳转到任意页码。
3.8 多表 JOIN 查询很慢,应该如何优化?
JOIN 优化的核心是减少参与关联的数据量,并降低关联匹配的成本。
首先,通过 EXPLAIN 查看连接顺序、访问方式以及每张表预计扫描的行数。
其次,确保关联字段具有合适的索引,尤其需要关注被驱动表的关联字段。通过索引定位匹配记录,通常可以减少重复扫描。
同时,检查关联字段的数据类型、字符集和排序规则是否一致,避免不必要的转换。
如果关联结果非常大,还需要尽量提前过滤无关记录,减少返回字段,并检查是否存在一对多关联导致结果集膨胀的问题。
对于复杂 JOIN,可以使用 EXPLAIN ANALYZE 查看各执行节点的实际行数和循环次数,确认主要开销出现在哪个环节。
3.9 为什么一条 SQL 平时很快,偶尔突然变慢?
这种情况需要优先检查锁等待、数据分布变化和数据库运行状态。
如果 SQL 的执行计划长期稳定,但只有部分请求明显变慢,可能是其他事务持有冲突锁,导致当前 SQL 等待。
可以通过 SHOW FULL PROCESSLIST 查看正在执行的 SQL,通过 information_schema.innodb_trx 和 sys.innodb_lock_waits 分析事务及锁等待关系。
另外,还需要检查数据库 CPU、磁盘 I/O、连接数,以及慢请求是否集中出现在业务高峰期。
如果只有某些查询参数特别慢,则要进一步检查数据倾斜、过滤条件的选择性和执行计划变化。
最终需要根据实际等待事件和监控指标确定原因,避免单纯通过增加索引处理并发问题。
3.10 项目中遇到订单列表查询很慢,具体怎么优化?
可以从订单列表的过滤条件、排序方式和回表成本入手。
例如,订单表数据量比较大,业务需要根据用户 ID 和订单状态查询订单,并按照创建时间倒序分页。
排查时先通过慢查询日志定位 SQL,再查看执行计划。如果发现扫描行数很大,并且存在 Using filesort,就需要检查现有索引是否与查询条件匹配。
假设原来只有 user_id 单列索引,那么数据库找到该用户的订单后,仍然可能需要过滤状态并进行排序。
针对这个查询模式,可以考虑建立 (user_id, status, create_time) 联合索引,让过滤和排序尽可能利用同一个索引。
如果查询只需要少量字段,还可以结合覆盖索引减少回表。
对于翻页越来越慢的问题,可以将深分页改成基于稳定排序键的游标分页。
最后使用相同查询条件和接近生产环境的数据量,对比优化前后的执行时间、实际扫描行数和资源消耗,同时检查新增索引对写入性能的影响。
继续追问:是不是索引越多,查询就越快?
不是所有查询都会因为增加索引而变快。每增加一个索引,都需要额外占用存储空间,而且插入、更新和删除数据时还需要维护对应的索引结构。
多个功能相近的索引还可能造成冗余。因此,需要结合主要查询模式设计少量有效的索引,并定期评估重复索引和低价值索引。
4. 易混淆概念对比
4.1 核心概念区别
| 对比概念 | 区别 |
|---|---|
EXPLAIN vs EXPLAIN ANALYZE |
前者主要展示优化器计划,后者实际执行并展示各节点的实际耗时和行数 |
type=ALL vs type=index |
前者扫描整张表,后者扫描整棵索引;两者都可能访问大量记录 |
Using index vs Using index condition |
前者通常表示覆盖索引读取,后者表示使用索引下推进行条件过滤,仍可能回表 |
| 回表 vs 覆盖索引 | 回表需要根据二级索引中的主键再次查找数据;覆盖索引可以直接获取所需列 |
Using filesort vs Using temporary |
前者表示额外排序,后者表示使用内部临时表;两种操作可以同时出现 |
| OFFSET 分页 vs 游标分页 | 前者通过跳过指定数量的记录定位页面,后者通过上页的排序位置继续查询 |
4.2 索引扫描与索引优化的区别
| 操作 | 是否能减少扫描范围 | 主要作用 |
|---|---|---|
| 索引定位 | 通常可以 | 根据索引条件直接定位目标记录 |
| 索引范围扫描 | 通常可以 | 只扫描符合范围条件的索引区间 |
| 全索引扫描 | 通常不能 | 遍历索引记录,可能比全表扫描成本低 |
| 索引下推 | 不一定能缩小扫描区间 | 提前过滤索引记录,减少回表 |
| 覆盖索引 | 不一定能缩小扫描区间 | 避免额外访问完整数据行 |
因此,判断索引是否真正发挥作用,需要同时关注扫描范围、过滤效率和回表成本。
5. 总结
慢 SQL 排查与优化的核心是找到 SQL 执行过程中真正耗时的环节。通常先通过慢查询日志和监控定位问题,再通过 EXPLAIN 或 EXPLAIN ANALYZE 分析索引使用、扫描行数、排序和关联成本。优化时优先减少无效扫描,通过合理的联合索引、覆盖索引、SQL 改写和分页优化降低数据访问成本。如果 SQL 执行计划正常却偶尔变慢,还需要关注锁等待和系统负载。最终以实际执行耗时、扫描行数和并发性能作为验证依据,同时兼顾索引维护成本,避免局部查询优化影响系统整体性能。