MySQL 慢 SQL 在高并发场景下怎么定位和治理?一次讲清慢日志、Explain、索引优化与分页改造

大家好,我是一名有 4 年工作经验的 Java 后端开发。
最近在系统整理高并发业务场景下的一些核心设计问题,准备沉淀成一个系列。
前面几篇我写了秒杀库存扣减、缓存一致性、MQ 幂等消费、支付超时库存回补、热点 Key 治理、分布式锁、本地消息表、限流降级熔断,这一篇继续聊一个后端线上问题里最常见、也最容易被误判的话题:MySQL 慢 SQL。

🦅个人主页
🐼

文章目录


一、前言

很多人第一次遇到线上 RT 飙升,第一反应通常是:

  • Redis 出问题了?
  • 应用线程池满了?
  • 网络抖动了?

但真实线上场景里,很多问题最后查下来,根因其实是:

某条 SQL 变慢了。

而且真正麻烦的地方在于,慢 SQL 往往不是“偶尔慢一点”这么简单,而是会在高并发下放大成整个系统问题。

比如:

  • 某条列表查询走了全表扫描
  • 某个排序 SQL 触发了 filesort
  • 深分页把数据库拖慢
  • 索引建了,但查询根本没走
  • 锁等待导致 SQL 明明简单却执行很久
  • 应用侧超时重试,把数据库越打越慢

最后你会看到:

  • 接口 RT 飙升
  • 数据库 CPU 打高
  • 慢日志开始刷屏
  • 应用线程大量阻塞
  • 上游接口开始雪崩

所以慢 SQL 真正要解决的问题,不只是“把某条语句改快”,而是:

在高并发场景下,怎么快速定位慢 SQL,怎么判断是真慢还是锁等待,怎么做真正有效的治理。

这篇文章就结合一个典型业务场景,把 MySQL 慢 SQL 的定位和治理思路系统讲透。


二、业务场景

先假设这样一个场景。

2.1 场景设定

电商系统里有一个“我的订单列表”接口,用户可以按时间倒序查看最近订单。

接口支持:

  • 按用户 ID 查询
  • 按订单状态筛选
  • 按下单时间倒序分页
  • 展示订单金额、状态、商品信息摘要

2.2 高峰特征

在大促期间,这个接口会出现典型的高并发特征:

  • 峰值 QPS 很高
  • 热门用户会频繁刷新
  • 分页翻页行为明显增多
  • 订单状态更新频繁
  • 查询和更新同时发生

2.3 业务要求

这个场景下,通常需要满足下面这些要求:

  • 列表查询 RT 稳定
  • 分页不能越翻越慢
  • 不影响订单写入主链路
  • 高峰期数据库 CPU 不能持续打满
  • 慢 SQL 能快速发现、快速定位、快速回滚优化

三、问题现象

很多项目里,这类订单列表查询一开始写法都很自然,比如:

select *
from order_info
where user_id = #{userId}
  and status = #{status}
order by create_time desc
limit #{offset}, #{pageSize}

表面上看,这条 SQL 非常普通。
但一旦数据量起来、高并发上来,就很容易出问题。

3.1 明明有索引,为什么还是慢?

很多人会说:

  • user_id 有索引
  • status 也有索引

那为什么还慢?

因为单列索引不一定能同时满足:

  • where 条件过滤
  • order by 排序
  • limit 分页

如果索引设计和查询模式不匹配,MySQL 依然可能:

  • 扫描大量行
  • 回表很多次
  • 做额外排序

3.2 深分页越翻越慢

比如:

select *
from order_info
where user_id = 10001
order by create_time desc
limit 100000, 20

这类 SQL 的问题在于:

  • limit 100000, 20 并不是直接跳到第 100000 条
  • MySQL 往往还是要先扫描并丢弃前面大量记录

所以页数越深,SQL 越慢。

3.3 SQL 本身不复杂,但执行时间很长

这种情况很多时候不一定是执行计划本身有问题,而是:

  • 锁等待
  • 大事务阻塞
  • 行锁竞争
  • 元数据锁

也就是说:

慢 SQL 不一定是“执行慢”,也可能是“等太久”。

3.4 高并发下慢 SQL 会放大成系统问题

一条本来执行 200ms 的 SQL,如果并发很高,就可能导致:

  • 数据库连接长期占用
  • 连接池被耗尽
  • 应用线程阻塞
  • 上游接口超时

最后问题就从“某条 SQL 偏慢”,升级成“整条业务链路不稳定”。


四、原理分析

