文档目录

6.5 瓶颈模式库(一):池化类

上一节:6.4 线程与协程快照 | 下一节:6.6 瓶颈模式库(二):数据与算法类 配套代码:06-analysis-and-profiling/05-bottleneck-pools


一句话结论

池化类瓶颈的共同特征是「CPU 不高但延迟高」,因为时间花在排队上而不在计算上。 它们的鉴别关键都在饱和度指标:池的 pending、队列的深度、调度器的排队数。


一、用「机场安检」理解池化类瓶颈

机场安检有 4 个通道(= 池大小):

现象 原因 对策
队伍不长,但每个乘客检查很慢 服务慢(不是通道不够) 加快单个检查(优化查询)
检查很快,但队伍越来越长 通道不够 加通道(加池)——但要小心
4 个通道全开,队伍还在涨 到瓶颈了 加通道 + 优化检查流程
通道空着,但乘客在别处排队 瓶颈不在安检 查别的环节

关键区分:「检查慢」和「通道少」是两件事,修法相反。

  • 检查慢 → 优化检查(查询)
  • 通道少 → 加通道(池)

判断方法:看 pending(队伍长度)与单次检查耗时(查询耗时)。


二、模式一:数据库连接池耗尽

现象

观测 值
CPU 40%(不高)
P99 从 80 ms 涨到 400 ms
db_pool_pending 18 ⭐
db_pool_active 10(= total,已打满)
wall 火焰图 大量栈停在 HikariPool.getConnection
线程 dump 40+ 线程停在 HikariPool.getConnection

验证命令

# ① 池状态
curl -s localhost:8080/metrics | grep -E "db_pool_(active|idle|pending|total)"

# ② 数据库侧:是否有长事务/慢查询占用连接
psql -c "SELECT pid, now()-query_start AS dur, state, wait_event_type, left(query,60)
         FROM pg_stat_activity WHERE state <> 'idle' ORDER BY dur DESC LIMIT 10;"

# ③ 慢查询排行
psql -c "SELECT calls, mean_exec_time, total_exec_time, left(query,50)
         FROM pg_stat_statements ORDER BY total_exec_time DESC LIMIT 10;"

修复方向(顺序很重要)

① 先确认「是查询慢还是池小」
   ├─ 有慢查询(mean_exec_time 高)→ 优化 SQL / 加索引(❗不要先加池)
   └─ 查询都快但仍然 pending → 用 Little's Law 计算所需连接数

② 如果是池小:
   所需连接 = 峰值 QPS × 单次查询 P99
   (用 P99 而不是平均,见第 0 章 0.4)

③ 无论如何都要加:获取连接超时(防止无限等待)

为什么不能先加池:池是数据库的并发闸门。加大它意味着允许更多并发查询打到数据库 → 锁竞争加剧、上下文切换增多 → 整体可能更慢,而且掩盖了真正的问题。


三、模式二:线程池饥饿

现象

观测 值
CPU 35%
executor_queue_depth 1200(持续增长)
executor_active 200(= 最大线程数)
线程 dump 200 个线程都在 RUNNABLE 或 TIMED_WAITING
P99 持续上升

验证命令

# ① 线程池状态
curl -s localhost:8080/metrics | grep -E "executor_(active|queue|pool)"

# ② 线程 dump:看这 200 个线程在做什么
jcmd <pid> Thread.print | grep -A5 "pool-" | head -40

# ② 如果是 Tomcat/Jetty,看具体线程池
jcmd <pid> Thread.print | grep -c "http-nio"

鉴别:线程在忙什么?

线程栈 结论 修法
停在 socketRead0 / JDBC 线程被 IO 阻塞 改异步,或增大线程数(治标)
停在 BLOCKED 锁竞争 见模式六
停在业务计算 CPU 密集型任务占满线程 拆分线程池(隔离)
停在 Thread.sleep 轮询/退避 改用事件驱动

