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 节)
建议顺序:先做 ③(一条命令,成本最低),再做 ① ②,最后 ④(需要工具)。
七、本节小结
- 池化类瓶颈的共同特征:CPU 不高但延迟高——时间花在排队上。
- 三种模式:连接池耗尽、线程池饥饿、协程调度器污染。
- 关键区分:「服务慢」(优化查询)vs「池太小」(加池)——看
pending与单次耗时。 - 不要让"加池"成为第一反应——池是并发闸门,加大会把压力转移到下游。
- 调度器污染最隐蔽:所有常规指标都正常,唯一的线索是 wall 火焰图与 「RUNNABLE == CPU 核数」。
- 线程池饥饿往往是症状不是病——要问「线程被什么占住了」。
八、自测
db_pool_pending = 18、active = total = 10,同时pg_stat_statements显示某条查询的mean_exec_time从 2 ms 涨到 350 ms。请说明根因和你该先做什么。- 一个服务的 CPU 是 25%,P99 很高,但线程数、GC、连接池指标都正常。请说出你最该做的一件事,以及你预期看到什么。
- 为什么「加连接池」可能是危险的?请说明在什么情况下它是对的、什么情况下它是错的。
- 根因是慢查询(而不是池太小):某条查询的
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 压力,可能让所有查询都变慢——问题被放大。 - 最该做的一件事:采 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(),看是否大量协程挂在同一个阻塞调用点。 - 危险的原因:连接池是数据库的并发闸门。加大它意味着允许更多并发查询同时执行,会导致:① 锁竞争加剧——更多事务争抢同一批行锁/表锁;② 上下文切换与 IO 队列变长——数据库的 CPU 与磁盘压力上升;③ 整体可能更慢——尤其当瓶颈是慢查询时,“更多并发"只是让更多慢查询同时在跑;④ 掩盖真问题——应用层的
pending消失了,但问题转移到了数据库侧,更难定位;⑤ 可能拖垮数据库——影响所有依赖它的服务;⑥ 资源开销——PostgreSQL 每个连接都有内存/进程成本。什么时候加池是对的:① 查询本身已经很快(单次 < 10 ms)且索引合理;②pending > 0是因为并发量确实高(而不是查询变慢);③ 用 Little’s Law 算出的所需连接数明显大于当前池大小(例如峰值QPS × P99延迟= 60,而池只有 10);④ 数据库侧的资源(CPU、内存、max_connections)允许;⑤ 同时配置了获取连接超时,避免无限等待。