为什么明明加了索引,MySQL 还是慢?
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_id 或 status 单列索引,效果可能非常有限。
更合理的思路通常是:
根据查询条件、排序方式、返回字段,一起设计复合索引。
4.4 锁等待为什么经常被误判成 SQL 性能问题?
因为很多监控里只看到:
- SQL 执行耗时 3 秒
但这 3 秒里,可能真正执行只用了 20ms,剩下的时间都在等锁。
所以遇到慢 SQL 时,一定要先分清:
- 是执行计划差
- 还是锁冲突严重
这一步很关键。
五、常见方案对比
下面把几种常见的慢 SQL 治理方式放在一起看一下。
| 手段 | 主要解决问题 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|---|
| 补索引 | 过滤和排序走索引 | 最直接有效 | 索引太多会影响写入 | 高频查询 |
| 改 SQL 写法 | 避免隐式转换、函数、深分页 | 成本低,收益高 | 需要理解执行计划 | 常见首选 |
| 覆盖索引 | 减少回表 | 读性能提升明显 | 索引更大 | 列表页、查询页 |
| 拆分大查询 | 降低单次扫描和排序成本 | 控制复杂 SQL 风险 | 代码更复杂 | 聚合型查询 |
| 归档 / 分表 | 降低单表数据量 | 长期收益高 | 成本大 | 超大表场景 |
| 读写分离 / 缓存 | 分担主库压力 | 见效快 | 一致性更复杂 | 读多写少场景 |
如果是大多数互联网业务,我更推荐:
先定位真实瓶颈,再优先做“复合索引 + SQL 改写 + 深分页改造 + 锁冲突治理”。
因为很多慢 SQL,根本不需要一上来就分库分表。
六、推荐方案设计
这里给一版更贴近线上落地的思路。
6.1 诊断顺序不要乱
如果线上出现慢 SQL,我一般会按这个顺序查:
- 先看是不是高峰期突发
- 查慢查询日志,确认具体 SQL
- 看调用链,确认是哪个接口触发
- Explain / Explain Analyze 看执行计划
- 看扫描行数、排序、回表情况
- 再排查是否有锁等待
- 最后再决定是补索引、改 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_id、status过滤 - 按
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;
重点要看这些字段:
typekeyrowsExtra
尤其要注意这些信号:
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 开发,
也欢迎大家一起讨论更好的实现方案。
更多推荐




所有评论(0)