核心洞察:线程池饥饿通常是"症状"而不是"病"——真正的问题是「线程被什么占住了」。


四、模式三:协程调度器被阻塞调用污染 ⭐

这是 Kotlin 后端最隐蔽、最难排查的一种。

现象

观测 值
CPU 25%(很低)
P99 所有接口一起变慢(包括不查数据库的)
线程数 正常(可能就是 CPU 核数)
线程 dump 8 个 RUNNABLE(= CPU 核数),其余 WAITING
GC 正常
连接池 pending 0

注意这些指标的"正常"——所有常规指标都正常,但服务就是慢。

验证命令

# ① wall 火焰图(关键!CPU 图看不出问题)
asprof -d 60 -e wall -f wall.html <pid>
# → 会看到 Default worker 全停在阻塞调用栈

# ② 协程快照
# 需要在代码里临时启用 DebugProbes(见 6.4 节)

关键判据

「RUNNABLE 线程数 == CPU 核数」+「CPU 利用率很低」+「所有接口一起慢」
    ↓
高度怀疑:Dispatchers.Default 被阻塞调用占满

验证:搜索代码

# 查找可疑的阻塞调用
grep -rn "Dispatchers.Default" src/ | grep -v limitedParallelism
grep -rn "runBlocking" src/
grep -rn "Thread.sleep" src/
# 找 JDBC / 同步 HTTP 调用是否在 Default 上下文里
grep -rn "executeQuery\|jdbcTemplate\|DriverManager" src/ | grep -v "withContext"

修复方向

// ❌ 阻塞调用在 Default 上(占满调度器)
suspend fun find(id: String) = jdbcTemplate.queryForObject(...)

// ✅ 切到 IO
suspend fun find(id: String): Order = withContext(Dispatchers.IO) {
    jdbcTemplate.queryForObject(...)
}

// ✅ 更好:异步驱动
suspend fun find(id: String): Order = r2dbcClient.query(id).await()

// ✅ 并且限制并发(保护下游)
private val limiter = Semaphore(64)
suspend fun find(id: String): Order = limiter.withPermit {
    withContext(Dispatchers.IO) { jdbcTemplate.queryForObject(...) }
}

五、三种池化瓶颈的对照表

连接池耗尽 线程池饥饿 调度器污染
关键指标 db_pool_pending > 0 executor_queue_depth 增长 无明显异常指标 ⚠️
CPU 不高 不高或中等 很低
线程 dump 停在 getConnection 停在 IO/锁/计算 8 个 RUNNABLE = 核数
wall 火焰图 停在 getConnection 停在具体阻塞点 Default worker 全阻塞
影响范围 数据库相关接口 该线程池的任务 所有使用 Default 的接口
修法 先优化查询,再调池 查"线程被什么占住" 切到 IO / 异步驱动

最难的是第三种:因为没有异常指标——CPU 低、线程数正常、GC 正常、连接池正常。唯一的突破口是 wall 火焰图和「RUNNABLE == 核数」这个巧合。


六、通用诊断流程

现象:CPU 不高但延迟高
    │
    ├─ ① 看连接池 pending
    │      └─ > 0 → 连接池排队(模式一)
    │
    ├─ ② 看线程池/队列深度
    │      └─ 持续增长 → 线程池饥饿(模式二)
    │
    ├─ ③ 看「RUNNABLE 线程数 vs CPU 核数」
    │      └─ 相等且 CPU 低 → 疑似调度器污染(模式三)
    │
    ├─ ④ 采 wall 火焰图
    │      └─ 看最宽的等待栈停在哪里
    │
    └─ ⑤ 都正常?
           └─ 问题可能不在这个进程(网络/网关/上游,见第 6.2 节)

建议顺序:先做 ③(一条命令,成本最低),再做 ① ②,最后 ④(需要工具)。


