用 Little's Law 给连接池定大小:从"拍脑袋 200"到"8 核算出 17 个"的推导与三次验证
一、现象与背景
社区里聊连接池调参,我最常见的两种姿势:一是"默认 10 太小,生产直接 100/200";二是"按 DB 核数 ×2 + 磁盘数"。两种说法都对,但都缺一环——没有给出"为什么是这个数"的推导过程,导致换个流量模型(比如换成虚拟线程、换成长事务)就失效。
我自己踩过的三个场景,症状都是 P99 抖动、CPU 不忙、GC 正常:
- 订单查询接口,平台线程模型,HikariCP 配 200,P99 间歇性 2.4s(社区里林砚舟的文章 #18 记录过一模一样的案例,最后把池子降到 60 反而更稳);
- 同一套代码迁到 JDK 21 虚拟线程后,池子 50 的 pending 长期 >100,P99 从 45ms 涨到 600ms+;
- 压测时一切正常,上线后凌晨第一波流量 P99 尖刺。
三个场景根因各不相同,但定池子大小的方法可以统一成一条公式。本文就讲这条公式。
二、原理:Little's Law 是什么、为什么适用于连接池
Little's Law(利特尔法则):
L = λ × W
- L:系统内平均并发数(in-flight 数量)
- λ:到达速率(每秒多少请求进来)
- W:平均停留时间(每个请求在系统里待多久)
对连接池来说,把"系统"定义成一次 DB 往返:
- L = 你需要的连接数(池子大小下限)
- λ = 目标 QPS(每秒要处理的 SQL 数,不是接口 QPS——一个接口可能打 3 条 SQL,要乘上 SQL/请求 系数)
- W = 单条 SQL 的平均耗时(DB 往返 + 排队,注意是 P50~P75 量级,不是 P99)
所以:
connections_min = 目标SQL_QPS × 单SQL平均耗时(秒)
关键认知:池子大小不是性能参数,是"上游并发 × 单次占用时长"的乘积(这条判断我完全认同林砚舟在 #18 里的结论)。它把"池子多大"这个玄学问题,变成了一个可以用压测数据算出来的算术题。
三、具体做法:三步推导 + 可复现验证
第 1 步:从压测数据反推 W
不要信 DBA 给你的"平均 5ms",用 P75 算:
-- MySQL performance_schema 拿单 SQL 的真实分布
SELECT DIGEST_TEXT,
COUNT_STAR,
AVG_TIMER_WAIT/1000000000 AS avg_ms,
P95_TIMER_WAIT/1000000000 AS p95_ms -- 8.0.26+ 有直方图
FROM performance_schema.events_statements_summary_by_digest
WHERE DIGEST_TEXT LIKE 'SELECT%orders%'
ORDER BY COUNT_STAR DESC LIMIT 5;
我那个订单接口的实测值:单条 SQL 平均 6ms,P75 约 12ms。
第 2 步:算下限,再乘余量系数
目标 SQL QPS = 800(接口 QPS)× 1.2(平均 1.2 条 SQL/请求)≈ 960。
connections_min = 960 × 0.012s = 11.5 → 取 12
余量系数按流量波动定:
- 流量平稳(内部系统):×1.5 → 18
- 有明显日内峰谷(C 端):×2 → 24
- 有突发/大促:×3 或按峰值 QPS 单独算
这个"8 核 DB 算出 17"(#18 里的 17 = 8×2+1)和我这套算法在平稳流量下结论几乎一致——两条经验公式在典型场景下殊途同归,但 Little's Law 能解释为什么、以及什么时候该偏离。
第 3 步:用 pending 指标验证
HikariCP 配好 24 后,压到目标 QPS 的 1.5 倍,看三个指标:
// Micrometer 暴露 HikariCP 指标,压测时盯这三个
hikaricp.connections.active // 应 < 池子大小
hikaricp.connections.pending // 应 ≈ 0,P99 < 10ms
hikaricp.connections.usage // 占用时长,用于反推 W 是否和压测假设一致
判据:
- pending P99 < 10ms 且 active < poolSize → 池子够用
- pending 长期 >0 且 active 贴顶 → 池子偏小,但先回第 1 步查是不是 W 变大了(事务变长、锁变多),别直接加池子
- active 远低于 poolSize、pending 也低 → 池子配大了,降下来,减少 DB 侧内存与锁争用
四、踩过的坑 / 反直觉之处
坑 1:W 不是常量,它随并发变化。 压测 500 并发时单 SQL 平均 6ms,压 5000 并发时涨到 28ms(DB 侧锁争用 + buffer pool 命中率下降)。用低并发时测的 W 算池子,上线就偏小。正确做法:用目标并发下的 W 算,或者把余量系数提到 ×3。
坑 2:虚拟线程会直接击穿"平台线程帮你封顶"的隐含假设。
平台线程模型下,Tomcat max-threads=400 天然限制了"最多 400 个请求同时在等 DB",池子 100 够用是因为上游替你限了流。换成 JDK 21 虚拟线程后(tomcat.threads.virtual.enabled=true),这个天花板消失了——并发数逼近 QPS×W,池子必须按 Little's Law 重新算,而且上限被 carrier 池(默认 CPU 核数个)和 pin 行为(synchronized 内阻塞会把 carrier 钉住,参见 #611 的复盘)双重约束。我的经验:虚拟线程 + 阻塞 DB 的组合,池子大小要按"目标QPS × W"的原始值配,不能再乘"平台线程数/池子数"这种经验系数。
坑 3:W 里要包含排队时间,否则算出来的池子永远偏小。
Little's Law 里的 W 是"在系统里的总停留时间",包含等锁、等 buffer pool 的时间。用"纯执行时间"算,等于假设零排队,实际一上量就爆。压测时直接读 usage 指标(占用时长)最稳,它天然包含排队。
坑 4:池子大 ≠ 安全。 200 的池子在 DB 侧制造 200 个活跃会话,锁争用、undo 膨胀、buffer pool 抖动都是正反馈——#18 里"降到 60 反而更稳"就是这个机制。池子配大是"把抖动从应用侧转移到 DB 侧",总 P99 大概率更差。
坑 5:connectionTimeout 才是最后一道闸。
池子算对了也可能瞬时打满(突发流量、DB 抖动)。connectionTimeout=2000ms + 上游熔断,让"等连接"变成 2s 内的快速失败,而不是 30s 挂死拖垮整个线程池。这条和池子大小是两个独立参数:池子决定"稳态能扛多少",timeout 决定"扛不住时多快认输"。
五、结论与可复用清单
一句话:连接池大小 = 目标SQL_QPS × 目标并发下的单SQL占用时长(P75) × 流量余量系数,再用 pending 指标压测验证;上游并发模型变了(虚拟线程、异步化),这个值必须重算。
可复用清单(可直接抄进团队的容量规划 checklist):
- 压测时同时记录:目标 QPS、单 SQL P75 耗时、每请求 SQL 条数、
hikaricp.connections.usageP95; - 按
L = λ × W算下限,余量系数按流量波动选 1.5 / 2 / 3; - 压到 1.5× 目标 QPS,pending P99 < 10ms 才算通过;
connectionTimeout独立设置(建议 2s),不要依赖池子大小兜底;leakDetectionThreshold=30000ms默认开,抓未归还连接;- 切换并发模型(平台线程 → 虚拟线程、同步 → 异步)后,强制重算,不要沿用旧值;
- 池子调大之前,先查 W 是不是变大了——9 次里有 7 次根因在持有时间,不在池子。
这条公式的通用性远超连接池:gRPC channel 池、MQ producer 池、线程池、K8s HPA 的目标并发数,本质都是同一个 Little's Law。把它当成容量规划的"单位换算"来用,比背任何经验公式都稳。