文档目录

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 忘记归还连接

七、本节小结

  1. 池化是在「复用成本」与「获取成本」之间取舍——对昂贵的资源值得,对便宜的不值得。
  2. 连接池大小用 Little’s Law 算:所需连接 = 峰值QPS × 耗时;算 P50 版和 P99 版,取中间值并留余量。
  3. 连接池是数据库的并发闸门——「越大越好」是错的,加大池只是把排队转移到数据库。
  4. 多副本场景:所有实例的池大小之和不能超过数据库的 max_connections。
  5. 对象池在 JVM 上通常是负优化(分配便宜 + 池有同步成本 + 破坏逃逸分析);例外是大数组、直接内存、构造昂贵的对象。
  6. 线程池的价值来自「线程创建贵 3 个数量级」;协程时代要把 Default(CPU)和 IO(阻塞)分开。
  7. 池化的四个纪律:有界、有获取超时、有指标、评估成本收益。

八、自测

  1. 峰值 QPS = 2000,单次查询 P50 = 5 ms、P99 = 50 ms。请分别用 P50 和 P99 算出所需连接数,并说明你会怎么配置。
  2. 一个有 6 个实例的服务,每个实例的连接池设成 30。数据库的 max_connections = 100。这有什么问题?
  3. 同事说:「为了减少 GC,我把所有 DTO 都放进对象池复用了。」请说出这个改动的三个问题。
  1. 计算: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 节的优先级)。
  2. 问题: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 会自动降低所需连接数)。
  3. 三个问题:① 分配本身很便宜——TLAB 指针碰撞比一次内存访问还快,池化的收益本来就小;② 池化引入同步成本——从池里取/还对象需要并发安全的数据结构,无竞争时也比直接分配慢,在高并发下可能更慢(第 2 章 2.6 的实测:无竞争锁 18ns,但池的完整操作包含更多步骤);③ 破坏逃逸分析——本来可以栈上分配甚至标量替换的 DTO,放进池里反而必须真正分配到堆上,GC 压力可能反而上升;④ 维护风险:忘记归还导致池耗尽,或归还时没清理干净导致状态污染(比内存泄漏更难查的 bug);⑤ 对 DTO 尤其不合适——DTO 通常是短生命周期、不可变的,正是逃逸分析最能发挥作用的场景。例外:如果 DTO 里包含大数组或直接内存,那池化可能值得——但应该只池化那个大缓冲,而不是整个 DTO。