6.6 瓶颈模式库(二):数据与算法类
上一节:6.5 瓶颈模式库(一):池化类 | 下一节:6.7 陷阱清单:Kotlin 与 C++ 直觉 配套代码:06-analysis-and-profiling/06-bottleneck-data
一句话结论
这八种瓶颈的共同特征是「能从一个具体指标上直接看出来」——不像池化类那么隐蔽。所以它们的排查更依赖**「知道该看哪个指标」**,而不是复杂的工具。
一、八种模式的速查表
| # | 模式 | 一句话现象 | 关键指标 | 验证命令 |
|---|---|---|---|---|
| 1 | N+1 查询 | DB 调用次数与请求数成正比 | pg_stat_statements 的 calls |
对比 calls 与 QPS |
| 2 | 缺索引/全表扫描 | 单条查询慢且随数据量恶化 | EXPLAIN 的 Seq Scan |
EXPLAIN (ANALYZE, BUFFERS) |
| 3 | 锁竞争 | CPU 高但吞吐不涨,sys 偏高 |
lock 火焰图、BLOCKED 线程 |
jstack + pg_locks |
| 4 | GC 停顿 | P99 尖刺呈周期性 | GC 日志停顿时间 | 与 P99 尖刺时间对齐 |
| 5 | 缓存击穿/雪崩 | 周期性尖刺,DB 负载脉冲 | 缓存命中率、DB QPS | 对齐缓存失效时间 |
| 6 | 序列化开销 | CPU 火焰图顶部是序列化栈 | cpu 火焰图、alloc 图 |
对比不同序列化器 |
| 7 | 日志同步 IO | sys 高,火焰图有文件写栈 |
JFR jdk.FileWrite |
降日志级别做对照 |
| 8 | 无界队列 OOM | 内存持续增长,最终 OOM | 队列深度、堆基线 | 看趋势是否单调 |
二、模式一:N+1 查询
现象与验证
典型症状:接口 P99 与返回的记录数成正比;QPS 不高但数据库负载很高。
-- 关键判据:calls 远大于 QPS
SELECT calls, mean_exec_time, total_exec_time, left(query, 60)
FROM pg_stat_statements
WHERE query LIKE 'select%'
ORDER BY calls DESC LIMIT 10;
判读:
接口 QPS = 50
某查询 calls = 5000/分钟 = 83/秒
→ 83 / 50 ≈ 1.7 次/请求?不对,应该是:
如果每请求查 100 次,则 50 QPS × 100 = 5000/秒
→ 对比数量级,差距就是"N+1 的 N"
最确定的证据:在代码里加一个"每请求查询次数"的指标。
// 用 ThreadLocal 或协程上下文统计
private val queryCounter = ThreadLocal.withInitial { AtomicInteger(0) }
fun <T> withQueryCounting(block: () -> T): Pair<T, Int> {
queryCounter.get().set(0)
val result = block()
return result to queryCounter.get().get()
}
修法
// ❌ N+1:循环里单查
val urls = ids.map { id -> queryOne(id) }
// ✅ 批量:1 次
val urls = queryMany(ids) // where id = any(?)
收益通常是数量级的(第 3 章 3.2 节的组件基准实测:批量比循环快 82 倍)。
三、模式二:缺索引 / 全表扫描
现象与验证
典型症状:单条查询慢,且随数据量增长而恶化。
EXPLAIN (ANALYZE, BUFFERS)
SELECT * FROM orders WHERE status = 'PAID' ORDER BY created_at DESC LIMIT 20;
四个危险信号:
| 信号 | 含义 |
|---|---|
Seq Scan |
全表扫描 |
Rows Removed by Filter: 900000 |
扫了 90 万行只留 20 行(绝大部分工作是浪费的) |
rows=20 但 actual rows=900000 |
统计信息过期(优化器估错了) |
Buffers: shared read=很大 |
大量磁盘读(缓存未命中) |
修法
-- ① 加索引(按查询模式设计)
CREATE INDEX CONCURRENTLY idx_orders_status_created
ON orders(status, created_at DESC);
-- ② 更新统计信息(如果估计偏差大)
ANALYZE orders;
-- ③ 验证:再跑一次 EXPLAIN,确认变成 Index Scan
注意「估计偏差」这一条最容易被忽略:优化器基于统计信息选择计划,如果统计信息过期,它可能选错计划。修法可能只是
ANALYZE,而不用加索引。
四、模式三:锁竞争
现象与验证
| 观测 | 值 |
|---|---|
| CPU | 高(且 sys 占比也高) |
| 吞吐 | 加线程也不涨 |
lock 火焰图 |
大量锁等待 |
| 线程 dump | 大量 BLOCKED (on object monitor) |
pidstat -w |
cswch/s 很高 |
# ① 锁火焰图
asprof -d 60 -e lock -f lock.html <pid>
# ② 线程 dump:找 BLOCKED 和持锁者
jcmd <pid> Thread.print | grep -A3 "BLOCKED" | head -30
# ③ 数据库侧的行锁(如果是 DB 锁)
psql -c "SELECT blocked.pid, blocking.pid, left(blocked.query,40), left(blocking.query,40)
FROM pg_stat_activity blocked
JOIN pg_stat_activity blocking ON blocking.pid = ANY(pg_blocking_pids(blocked.pid));"
修法优先级
① 消除共享(分片 / 线程封闭 / 不可变) ← 最有效
② 缩小临界区(只锁必须锁的部分)
③ 用分片结构(LongAdder、ConcurrentHashMap)
④ 最后才考虑自己写无锁
五、模式四:GC 停顿
现象与验证
典型症状:P99 和 P999 呈周期性尖刺,尖刺间隔与 GC 周期一致。
唯一的确定性验证:把 GC 停顿的时间戳与 P99 尖刺的时间戳对齐。
# ① 提取 GC 停顿时间戳
grep -E "Pause Young|Pause Full" gc.log | awk '{print $1, $NF}' > gc-pauses.txt
# ② 在监控里找出 P99 尖刺的时间点
# ③ 对比:如果对齐 → GC 是根因;不对齐 → 排除 GC
对齐了就继续看:
| 观察 | 结论 | 修法 |
|---|---|---|
| Young GC 频繁(几秒一次) | 分配速率太高 | 找分配热点(alloc 火焰图) |
| 停顿时间长(> 100 ms) | 堆太大或对象存活多 | 调堆/换 GC |
| 回收后基线抬升 | 内存泄漏或对象晋升 | 查泄漏(第 3 章 3.5) |
| 出现 Full GC | 配置问题或泄漏 | 必须查明 |
六、模式五:缓存击穿 / 雪崩
现象与验证
| 模式 | 现象 | 原因 |
|---|---|---|
| 击穿 | 单个热点 key 失效瞬间,DB 负载骤升 | 并发回源(同一 key 大量请求同时穿透) |
| 雪崩 | 大批 key 同时失效,DB 被打满 | TTL 没有抖动 |
| 穿透 | 查询不存在的 key,每次都到 DB | 未缓存空结果 |
验证:
# ① 缓存命中率
redis-cli INFO stats | grep keyspace
# ② 与 DB QPS 曲线对齐:如果 DB QPS 出现周期性脉冲,且与缓存过期时间吻合 → 雪崩
修法:
// ① 击穿:单飞(同一 key 的并发回源只执行一次)
val value = cache.get(key) { k -> loadFromDb(k) } // Caffeine 的 get 是原子的
// ② 雪崩:TTL 加随机抖动
val ttl = baseTtl + Random.nextLong(0, baseTtl / 5)
// ③ 穿透:缓存空结果(短 TTL)
cache.put(key, NULL_SENTINEL, ttl = 1.minutes)
七、模式六:序列化开销
现象与验证
典型症状:CPU 火焰图顶部有一个很宽的序列化栈。
asprof -d 60 -e cpu -f cpu.html <pid>
asprof -d 60 -e alloc -f alloc.html <pid> # 序列化通常也伴随大量分配
判读:
| 火焰图特征 | 含义 |
|---|---|
Jackson.writeValue 宽而平 |
反射型序列化开销 |
String.format / StringBuilder |
字符串处理 |
Integer.valueOf 在序列化栈下 |
装箱 |
修法:
| 方法 | 收益 | 代价 |
|---|---|---|
| 换成代码生成型序列化(kotlinx.serialization / Protobuf) | 中–高 | 需要改代码 |
| 减少序列化的数据量(只返回必要字段) | 高 | 需要改 API |
| 避免重复序列化(JSON → Map → JSON) | 高 | 需要重构 |
| 用流式序列化代替构造完整字符串 | 中 | 代码复杂 |
注意第二行:「少传数据」往往比「换序列化器」收益更大——因为它同时减少了 CPU、内存、网络开销。
八、模式七:日志同步 IO
现象与验证
| 观测 | 值 |
|---|---|
sys CPU |
偏高(> 20%) |
| wall 火焰图 | 栈里有 FileOutputStream.write |
| JFR | jdk.FileWrite 事件频繁且耗时长 |
# JFR 验证(最确定)
jfr print --events jdk.FileWrite recording.jfr | grep -c "app.log"
# 对照实验(最直接):把日志级别降到 ERROR,看 P99 变化
修法(第 4 章 4.6 节):异步 appender、去掉行号(%L)、参数化日志、降低热路径日志级别。
九、模式八:无界队列 OOM
现象与验证
| 观测 | 值 |
|---|---|
| 堆使用 | 单调上升(GC 后基线也抬升) |
| 队列深度 | 持续增长 |
| 延迟 | 随负载线性恶化 |
| 最终 | OOM |
# 队列深度趋势
curl -s localhost:8080/metrics | grep queue_depth
# 堆基线趋势(用第 3 章 3.5 的分析脚本)
python3 tools/analyze_soak.py soak-metrics.csv
修法:
// ❌ 无界队列:请求堆积到 OOM
val queue = LinkedBlockingQueue<Runnable>()
// ✅ 有界 + 明确的拒绝策略
val queue = ArrayBlockingQueue<Runnable>(2000)
// 拒绝时:快速失败(返回 503)或降级,而不是无限等待
核心原则:快速失败优于堆积——堆积会把「局部慢」变成「全站挂」。
十、六个鉴别要点
排查这八种模式时,最有用的是**「用一个指标区分两种相似现象」**:
| 容易混淆 | 区分方法 |
|---|---|
| N+1 vs 缺索引 | 看 calls(高频)还是 mean_exec_time(单次慢) |
| 锁竞争 vs CPU 饱和 | sys 占比高 + BLOCKED 线程多 → 锁 |
| GC vs 其他周期性 | 时间戳对齐(唯一确定的方法) |
| 缓存击穿 vs 雪崩 | 单 key 脉冲 vs 大批 key 同时失效 |
| 序列化 vs 业务计算 | 火焰图顶部的具体函数 |
| 日志 IO vs 网络 IO | JFR 的 FileWrite vs SocketWrite 事件 |
最后一行特别实用:JFR 能精确区分「阻塞在文件」和「阻塞在网络」——这是火焰图做不到的。
十一、本节小结
- 八种数据与算法类瓶颈,都能从一两个具体指标上看出来——关键是知道看哪个。
- N+1:看
calls与 QPS 的比例;缺索引:看EXPLAIN的Seq Scan与行数估计偏差。 - 锁竞争:
sys高 +BLOCKED线程 + lock 火焰图;修法优先级是「消除共享 → 缩小临界区 → 分片结构」。 - GC:唯一确定的方法是时间戳对齐;不对齐就排除它(能省大量时间)。
- 缓存三问题:击穿(单飞)、雪崩(TTL 抖动)、穿透(缓存空值)。
- 序列化:「少传数据」往往比「换序列化器」收益更大。
- 日志同步 IO:JFR 的
jdk.FileWrite是最确定的证据。 - 无界队列:快速失败优于堆积。
十二、自测
- 接口 P99 与返回的记录数成正比,QPS 不高但数据库 CPU 很高。请说出最可能的模式,以及你要看的两个指标。
- P99 出现周期性尖刺。你怀疑是 GC。请说出你的验证方法,以及「如果不对齐」你会得出什么结论。
- 一个服务的
sysCPU 是 25%,P99 从 50 ms 涨到 180 ms。请列出三种可能的原因,以及区分它们的命令。
- 最可能的模式:N+1 查询(也可能是"分页查大结果集",但 N+1 更常见)。两个指标:①
pg_stat_statements的calls—— 如果某条简单查询的 calls ≈ 接口 QPS × 每次请求的记录数,就确认了 N+1;② 每条请求的数据库查询次数(可用 ThreadLocal 计数或 tracing 的 span 数)—— 这是最直接的证据。补充:还要看total_exec_time(总耗时)——N+1 的特征是单次很快(mean 可能 < 1ms)但总消耗极大,所以只看mean_exec_time会完全漏掉它。修法:改成批量查询(where id = any(?))或用 join / 预取,收益通常是数量级的。 - 验证方法:① 从 GC 日志里提取所有
Pause Young/Pause Full的时间戳:grep -E "Pause" gc.log | awk '{print $1}';② 从监控里找出 P99 尖刺的时间点;③ 逐一对齐——如果每个尖刺都能对应到一个 GC 停顿,则确认 GC 是根因。如果不对齐,结论是排除 GC,而不是"GC 可能还是有点影响"。这是排除法的关键:不对齐就是排除(除非是采样粒度问题,比如监控是 1 分钟粒度而 GC 停顿只有几十毫秒——此时要先用更细粒度的数据确认)。排除 GC 的价值:它能让你把时间花在别的地方。下一步应该转向:连接池(pending)、慢查询、锁竞争、下游调用(JFR 的SocketRead)、以及容器 CPU 节流。 - 三种可能:① 锁竞争——大量线程在抢锁,内核的 futex 操作会消耗
sysCPU;② 频繁的系统调用——例如每次请求都写日志(同步 IO)、或大量小 IO 操作;③ 网络栈处理——高 QPS 下的中断与协议栈开销(如果 QPS 很高)。区分命令:① 锁竞争:jcmd <pid> Thread.print | grep -c BLOCKED(大量 BLOCKED)+asprof -e lock(锁火焰图有内容);② 日志/文件 IO:jfr print --events jdk.FileWrite recording.jfr(有大量 FileWrite 事件),或做对照实验(把日志级别降到 ERROR 看 P99 是否回落);③ 网络:jfr print --events jdk.SocketRead与SocketWrite对比耗时,或用perf top看sys部分的热点(网络栈函数如tcp_sendmsg)。补充第四种:内存问题(大量页错误、TLB 失效)——用perf stat -e page-faults或看jvm_gc_...与分配速率。