慢 SQL 真正要解决的,不只是“让 Explain 好看一点”,而是:

搞清楚它到底慢在哪里,是扫描慢、排序慢、回表慢、锁等待慢,还是并发放大导致整体慢。

4.1 慢 SQL 常见根因有哪些?

在高并发场景里,最常见的慢 SQL 根因通常有这些:

  • 没有合适索引
  • 索引建了但没走
  • 过滤性差导致扫描行数过多
  • 排序和分页不走索引
  • 回表次数太多
  • select * 带来额外 IO
  • 隐式类型转换导致索引失效
  • 函数操作导致索引失效
  • 大事务持锁导致锁等待
  • 深分页导致大量无效扫描

4.2 为什么 Explain 只是起点,不是结论?

很多人优化 SQL 时,一上来就看 Explain。
这当然没错,但 Explain 只能告诉你:

  • 可能怎么执行

它不能完整告诉你:

  • 锁等了多久
  • 实际扫描了多少行
  • 并发下放大了多少问题
  • 这条 SQL 在高峰期有没有抖动

所以真正线上定位慢 SQL,一般要结合:

  • 慢查询日志
  • Explain / Explain Analyze
  • 执行计划
  • 锁等待信息
  • 业务调用链
  • 数据量和并发模型

4.3 为什么“建个索引”不等于解决问题?

因为索引不是越多越好,也不是随便建一个就生效。

比如:

  • where 用的是 (user_id, status)
  • order by 用的是 create_time desc

那你只建 user_idstatus 单列索引,效果可能非常有限。

更合理的思路通常是:

根据查询条件、排序方式、返回字段,一起设计复合索引。

4.4 锁等待为什么经常被误判成 SQL 性能问题?

因为很多监控里只看到:

  • SQL 执行耗时 3 秒

但这 3 秒里,可能真正执行只用了 20ms,剩下的时间都在等锁。

所以遇到慢 SQL 时,一定要先分清:

  • 是执行计划差
  • 还是锁冲突严重

这一步很关键。


五、常见方案对比

下面把几种常见的慢 SQL 治理方式放在一起看一下。

手段 主要解决问题 优点 缺点 适用场景
补索引 过滤和排序走索引 最直接有效 索引太多会影响写入 高频查询
改 SQL 写法 避免隐式转换、函数、深分页 成本低,收益高 需要理解执行计划 常见首选
覆盖索引 减少回表 读性能提升明显 索引更大 列表页、查询页
拆分大查询 降低单次扫描和排序成本 控制复杂 SQL 风险 代码更复杂 聚合型查询
归档 / 分表 降低单表数据量 长期收益高 成本大 超大表场景
读写分离 / 缓存 分担主库压力 见效快 一致性更复杂 读多写少场景

如果是大多数互联网业务,我更推荐:

先定位真实瓶颈,再优先做“复合索引 + SQL 改写 + 深分页改造 + 锁冲突治理”。

因为很多慢 SQL,根本不需要一上来就分库分表。


六、推荐方案设计

这里给一版更贴近线上落地的思路。

6.1 诊断顺序不要乱

如果线上出现慢 SQL,我一般会按这个顺序查:

  1. 先看是不是高峰期突发
  2. 查慢查询日志,确认具体 SQL
  3. 看调用链,确认是哪个接口触发
  4. Explain / Explain Analyze 看执行计划
  5. 看扫描行数、排序、回表情况
  6. 再排查是否有锁等待
  7. 最后再决定是补索引、改 SQL,还是拆业务

这个顺序很重要。

很多人一上来就“建索引试试”,最后容易治标不治本。

6.2 订单列表这种场景,索引应该怎么想?

以这个 SQL 为例:

select id, order_no, status, total_amount, create_time
from order_info
where user_id = #{userId}
  and status = #{status}
order by create_time desc
limit 20

更合理的索引设计通常会考虑:

create index idx_user_status_ctime on order_info(user_id, status, create_time desc);

这样有机会同时兼顾:

  • user_idstatus 过滤
  • create_time desc 排序
  • limit 快速截断

如果返回字段还能被索引覆盖,效果会更好。

6.3 深分页为什么更推荐游标翻页?

因为深分页的核心问题不是“结果要 20 条”,而是“前面被跳过的几十万条也要处理”。

所以更好的写法通常是基于上一次最后一条记录翻页:

select id, order_no, status, total_amount, create_time
from order_info
where user_id = #{userId}
  and create_time < #{lastCreateTime}
order by create_time desc
limit 20

这类写法通常叫:

  • 游标分页
  • Seek Method