七、本节小结

  1. 池化类瓶颈的共同特征:CPU 不高但延迟高——时间花在排队上。
  2. 三种模式:连接池耗尽、线程池饥饿、协程调度器污染。
  3. 关键区分:「服务慢」(优化查询)vs「池太小」(加池)——看 pending 与单次耗时。
  4. 不要让"加池"成为第一反应——池是并发闸门,加大会把压力转移到下游。
  5. 调度器污染最隐蔽:所有常规指标都正常,唯一的线索是 wall 火焰图与 「RUNNABLE == CPU 核数」。
  6. 线程池饥饿往往是症状不是病——要问「线程被什么占住了」。

八、自测

  1. db_pool_pending = 18、active = total = 10,同时 pg_stat_statements 显示某条查询的 mean_exec_time 从 2 ms 涨到 350 ms。请说明根因和你该先做什么。
  2. 一个服务的 CPU 是 25%,P99 很高,但线程数、GC、连接池指标都正常。请说出你最该做的一件事,以及你预期看到什么。
  3. 为什么「加连接池」可能是危险的?请说明在什么情况下它是对的、什么情况下它是错的。
  1. 根因是慢查询(而不是池太小):某条查询的 mean_exec_time 从 2 ms 涨到 350 ms,意味着每个连接被占用 175 倍的时间,10 个连接很快被占满 → pending 上升 → 请求排队。pending = 18 是结果,不是原因。 该先做什么:① 优化那条慢查询——用 EXPLAIN (ANALYZE, BUFFERS) 看执行计划,检查是否缺索引、统计信息是否过期、是否全表扫描;② 优化后复测(组件基准),确认 P99 从 350 ms 降回几毫秒;③ pending 会自然回到 0;④ 只有在查询恢复正常后 pending 仍然 > 0 时,才考虑按 Little’s Law 调整池大小。如果先加池会怎样:更多并发查询打到数据库,加剧锁竞争与 IO 压力,可能让所有查询都变慢——问题被放大。
  2. 最该做的一件事:采 wall-clock 火焰图(asprof -d 60 -e wall -f wall.html <pid>),因为「CPU 不高 + 常规指标正常 + 延迟高」是典型的等待类瓶颈,而 CPU 火焰图看不到等待。预期看到:大量线程(特别是 Dispatchers.Default 的 worker)停在阻塞调用栈上——最可能是 socketRead0(等 IO)、HikariPool.getConnection(等连接,虽然 pending 显示 0,但可能有短暂的)、或 JDBC 相关调用。同时要做的第二件事:检查「RUNNABLE 线程数是否等于 CPU 核数」——如果相等且 CPU 低,高度怀疑调度器被阻塞调用污染(第 2 章 2.7):8 核 = 8 个 Default worker,它们全被阻塞调用占住,导致所有协程排队。验证:临时启用 DebugProbes.dumpCoroutines(),看是否大量协程挂在同一个阻塞调用点。
  3. 危险的原因:连接池是数据库的并发闸门。加大它意味着允许更多并发查询同时执行,会导致:① 锁竞争加剧——更多事务争抢同一批行锁/表锁;② 上下文切换与 IO 队列变长——数据库的 CPU 与磁盘压力上升;③ 整体可能更慢——尤其当瓶颈是慢查询时,“更多并发"只是让更多慢查询同时在跑;④ 掩盖真问题——应用层的 pending 消失了,但问题转移到了数据库侧,更难定位;⑤ 可能拖垮数据库——影响所有依赖它的服务;⑥ 资源开销——PostgreSQL 每个连接都有内存/进程成本。什么时候加池是对的:① 查询本身已经很快(单次 < 10 ms)且索引合理;② pending > 0 是因为并发量确实高(而不是查询变慢);③ 用 Little’s Law 算出的所需连接数明显大于当前池大小(例如 峰值QPS × P99延迟 = 60,而池只有 10);④ 数据库侧的资源(CPU、内存、max_connections)允许;⑤ 同时配置了获取连接超时,避免无限等待。