7.6 池化与复用:连接池要算,对象池要慎
上一节:7.5 背压与韧性 | 下一节:7.7 JVM 调优 配套代码:07-optimization-and-validation/06-pooling
一句话结论
池化是在「复用成本」与「获取成本」之间做取舍。 对真正昂贵的资源(数据库连接、线程)池化收益巨大;对本身就很便宜的(普通对象),池化在 JVM 上通常是负优化——因为池的同步成本可能超过分配成本。
一、用「租车 vs 买车」理解池化
| 做法 | 获取成本 | 持有成本 | 适用 |
|---|---|---|---|
| 每次买车 | 极高(几十万) | — | ❌ 不现实 |
| 租车(池化) | 低(走过去开走) | 租金 + 停车位 | ✅ 偶尔用车 |
| 自己组装再拆开 | 组装费 > 直接买 | — | ❌ 荒谬 |
后端对应:
| 资源 | 获取成本 | 池化 |
|---|---|---|
| 数据库连接 | 高(TCP 握手 + 认证 + 数据库侧分配进程/内存) | ✅ 必须池化 |
| 线程 | 高(几十微秒 + 1MB 栈) | ✅ 必须池化 |
| HTTP 连接 | 中(TCP + TLS 握手) | ✅ 应该复用 |
| 普通对象 | 极低(TLAB 指针碰撞,比一次内存访问还快) | ❌ 通常是负优化 |
| 大缓冲(ByteArray) | 中(分配 + GC + 可能 Humongous) | ⚠️ 可能值得 |
| 直接内存缓冲 | 高 | ✅ 值得 |
注意第四行:这是 JVM 与 C++ 最大的直觉差异之一(第 6.7 节)。
二、连接池大小:用 Little’s Law 算,不要拍
公式
所需连接数 = 目标 QPS × 单次操作的平均耗时(秒)
要用两个版本:
L_乐观 = 峰值QPS × P50耗时
L_悲观 = 峰值QPS × P99耗时
实例
峰值 QPS = 1000
查询 P50 = 8 ms,P99 = 60 ms
L_乐观 = 1000 × 0.008 = 8 个
L_悲观 = 1000 × 0.060 = 60 个
两个数字差 7.5 倍!
怎么选:
| 选择 | 效果 |
|---|---|
| 按 L_乐观 配置(8) | 平时刚好够,但最慢的那一刻会严重排队 |
| 按 L_悲观 配置(60) | 最坏时刻够用,但平时大量连接闲置(浪费数据库内存) |
| 实践做法 | 取中间值(如 P95 对应的数量)+ 有界 + 获取连接超时 |
⚠️ 一个必须理解的点
连接池是数据库的并发闸门——它决定了「最多有多少个查询同时在数据库上执行」。
池设小了 → 应用排队(延迟高,但数据库健康)
池设大了 → 数据库排队(锁竞争加剧、IO 队列变长)→ 所有查询都变慢
所以「池越大越好」是错的:加大池只是把排队从应用层转移到数据库层,而数据库层的排队更难恢复。
正确的调优顺序
① 先确认查询本身是否合理(有索引、无 N+1)—— 用第 6.6 节的方法
② 再看 pending:如果 pending > 0 且查询都快 → 才考虑调池
③ 用 Little's Law 算一个范围,从偏小的值开始试
④ 每次调整后观察:pending、P99、以及【数据库侧的指标】
⑤ 同时必须配置:获取连接超时(避免无限等待)
数据库侧的上限
-- PostgreSQL 默认 max_connections = 100
SHOW max_connections;
-- 检查是否快到了
SELECT count(*) AS used, current_setting('max_connections')::int AS max
FROM pg_stat_activity;
注意:PostgreSQL 每个连接是一个后端进程(不是线程),有内存开销。所以「多实例 × 每实例连接池」的总和不能超过 max_connections。
例:6 个应用实例 × 每实例池 30 = 180 个连接
但 max_connections = 100 → ❌ 会有一部分连接建立失败
这就是为什么「每实例调大池」在多副本场景下会出问题。
三、对象池:为什么在 JVM 上通常是负优化
三个理由
| 理由 | 说明 |
|---|---|
| 分配本身很便宜 | TLAB 指针碰撞,比一次内存访问还快(第 2 章 2.5) |
| 池有同步成本 | 从池里取/还需要并发安全的数据结构,无竞争时也比直接分配慢 |
| 破坏逃逸分析 | 本来可以栈上分配的对象,放进池里反而必须真正分配 |
还有两个维护风险:
| 风险 | 说明 |
|---|---|
| 状态污染 | 归还时没清理干净 → 比内存泄漏更难查的 bug |
| 忘记归还 | 池耗尽或"泄漏" |
什么时候对象池确实值得
| 场景 | 理由 |
|---|---|
| 大数组(如 1 MB 的 ByteArray) | 分配 + 清零 + GC 成本高;可能触发 Humongous(第 2 章 2.5) |
| 直接内存缓冲(Netty ByteBuf) | 堆外分配涉及系统调用 |
| 确实吃力的构造(如复杂正则编译、加密上下文) | 构造成本远大于池化成本 |
| 数据库连接 / 线程 | 见上文 |
判断方法:先测「构造/分配这个对象的成本」,再测「池化的成本」,对比。
// 判断步骤
// ① 测分配成本:用 JMH 或分配火焰图看 gc.alloc.rate
// ② 测构造成本:如果对象构造里有正则编译/加密初始化,成本可能很高
// ③ 如果 ①+② 的总成本 < 池化成本(同步 + 管理),就不要池化
四、线程池:复用但别贪多
线程池的价值
线程创建约 40 微秒(第 2 章 2.6 的实测),而一次普通操作只要几十纳秒——差三个数量级。所以按请求创建线程是灾难。
线程池的大小
| 任务类型 | 大小 | 理由 |
|---|---|---|
| CPU 密集 | ≈ CPU 核数 | 超过核数只增加切换开销 |
| IO 密集 | 核数 × 2 ~ 4 | 有等待时间可重叠 |
| 有下游限制 | 按下游承载能力 | Little’s Law 反推 |
协程时代的补充
协程不是"不需要池",而是"池变成了调度器":
// Dispatchers.Default 的并行度 = max(2, CPU 核数)
// Dispatchers.IO 的并行度 = max(64, CPU 核数)
两个必须做的事:
// ① 区分 CPU 密集与 IO 密集(分别用不同调度器)
withContext(Dispatchers.Default) { heavyCompute() }
withContext(Dispatchers.IO) { blockingIo() }
// ② 限制对下游的并发(用信号量)
private val limiter = Semaphore(64)
suspend fun call() = limiter.withPermit { withContext(Dispatchers.IO) { blockingIo() } }
五、池化的四个通用纪律
| 纪律 | 说明 |
|---|---|
| 必须是有界的 | 无界池 = 无界资源消耗(第 6.6 节模式八) |
| 必须有获取超时 | 避免无限等待(第 7.5 节的超时预算) |
| 必须有指标 | active / idle / pending / total(第 1 章 1.1 节) |
| 必须评估「池化的成本 vs 收益」 | 不是所有资源都值得池化 |
六、一个完整的连接池配置
val config = HikariConfig().apply {
jdbcUrl = "..."
username = "..."
password = "..."
// ① 池大小:按 Little's Law 推导(这里是 P95 对应的值)
maximumPoolSize = 20
minimumIdle = 5 // 保持一定的最小连接,避免突发时建连
// ② 获取连接超时(关键!避免无限等待)
connectionTimeout = 3_000 // 3 秒拿不到就失败
// ③ 连接有效性检测
validationTimeout = 1_000
keepaliveTime = 30_000 // 定期保活,避免被中间设备断开
// ④ 连接最大存活时间(避免长连接导致的负载不均)
maxLifetime = 1_800_000 // 30 分钟
// ⑤ 泄漏检测(开发/测试环境用)
leakDetectionThreshold = 10_000 // 10 秒未归还可疑
poolName = "app-pool"
}
每一项的理由:
| 参数 | 防止什么 |
|---|---|
maximumPoolSize |
无限制的并发打到数据库 |
connectionTimeout |
无限等待(最重要的一个) |
validationTimeout |
拿到失效连接 |
keepaliveTime |
连接被中间设备(防火墙/NAT)静默断开 |
maxLifetime |
长连接导致的负载不均 + 内存泄漏累积 |
leakDetectionThreshold |
忘记归还连接 |
七、本节小结
- 池化是在「复用成本」与「获取成本」之间取舍——对昂贵的资源值得,对便宜的不值得。
- 连接池大小用 Little’s Law 算:
所需连接 = 峰值QPS × 耗时;算 P50 版和 P99 版,取中间值并留余量。 - 连接池是数据库的并发闸门——「越大越好」是错的,加大池只是把排队转移到数据库。
- 多副本场景:所有实例的池大小之和不能超过数据库的
max_connections。 - 对象池在 JVM 上通常是负优化(分配便宜 + 池有同步成本 + 破坏逃逸分析);例外是大数组、直接内存、构造昂贵的对象。
- 线程池的价值来自「线程创建贵 3 个数量级」;协程时代要把
Default(CPU)和IO(阻塞)分开。 - 池化的四个纪律:有界、有获取超时、有指标、评估成本收益。
八、自测
- 峰值 QPS = 2000,单次查询 P50 = 5 ms、P99 = 50 ms。请分别用 P50 和 P99 算出所需连接数,并说明你会怎么配置。
- 一个有 6 个实例的服务,每个实例的连接池设成 30。数据库的
max_connections = 100。这有什么问题? - 同事说:「为了减少 GC,我把所有 DTO 都放进对象池复用了。」请说出这个改动的三个问题。
- 计算:
L_乐观 = 2000 × 0.005 = 10 个;L_悲观 = 2000 × 0.050 = 100 个。两者差 10 倍。配置建议:① 不要按 L_乐观(10)配——最慢的那一刻会严重排队,pending飙升;② 也不要直接按 L_悲观(100)配——那会让 100 个并发查询同时打到数据库,可能引发锁竞争与 IO 排队,整体更慢,而且浪费数据库内存;③ 取中间值(例如 P95 对应的数量,约 30–50),并且:④ 必须配置获取连接超时(如 3 秒),避免无限等待;⑤ 从偏小的值开始试(比如 20),观察pending与 数据库侧指标,逐步调整;⑥ 同时要检查查询本身是否合理——如果 P99 = 50 ms 是因为缺索引,优化查询比调池更有效(第 7.1 节的优先级)。 - 问题:6 × 30 = 180 个连接,但数据库的
max_connections = 100——超出部分无法建立连接。后果:① 部分实例的池永远填不满,pending持续 > 0;② 连接建立会失败并抛异常(FATAL: sorry, too many clients already),可能导致健康检查失败、实例被重启;③ 更隐蔽的是:未达到上限前看起来正常,但一旦所有实例同时满载,就会集体失败。解决方案:① 按实例数分摊——例如每实例池 ≤ 15(6 × 15 = 90 < 100);② 给运维/监控留几个连接(superuser_reserved_connections);③ 如果需要更多并发,考虑引入 PgBouncer 之类的连接池代理(把 180 个应用连接映射到少量数据库连接);④ 或者降低实例数、提高单实例能力;⑤ 根本上:优化查询,让单次查询更快(Little’s Law 会自动降低所需连接数)。 - 三个问题:① 分配本身很便宜——TLAB 指针碰撞比一次内存访问还快,池化的收益本来就小;② 池化引入同步成本——从池里取/还对象需要并发安全的数据结构,无竞争时也比直接分配慢,在高并发下可能更慢(第 2 章 2.6 的实测:无竞争锁 18ns,但池的完整操作包含更多步骤);③ 破坏逃逸分析——本来可以栈上分配甚至标量替换的 DTO,放进池里反而必须真正分配到堆上,GC 压力可能反而上升;④ 维护风险:忘记归还导致池耗尽,或归还时没清理干净导致状态污染(比内存泄漏更难查的 bug);⑤ 对 DTO 尤其不合适——DTO 通常是短生命周期、不可变的,正是逃逸分析最能发挥作用的场景。例外:如果 DTO 里包含大数组或直接内存,那池化可能值得——但应该只池化那个大缓冲,而不是整个 DTO。