文档目录

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 能精确区分「阻塞在文件」和「阻塞在网络」——这是火焰图做不到的。


十一、本节小结

  1. 八种数据与算法类瓶颈,都能从一两个具体指标上看出来——关键是知道看哪个。
  2. N+1:看 calls 与 QPS 的比例;缺索引:看 EXPLAIN 的 Seq Scan 与行数估计偏差。
  3. 锁竞争:sys 高 + BLOCKED 线程 + lock 火焰图;修法优先级是「消除共享 → 缩小临界区 → 分片结构」。
  4. GC:唯一确定的方法是时间戳对齐;不对齐就排除它(能省大量时间)。
  5. 缓存三问题:击穿(单飞)、雪崩(TTL 抖动)、穿透(缓存空值)。
  6. 序列化:「少传数据」往往比「换序列化器」收益更大。
  7. 日志同步 IO:JFR 的 jdk.FileWrite 是最确定的证据。
  8. 无界队列:快速失败优于堆积。

十二、自测

  1. 接口 P99 与返回的记录数成正比,QPS 不高但数据库 CPU 很高。请说出最可能的模式,以及你要看的两个指标。
  2. P99 出现周期性尖刺。你怀疑是 GC。请说出你的验证方法,以及「如果不对齐」你会得出什么结论。
  3. 一个服务的 sys CPU 是 25%,P99 从 50 ms 涨到 180 ms。请列出三种可能的原因,以及区分它们的命令。
  1. 最可能的模式: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 / 预取,收益通常是数量级的。
  2. 验证方法:① 从 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 节流。
  3. 三种可能:① 锁竞争——大量线程在抢锁,内核的 futex 操作会消耗 sys CPU;② 频繁的系统调用——例如每次请求都写日志(同步 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_... 与分配速率。