数据库慢查询排查的完整链路
接口变慢了,第一反应是“数据库慢查询”,第二反应是“加个索引”。
这两个反应之间,跳过了最重要的一步:确认真的是数据库慢,以及慢在哪条 SQL 上。
跳过这一步的代价很具体 —— 加了索引、没变快,然后开始怀疑索引、怀疑数据库、怀疑人生。这篇把排查顺序理一遍。
先说一次具体的
一个多端的管理系统(后台 + 门店端 + 小程序),订单列表接口随着数据积累越来越慢。后台点一次要等几秒,并发一上来小程序端直接超时。起初只有数据量大的那几个门店反馈,后来越来越普遍。
我的第一反应就是“加索引”,挑了一条看起来最慢的 SQL 加上去。没变快。
于是老老实实按顺序重来:
- 先分层计时:把一次请求里的 SQL、外部调用、应用层处理分开计时。确认瓶颈确实在数据库 —— 应用层和外部接口都不慢。
- 拿真实的 SQL:开慢查询日志,然后按总耗时排序,不按单次耗时。结果和我猜的不一样:拖慢系统的不是那条“单次最慢”的,而是列表查询本身被调用得极多,再加上“取到订单后每条再查一次明细”,一次请求打出了上百条 SQL。
- 看执行计划:列表查询按“门店 + 状态 + 时间倒序”过滤,而这三个字段上只有单列索引。结果是
type=ALL、key=NULL,Extra里同时出现Using filesort和Using temporary。 - 深分页加了最后一把火:后台越往后翻越卡,因为每翻一页都要先扫过前面所有行。
根因是三条叠在一起:联合索引缺失 + N+1 明细查询 + 深分页。正因为叠在一起,只解决任何一条都不会有明显变化 —— 这也是一开始那个索引没起作用的原因。
改法分别是:把过滤和排序字段合成一个联合索引;明细改成一次批量取回,不再逐条查;翻页改成基于排序键的游标分页。改完再验证执行计划 —— key 用上了索引、rows 明显下降、Extra 里的两个高危标记消失,接口耗时从秒级回到百毫秒级。
优化前那条列表查询,EXPLAIN 长这样:
type=ALL、key=NULL、rows 是全表量级(我当时那张订单表在百万行级别)—— 意味着每翻一页都要先扫过整张表再临时排序。Extra 里同时挂着 Using filesort 和 Using temporary。加上外层“取到订单后逐条查明细”,一次后台请求实际打出上百条 SQL —— 这就是“后台点一下等几秒、并发一上来小程序直接超时”的来源。改完之后,同一条查询变成:
type=range、key 用上了联合索引、rows 掉到个位数到几十、Extra 里只剩 Using index(覆盖索引命中)。filesort 和 temporary 都消失了。
(上面这些 type/key/Extra 的取值是真实现状,rows 的量级请按你真实那张表的大小对一下再发。)
这次最大的收获不是那三个改法,而是顺序:如果一开始就“按总耗时排序 + 看一眼 EXPLAIN”,可以省掉那一轮白做的尝试。
下面把完整顺序理一遍。
第一步:先确认瓶颈在哪一层
“接口慢”和“SQL 慢”不是一回事。在打开数据库之前,先花几分钟排除这几种:
- 网络与调用:是不是调了外部接口,对方慢
- 应用层:是不是循环里查数据库(N+1)、或者序列化/模板渲染慢
- 并发与连接池:单次请求不慢,但并发上来就慢 —— 这通常是连接池排队,不是 SQL 本身
- 锁:不是“慢”,是“卡住”。这个特征不同,排查路径也完全不同
判断方法很简单:把这条接口里的每个环节单独计时。 一次请求里,SQL 耗时占比多少、外部调用多少、应用层多少。有了这个分布,才知道该往哪看。
很多时候做到这一步就结束了 —— 慢的不是数据库。
第二步:拿到真实的那条 SQL
不要靠猜。要让数据库告诉你哪条 SQL 慢:
- 慢查询日志:设一个阈值(比如 1 秒),先看分布。重点不是“哪条最慢”,而是“哪条被调用得最多 × 单次不算慢”
- 监控/APM 里的 SQL 统计:按总耗时排序(不是按单次耗时),通常能立刻看出问题
- ORM 打印的 SQL:开发环境直接看,同时能发现 N+1 这类问题
拿到的应该是带具体参数的真实 SQL,不是模板。因为参数值不同,执行计划可能完全不同:查一个热门分类和查一个没人访问的 ID,走的路可能不一样。
第三步:看执行计划
拿到 SQL,看 EXPLAIN。几个真正值得看的字段:
| 字段 | 看什么 |
|---|---|
type |
访问类型。ALL 是全表扫描,index 是全索引扫描,range/ref/eq_ref 通常可接受,const 最好 |
key |
实际用了哪个索引。是 NULL 就说明没走索引 |
rows |
预估扫描行数。这个数远大于实际返回行数,说明索引选择性差或者用错了 |
filtered |
过滤后剩余比例。很低说明扫了大量无用行 |
Extra |
最有信息量的一列,见下 |
Extra 里几个高危信号:
Using filesort:排序没有用上索引,需要额外排序Using temporary:用了临时表,常见于GROUP BY/DISTINCT/ORDER BY组合Using join buffer:连接时没走索引Using index:覆盖索引命中,是好事
第四步:常见根因清单
按出现频率排,从最常见的开始排除。
1. 索引缺失
最直接的一种:key 是 NULL,type 是 ALL。
处理方式就是加索引。但注意索引不是越多越好 —— 每个索引都会拖慢写入,而且索引多了优化器还可能选错。加索引应该针对具体查询,不是“常用的字段都加上”。
2. 索引失效
有索引但没用上,几种经典原因:
- 字段上做运算或函数调用:
WHERE DATE(created_at) = '2026-01-01'会让索引失效,改成范围条件 - 隐式类型转换:字段是字符串,传了数字(或反过来)。这个坑特别隐蔽,因为 SQL 看起来完全正常
- 前导通配符:
LIKE '%关键词'无法走索引,LIKE '关键词%'可以 - 违反最左前缀:联合索引
(a, b, c),只查b或c用不上
判断方法:把条件里的字段原样放进索引,不要对它做任何加工。
3. 回表太多
走索引找到主键,再回主表取其余字段 —— 如果这个查询返回的列很多、行数很多,回表的开销会盖过索引的收益。
两种处理:
- 覆盖索引:把查询用到的列都放进索引,让它不用回表
- 减少返回行:分页、加条件,从业务上把结果集变小
4. 深分页
LIMIT 100000, 20 这种写法,数据库要先扫过前 100000 行再丢掉。
常见改法是用游标分页:记住上一页最后一条的排序键,下一页从它之后取。前提是这个键有索引且唯一。
5. N+1 查询
不是单条 SQL 慢,是“取 100 条记录又各查了一次关联表”,变成 101 次查询。
我那次列表接口就是典型:拿到 20 条订单,循环里对每条再 SELECT ... WHERE order_id = ? 查明细,一次请求就是 1 + 20 = 21 条 SQL,翻一页再乘 20。慢查询日志里单条都没超过阈值,看着“都正常”,但按总耗时一拉,明细查询堆在头几名 —— 就是“每次都不慢、加起来要命”的那种。
这个问题在慢查询日志里往往不显眼(每条都不慢),但总耗时占比极高。要按总耗时排序才看得出来。
6. 排序与临时表
ORDER BY 的字段不在索引里 → filesort。多字段排序方向不一致(一个升一个降)也会导致索引失效。
7. 锁等待
特征是偶发、无规律、重试就好,而不是稳定地慢。排查方向在事务粒度:有没有长事务、有没有大范围的 UPDATE、隔离级别是否需要那么高。
8. 统计信息过期
优化器选错索引,有时候是因为统计信息不准。表现为:明明有更好的索引却没用。更新统计信息可能立刻改善。
第五步:改完之后怎么验证
改完不要只看“感觉快了”,要做对比:
- 执行计划是否变了:
key、rows、Extra有没有按预期变化 - 耗时对比:同一条件下多次执行取中位数,不要只跑一次
- 注意缓存干扰:查询缓存、数据库页缓存、应用缓存都会让第二次变快。验证时要能区分“真的优化了”和“这次刚好命中缓存”
- 看总耗时,不只看单次:一条 SQL 从 50ms 降到 20ms,但每秒调用 1000 次,收益比一条 5 秒降到 2 秒的大
上线时的两条纪律
排查出问题只是第一步,改的方式也有风险:
- 大表加索引要确认是否锁表。 生产环境优先用支持在线 DDL 的方式,并挑低峰期
- 改 SQL 要能回滚。 优化 SQL 有时会改变语义(尤其是你重构了
WHERE条件的时候),上线前留好回退方案
小结
排查顺序比技巧重要:
- 先确认瓶颈在不在数据库 —— 在错误的层上加索引是纯浪费
- 拿到真实的 SQL 和真实参数 —— 不要靠猜
- 看执行计划,不只看耗时
- 从最常见的原因开始排除:索引缺失 → 索引失效 → 回表 → 分页 → N+1 → 排序 → 锁
- 按总耗时排序,不是按单次耗时
加索引只是这张清单里的第 1 条。先把它放到第四步(常见根因清单)去做。
评论
说说你的看法,或补充一个你遇到过的类似情况。