它比深分页更适合高并发系统。


七、落地代码

下面给一版比较贴近实际项目思路的代码。

7.1 开启慢查询日志

首先要让慢 SQL 可见。

set global slow_query_log = 'ON';
set global long_query_time = 0.2;
set global log_queries_not_using_indexes = 'ON';

这里的思路不是“永远开到特别低”,而是:

  • 在线上结合场景合理配置
  • 在问题排查期临时拉低阈值

7.2 用 Explain 看执行计划

以订单列表 SQL 为例:

EXPLAIN
select id, order_no, status, total_amount, create_time
from order_info
where user_id = 10001
  and status = 1
order by create_time desc
limit 20;

重点要看这些字段:

  • type
  • key
  • rows
  • Extra

尤其要注意这些信号:

  • type=ALL:可能全表扫描
  • Using filesort:额外排序
  • Using temporary:可能用了临时表
  • rows 很大:扫描行数过多

7.3 复合索引优化示例

如果原来只有单列索引:

create index idx_user on order_info(user_id);
create index idx_status on order_info(status);

更合理的复合索引可能是:

create index idx_user_status_ctime on order_info(user_id, status, create_time desc);

优化后的 SQL:

select id, order_no, status, total_amount, create_time
from order_info
where user_id = #{userId}
  and status = #{status}
order by create_time desc
limit 20;

这种场景下,复合索引通常比多个单列索引更有效。

7.4 避免索引失效的典型写法

下面几种写法都很容易让索引白建。

比如对索引列做函数:

select *
from order_info
where date(create_time) = '2026-03-31';

更好的写法是:

select *
from order_info
where create_time >= '2026-03-31 00:00:00'
  and create_time < '2026-04-01 00:00:00';

再比如隐式类型转换:

select *
from order_info
where user_id = '10001';

如果字段是数值型,最好保证参数类型一致,避免隐式转换风险。

7.5 深分页改造成游标分页

原来的深分页:

select id, order_no, status, total_amount, create_time
from order_info
where user_id = #{userId}
order by create_time desc
limit #{offset}, 20;

改造后的游标分页:

select id, order_no, status, total_amount, create_time
from order_info
where user_id = #{userId}
  and create_time < #{lastCreateTime}
order by create_time desc
limit 20;

如果还要防止同一秒数据重复,可以用联合游标:

