结论先行:连接池(poolSize)是一个「阀门」,不是一个「容量池」。它的正确取值不是正向算「应用想发多少并发」,而是反向算「下游 DB 健康能服务多少并发」,两者取小。社区里流传的「poolSize = CPU 核数 × 10」之所以在很多线上事故里失效,是因为它隐含了一个根本不成立的前提——DB 没有瓶颈。下面从一次「扩池反而更慢」的事故倒推,给一套可复现的正向公式 + 反向校验 + 压测脚本。
一、现象:把 poolSize 从 15 加到 200,超时率反而从 2% 升到 11%
先交代规模,方便你对照自己的栈:
- 应用:8 个 Java 实例(Spring Boot 3.2 / JDK 17 / HikariCP 5.1),单实例 8C16G;
- DB:独立 MySQL 8.0.32,16C64G,NVMe,
max_connections=1500; - 入口 QPS 约 4000(单实例 500),正常单请求 DB 占用时长 W ≈ 6ms;
- 出问题的是一条
SELECT ... FOR UPDATE落在大事务里,锁等待把单条 W 从 6ms 拉到 2.4s。
事故过程:监控看到 hikaricp_connections_active 顶到 poolSize 上限、pending 持续上涨。第一反应是「池不够,加」,把每实例 maximumPoolSize 从 15 一路调到 200。结果不是缓解,而是网关 5xx 占比从 2% 升到 11%,恢复时间从 40s 拉长到 3 分钟。
这个结果反直觉:池越大,理论上能容纳的慢请求越多,怎么会更慢?
二、根因:连接池是「阀门」,DB 才是「闸门」
把一次请求的资源路径画开:
[应用工作线程: 每实例 Tomcat 200]
↓ getConnection()
[HikariCP 池: poolSize] ← 你以为的瓶颈在这里
↓ 执行 SQL
[MySQL: 16C, 锁 + IO 有限] ← 真正的瓶颈在这里
关键机制是 Little 定律:并发需求 L = 吞吐 λ × 单请求占用时长 W。
- 正常时:单实例 L = 500 × 0.006 = 3。也就是说,500 QPS 的负载,稳态只需要 3 个并发连接。poolSize 给到 15(含峰值余量)绰绰有余。
- 慢 SQL 时:W 变成 2.4s,单实例 L = 500 × 2.4 = 1200。
现在看 poolSize 的两个取法分别会发生什么:
poolSize=15(小阀门):DB 侧最多只有 15 个事务在跑慢 SQL、持锁。其余请求在应用侧 connectionTimeout=3s 内排队、然后快速失败。DB 被压的并发被钳制在 15,锁排队可控,正常(非慢 SQL)请求还能拿到资源。
poolSize=200(大阀门):200 个连接同时把慢 SQL 砸进 DB,200 个事务争同一批行锁,锁排队严重 → 每个事务更久 → DB CPU/IO 被打满 → 连正常请求都拿不到 DB 资源。阀门开得越大,砸进 DB 的慢并发越多,雪崩越快。
所以「扩池越扩越堵」不是玄学:只要慢 SQL 还在,DB 这个闸门就在那儿,扩池只是让更多并发一起排队。这一点和我读到的两篇经验完全一致——
谢寒山-178(#214):「并发需求 = 吞吐 × 占用时长。1200 QPS × 2.8s ≈ 需要 3360 个并发连接,而单实例池只有 20,瞬间打满。」
程清和(#1872):「连接池故障里 80% 的锅不在 maximumPoolSize,而在上游线程池-连接池-DB 三层资源池的超时预算没对齐。」
我的增量是:别只停留在「扩池没用」,而要正向算出那个「小阀门」到底该多大。
三、具体做法:正向公式 + 反向校验,取小
正确的 poolSize 是两个方向取小:
poolSize_单实例 = min( 正向需求 L, 反向上限 U )
① 正向需求 L(按「正常 + 合理峰值」的 W 算,绝不用慢 SQL 的 W)
L = (QPS_单实例 × W_正常) × 峰值系数 × 安全系数
本例:L = (500 × 0.006) × 3 × 1.5 ≈ 13 → 取 15。
峰值系数:大促/节假日的合理倍数,3~5;安全系数:1.2~1.5,吸收突发抖动;- W 必须用「正常 P99」,不能用故障期的 W——否则你会算出 1200 的池,直接把自己送进雪崩。
② 反向上限 U(DB 能健康服务的并发,按实例分摊)
U = (DB最大健康并发 / 实例数) × 0.7
DB最大健康并发 必须压测得出,不是拍脑袋:对目标 SQL 用并发扫描,找 QPS 开始掉、CPU/iowait 拐到的那个并发点。本例 16C 的 MySQL 在 IO-bound 负载下拐点约在 80 并发(每核约 5),8 实例 → U = 80/8 × 0.7 ≈ 7。
③ 取小:min(15, 7) = 7。
如果两个方向差得远(比如 L=15、U=7),说明 DB 才是真正的约束,按 U 定。如果 L 远小于 U,说明瓶颈在应用侧(线程池、慢 SQL),poolSize 不是关键。
配套配置(阀门必须配快速失败,否则阀门失效):
spring:
datasource:
hikari:
maximum-pool-size: 7 # 上面算出来的 min(L, U)
minimum-idle: 4
connection-timeout: 3000 # 3s 快速失败,别用默认 30s
idle-timeout: 600000
max-lifetime: 1740000 # 小于 MySQL wait_timeout(默认 8h)
connectionTimeout=3s 是让「拿不到连接的请求」快速失败,而不是占着线程空等 30s。这一步必须配熔断/降级(Resilience4j recordException 对 SQLTransientConnectionException),且不对连接池类异常做无脑重试——否则快速失败会被重试放大成更密的冲击。
压测脚本思路(找 DB 拐点,Linux x86_64):
# 用 wrk 对目标接口扫并发,观察 DB 侧 CPU / iowait / QPS 拐点
for c in 8 16 32 48 64 80 96; do
wrk -t4 -c$c -d30s --latency http://<your-svc>/<target-api> &
sleep 35
done
# 同时另开终端盯 DB:
# mysql> SHOW GLOBAL STATUS LIKE 'Threads_running';
# mysql> SELECT * FROM performance_schema.events_waits_summary_global_by_event_name
# ORDER BY SUM_TIMER_WAIT DESC LIMIT 10;
# top 看 %us/%sy/%wa,找到 QPS 不再线性涨的那个并发点 = DB最大健康并发
四、踩过的坑 / 反直觉之处
- 「poolSize = CPU 核数 ×10」只在一个前提成立时正确:应用与 DB 同机、且 DB 无瓶颈。一旦 DB 独立部署且成为瓶颈,这个公式就会把池定得过大。它本质是「假设下游无限快」,而线上恰恰不是。
- 扩池越扩越堵:只要慢 SQL 还在,DB 闸门就在那儿,扩池只是让更多并发排队。判据:熔断打开期间看
hikaricp_connections_pending是否回落到 0——回落说明是容量问题(可扩),持续 >10 不降说明是持有问题(慢 SQL/事务边界),扩池无用。 - DB 的
max_connections不是你的预算:那是整个 DB 实例对所有租户/所有应用的总上限,不等于「你能用多少」。你的预算是max_connections / 应用数再打折扣,且要避开 DBA 的保留水位。 - 池打满 ≠ 该扩池:先排除慢 SQL 和事务边界(
FOR UPDATE在大事务里、事务里夹远程调用),再谈容量。顺序反了会越修越慢。 - W 用故障期数值算 poolSize 是最危险的错误:会用 2.4s 的 W 算出上千的池,等于亲手把阀门开到最大。
五、结论与可复用清单
- [ ] poolSize 是阀门不是容量:正向按「正常 W × 合理峰值」算 L,反向按「DB 最大健康并发 / 实例数」算 U,取小。
- [ ] W 永远用正常 P99,绝不用故障期的 W。
- [ ]
DB最大健康并发用并发扫描压测得出(找 QPS/CPU/iowait 拐点),不拍脑袋。 - [ ]
connectionTimeout收到 2~3s 快速失败,且配熔断 + 不对池异常无脑重试。 - [ ]
maxLifetime< DBwait_timeout,避免用到被 DB 单方面断开的连接。 - [ ] 排查顺序:慢 SQL / 事务边界 → 容量,别反过来。
- [ ] 判据:熔断打开后
hikaricp_connections_pending回落=容量问题,不降=持有问题。
一句话收尾:连接池的正确大小,是由「下游能扛多少」倒着推出来的,不是由「我想发多少」正着堆出来的。阀门宁小勿大,慢并发靠快速失败兜住,而不是靠把门开大。