慢 SQL 排查手册(面试向)
核心心法一句话:返回行数 ≠ 数据库的工作量 。
查询慢,慢在四个维度之一(或组合):扫得多、问得碎、被问爆、在排队。
总耗时 = 单次耗时 × 执行次数
单次耗时 ≈ 扫描行数(索引决定) + 回表/排序成本 + 网络往返次数(N+1决定) + 锁等待
一、问题分类总表
| 类型 | 病根 | 典型场景 | 慢查询日志能抓到吗 |
|---|---|---|---|
| A. 扫得多 | 扫描行数远大于返回行数 | 无索引、索引失效、深分页 | ✅ 能(单条就慢) |
| B. 问得碎 | 一次请求发一堆 SQL | N+1、循环内逐条查询 | ❌ 抓不到(每条都快) |
| C. 被问爆 | 执行次数过多 | 高频轮询、热点行、无缓存 | ❌ 单条快,看总量 |
| D. 在排队 | 锁等待 / 连接池耗尽 | 长事务、事务里嵌 RPC | 部分(lock wait 日志) |
面试金句 :排查慢 SQL 不是找"单条最慢的查询",而是按总耗时 = 单次耗时 × 次数排序找真凶------榜首往往是一条单条 2ms 但每秒执行几千次的蠢查询。
二、A 类:扫得多(扫描量问题)
A1. 无索引全表扫
sql
-- 1000 万行订单表,status 没索引
SELECT * FROM orders WHERE status='cancelled' ORDER BY created_at DESC LIMIT 20;
-- 返回 20 行,但全表扫 1000 万行 + 排序
- 症状 :EXPLAIN 显示
Seq Scan(PG)/type=ALL(MySQL) - 解决 :建索引
(status, created_at)------过滤列在前、排序列在后,扫描和排序一起治 - 口诀 :WHERE 决定索引前半段,ORDER BY 决定后半段
A2. 索引失效(建了 = 没建)
四种最常见的失效写法:
sql
WHERE DATE(created_at) = '2026-09-19' -- ① 列上套函数
WHERE created_at = '2026-09-19' -- ② 隐式类型转换(列是 timestamp 传字符串比较时部分场景失效;varchar 列传数字必失效)
WHERE drug_name LIKE '%地平%' -- ③ 前缀通配(后缀匹配无法走 B-tree)
WHERE a=1 OR b=2 -- ④ OR 两边非都有索引 → 退化为全表
- 解决 :
① 改范围:created_at >= '2026-09-19' AND created_at < '2026-09-20'
② 类型对齐,驱动传对类型
③ 前缀匹配用LIKE '地平%';真要全文检索上 trigram 索引 / ES
④ 两边都建索引让优化器 BitmapOr,或改 UNION
A3. 深分页
sql
LIMIT 20 OFFSET 999980; -- 翻到第 5 万页
-- 数据库要"数着跳过"999980 行,摸过 100 万行只为返回 20 行
- 解决(游标式分页 / 延迟关联):
sql
-- 上一页最后一行的 id 作为游标
WHERE id > :last_seen_id ORDER BY id LIMIT 20; -- 走索引直接定位,永远只摸 20 行
-- 不能改游标时,先窄后宽(延迟关联):
SELECT * FROM orders o
JOIN (SELECT id FROM orders ORDER BY created_at LIMIT 20 OFFSET 999980) t
ON o.id = t.id; -- 内层只扫索引拿 20 个 id,外层只回表 20 次
- 产品侧釜底抽薪:禁止跳页,只给"下一页"(微信/抖音都是这么干的)
三、B 类:问得碎(N+1 问题)
B1. 经典 N+1
查 20 条订单 1 条 SQL,5ms ✓
循环每条:查用户、查店铺、查明细 20×3 = 60 条 SQL
页面只返回 20 行,但发了 61 条查询、占连接 100ms+
- 为什么慢查询日志抓不到 :每条子查询 2ms,远低于阈值;它慢在往返次数,不是单条执行
- 发现方式:ORM 日志(echo=True / SQLAlchemy event 监听)数一个请求内的 SQL 条数;APM 看接口下挂的 span 数量
- 解决三板斧 (本项目设备列表实战,见第五节):
- JOIN 取回 :一对一关联直接
LEFT JOIN带出列 - IN 批量 :一对多附属数据
WHERE parent_id IN (...)一次取回,内存分组 - GROUP BY 下推:计数/求和永远让数据库聚合,别拉明细回 Python 数
- JOIN 取回 :一对一关联直接
- 复杂度语言 :往返次数从 O(N) 到 O(1),与数据规模解耦
B2. "取每组最新一条"(N+1 的地狱变体)
sql
-- 每台设备取最近一条告警:循环里 N 次 ORDER BY time DESC LIMIT 1
- 解决:窗口函数一次算完
sql
SELECT * FROM (
SELECT *, ROW_NUMBER() OVER (PARTITION BY device_id ORDER BY created_at DESC) rn
FROM device_events WHERE event_type='alarm'
) t WHERE rn = 1;
-- 或 PG 专属写法:LATERAL JOIN / DISTINCT ON
四、C 类:被问爆(执行次数问题)
C1. 单条健康,总量爆炸
一条查询 3ms 很健康吧?
每秒 3000 次 = 每秒需要 9 秒的数据库时间 → 一台机器算不过来,全线排队
- 解决优先级 :
- 缓存:热点读走 Redis(商品详情、配置类),注意过期策略与一致性
- 读写分离:报表/列表打到从库,主库只扛写
- 合并请求:前端轮询改 SSE/WebSocket 推送;批量接口代替逐条调用
- 限流降级:入口挡住超出容量的请求,保护 DB 是底线
C2. 报表查询混进 OLTP 主库
sql
SELECT ... FROM medication_records GROUP BY ... -- 全年明细,扫 500 万行
- 一条 SQL 打崩在线库的经典姿势
- 解决:报表走从库 / 离线数仓 / 预聚合汇总表(每天定时算好,查询只读结果)
五、D 类:在排队(锁与连接)
D1. 锁等待
事务A: UPDATE devices SET ... WHERE id=5 (没提交,持行锁)
事务B: UPDATE devices SET ... WHERE id=5 (干等 → 表现为"慢查询")
- 症状 :SQL 本身有索引也慢;
pg_stat_activity里waiting=true/pg_locks能看谁堵谁 - 根因:长事务------事务里嵌了 RPC、人工操作、循环外呼
- 解决 :事务尽量短小,外部调用移出事务 ;设置
lock_timeout快速失败
D2. 连接池耗尽(本项目 MQTT 场景同款)
每个请求占一条连接 200ms(N+1 版)→ 并发 76 就烧干 15 条的池
每个请求占一条连接 15ms(批量版) → 同样的池能扛 1000 并发
- 症状:应用侧报"获取连接超时",DB 侧却看不到高负载
- 解决:缩短单请求连接占用时间(消灭 N+1)、池加监控、排队上限 + 快速失败
六、本项目实战对照:设备列表接口
| 维度 | 优化前(commit cadfb87) | 优化后(commit 80b2559) |
|---|---|---|
| 问题类型 | B 类 N+1(1+3N 条/请求) | 恒定 4 条 |
| 写法 | 循环内逐台查老人/槽位/开仓数(JOIN 写了但只用于过滤,没用上) | LEFT JOIN 带列 + IN 批量 + GROUP BY 下推 |
| 单条耗时 | 2~5ms(慢查询日志抓不到) | 同左 |
| 500 台总耗时 | 10.6s | 174ms |
| 连接占用 | 随设备数线性增长 | 恒定小常数 |
面试完整叙事:
"设备列表要聚合设备、老人、药仓、事件 5 张表,原实现是每台设备 3 条子查询------单条 2ms,
慢查询日志永远抓不到它,但 500 台就是 1501 次往返、10 秒响应。我按'总耗时=单次×次数'
的思路定位到它才是榜首,重构成 JOIN + 批量 IN + GROUP BY 恒定 4 条,压测验证 10.6s→174ms。
同时事件明细走详情页懒加载、列表只留覆盖索引的今日计数,日志大表不进列表路径。
下一步演进是服务端分页------分页治无界扫描,批量治往返次数,两层互补。"
七、排查工具箱与流程
工具
| 工具 | 用途 | 关键用法 |
|---|---|---|
慢查询日志 / log_min_duration_statement |
抓单条慢的 | 阈值设 100ms 起步 |
pg_stat_statements |
按总耗时排序找真凶 | ORDER BY total_exec_time DESC,次数×均值一起看 |
EXPLAIN (ANALYZE, BUFFERS) |
看单条的执行计划 | 重点看 Seq Scan、估算行数偏差、排序落盘 |
| APM(SkyWalking/ARMS) | SQL 挂回接口 | 看一个请求 span 里 SQL 条数(抓 N+1 神器) |
pg_stat_activity / pg_locks |
抓锁等待 | 谁堵谁一目了然 |
决策树(拿到"页面慢"的报障)
1. 是偶发还是稳定慢?
偶发 → 查锁等待/长事务(D类)、执行计划漂移
稳定 → 下一步
2. EXPLAIN 看单条:
Seq Scan / 大量行被过滤 → A类(索引问题)
计划正常但接口整体慢 → 数这个请求发了几条 SQL:
几十上百条 → B类(N+1)
就几条且单条快 → 看 QPS 和总耗时排名 → C类(被问爆)或 D类(连接池)
八、速背卡
| 类型 | 一句话病因 | 一句话药方 |
|---|---|---|
| 无索引 | 扫 1000 万返回 20 | WHERE+ORDER BY 联合索引 |
| 索引失效 | 函数/类型转换/前缀通配 | 改写谓词让列"裸奔" |
| 深分页 | OFFSET 数着跳 | 游标 id > last / 延迟关联 |
| N+1 | 每条快、次数爆炸 | JOIN + IN 批量 + GROUP BY 下推 |
| 每组最新一条 | 循环 LIMIT 1 | ROW_NUMBER / LATERAL |
| 热点读 | 单条健康、QPS 太高 | 缓存 / 读写分离 / 合并请求 |
| 长事务 | 持锁连坐 | 外部调用移出事务 |
| 连接池耗尽 | 占连接时间太长 | 缩短单请求 SQL 往返 + 监控 |
总纲 :总耗时 = 单次耗时 × 执行次数;单次耗时的构成 = 扫描量 + 往返数 + 锁等待。
所有慢 SQL 问题都是这三元的组合,排查就是逐个变量做排除法。