where user_id = #{userId}
  and (create_time < #{lastCreateTime}
       or (create_time = #{lastCreateTime} and id < #{lastId}))
order by create_time desc, id desc
limit 20;

7.6 判断是不是锁等待

如果你怀疑是锁问题,而不是执行计划问题,可以重点查:

show engine innodb status;

或者:

select *
from performance_schema.data_locks;

这一步的核心目的不是背命令,而是先分清:

  • 这是“查得慢”
  • 还是“等得久”

7.7 应用侧也要配合治理

慢 SQL 治理不只是数据库层问题,应用层也要配合:

  • 给 SQL 调用设置合理超时
  • 避免无限重试
  • 热点接口加缓存
  • 对慢接口限流降级

否则数据库刚一慢,应用侧一重试,就会把问题继续放大。


八、为什么很多项目优化了慢 SQL,线上还是会慢?

这也是线上非常常见的情况。

8.1 Explain 好看了,但业务高峰还是扛不住

因为单条 SQL 变快,不代表整体系统容量就够了。
还要看:

  • 并发量
  • 连接池大小
  • 热点资源竞争
  • 缓存命中率

8.2 只看执行计划,不看锁等待

这样很容易把锁冲突问题误判成索引问题。

8.3 索引加太多,写入反而变慢

索引不是免费的。

如果表本身写入很频繁,过多索引会导致:

  • insert/update 变慢
  • 索引维护成本变高
  • 空间占用增加

8.4 只优化 SQL,不改分页方式

深分页场景下,单靠索引优化往往治不好根。

8.5 把慢 SQL 当成单点问题,而不是链路问题

真正高并发场景里,慢 SQL 常常和这些因素一起出现:

  • 热点接口
  • 下游重试
  • 锁冲突
  • 大事务
  • 缓存失效

所以很多时候,SQL 优化只是链路治理的一部分。


九、压测与监控怎么写,文章才更像做过项目的人写的?

慢 SQL 这类文章,如果只讲索引,不讲监控和压测,很容易显得“懂 SQL,但没做过线上治理”。

所以建议补上压测和线上观测视角。

9.1 压测场景示例

这里给一个适合写进文章的测试场景:

场景配置:

  • 订单表数据量:3000 万
  • 订单列表峰值 QPS:6000
  • 应用实例数:4
  • MySQL:主从
  • 订单列表支持状态筛选和按时间倒序分页

对比方案:

  • 方案 A:单列索引 + 深分页
  • 方案 B:复合索引 + 普通分页
  • 方案 C:复合索引 + 游标分页 + 缓存热点页

9.2 压测结果示例

指标 单列索引 + 深分页 复合索引 复合索引 + 游标分页
平均 RT 420ms 110ms 45ms
TP99 2300ms 520ms 180ms
扫描行数 很高
MySQL CPU 88% 56% 38%
接口稳定性

从结果上可以看出:

  • 只靠单列索引很难同时兼顾过滤、排序和分页
  • 复合索引效果明显
  • 深分页改造往往比单纯补索引收益更大

说明:以上压测数据为示例写法,实际结果需要结合数据分布、字段选择度、索引大小和业务并发综合评估。

9.3 线上建议重点监控哪些指标?

如果你准备真正治理慢 SQL,至少建议监控这些指标:

  • 慢查询数量
  • Top N 慢 SQL 模板
  • SQL 平均耗时 / TP95 / TP99
  • 扫描行数
  • 回表比例
  • 锁等待时间
  • 数据库 CPU / IO / Buffer Pool 命中率
  • 连接池活跃连接数
  • 应用侧 SQL 超时次数
  • 重试次数和降级次数

这些指标一旦写进文章里,会明显更像真实线上经验总结。


十、面试中怎么回答这个问题?

如果面试官问你:

MySQL 慢 SQL 在高并发场景下你一般怎么定位和治理?

你可以这样回答。

10.1 回答思路

第一,我会先确认是哪个接口在高峰期 RT 飙升,然后结合慢查询日志定位到具体 SQL 模板,再看问题是长期存在还是高峰期才出现。

第二,拿到 SQL 后,我不会马上就说加索引,而是先用 Explain 或 Explain Analyze 看执行计划,重点看有没有全表扫描、是否触发 filesort、扫描行数是否过大、有没有回表过多的问题。

第三,我还会额外判断这条 SQL 是执行慢还是锁等待慢,因为很多时候 SQL 本身并不复杂,但由于大事务或行锁冲突,实际耗时会非常长。

第四,如果是查询模式和索引不匹配,我会优先考虑复合索引设计;如果是深分页场景,我会考虑改成游标分页;如果是函数、隐式类型转换导致的索引失效,我会优先改 SQL 写法。

第五,治理不能只停留在数据库层,还要结合应用侧做超时控制、缓存、限流和重试收敛,否则高并发下数据库压力会被进一步放大。

10.2 面试官更想听到什么?

面试官真正想听的,通常不是一句“加索引”,而是你有没有这些意识:

  • 你知道慢 SQL 先要分清是执行慢还是锁等待
  • 你知道 Explain 只是起点,不是全部结论
  • 你知道复合索引要和 where、order by、limit 一起设计
  • 你知道深分页是高频根因
  • 你知道索引不是越多越好
  • 你知道应用侧也要配合治理

如果你能把这些点讲清楚,面试官会明显觉得你做过真实线上排障,而不只是背过 SQL 优化口诀。


十一、总结

慢 SQL 这个问题,真正难的不是“把一条 SQL 改快”,而是如何在高并发场景下,快速判断根因、避免误判、并把数据库层和应用层治理一起补齐。

如果只记一句结论,我觉得可以记住这句:

高并发场景下治理慢 SQL,优先遵循“先定位、再区分执行慢和锁慢、再做复合索引和分页改造、最后补齐应用侧保护”。

这套思路通常比“看到慢就加索引”更稳,也更接近真实线上治理方式。


十二、后续准备继续写的内容

如果这篇你觉得还可以,后面这个系列我准备继续写:

  • JVM Full GC 问题在线上怎么排查?
  • ThreadPool 线程池参数到底怎么配才靠谱?
  • 一次真实线上接口 RT 飙升的排查复盘
  • Redis 大 Key 和热 Key 怎么分别治理?
  • 高并发系统的链路追踪和可观测性怎么落地?

如果你也在做高并发相关业务,欢迎交流。


十三、结尾

如果你觉得这篇文章对你有帮助,欢迎点赞、收藏、关注。
后面我会继续输出一些偏实战的 Java 后端文章。

我是一个正在持续沉淀高并发与后端工程实践的 Java 开发,
也欢迎大家一起讨论更好的实现方案。

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