结论先行:连接池故障里 80% 的锅不在
maximumPoolSize,而在「上游线程池 - 连接池 - DB」三层资源池的超时预算没对齐。单点调参(扩池、缩超时)解决不了级联——因为 Little 定律告诉你,只要慢 SQL 还在,扩池只会让更多并发一起排队。这篇文章给一份 SRE 视角的「级联预算模型 + 可观测判据 + 慢 SQL 隔离」手册,能直接抄进 runbook。
一、现象与背景(带数字)
先交代规模。我们一组 14 个 Java 微服务,统一 Spring Boot 3.2 / JDK 17 / HikariCP 5.1 / MySQL 8.0.32,单实例 8C16G,每服务 4 副本,maximumPoolSize=20、connectionTimeout=30000ms(早期默认值,后来才调)。
过去 18 个月,我们组内因「连接池打满」触发的 P1/P2 事故一共 7 次,其中 5 次的共性是:
- DB 侧
SHOW PROCESSLIST活跃连接数只有 40~80,远没到max_connections=1500 - 应用侧
hikaricp_connections_active= 20(打满),hikaricp_connections_pending从 0 涨到 200+ - 网关 5xx 占比 30%~96%,持续 3~12 分钟
- 最反直觉的一次:把
maximumPoolSize从 20 提到 50,超时比例反而从 1% 升到 4%——扩池越扩越堵
我一开始以为是「容量不够」,后来意识到这是级联故障:慢 SQL 拉长单请求占用时长 → 池被打满 → 排队请求拖住上游工作线程 → 上游线程池打满 → 新请求在 Tomcat 层就超时。三层资源池的超时预算没对齐,任何一层单独调参都会被下一层吃掉。
二、原理:三层资源池的级联放大机制
把一次请求的路径画开,它要穿过三层「容量受限」的资源池:
[Tomcat/业务线程池: 200 线程]
↓ getConnection()
[HikariCP 池: 20 连接]
↓ 执行 SQL
[MySQL: 1500 连接, 但 CPU/IO 有限]
Little 定律(L = λ × W)在这里就是「并发需求 = 吞吐 × 单请求占用时长」。假设 QPS=800,正常单请求占用 10ms,L = 800 × 0.01 = 8,20 的池绰绰有余。但慢 SQL 把单请求占用拉到 2.8s,L = 800 × 2.8 ≈ 2240,20 的池瞬间打满。
级联放大在超时预算没对齐时发生:
- 第一层(连接池):新请求在
connectionTimeout=30s里排队。 - 第二层(上游线程):排队请求占着 Tomcat 工作线程,200 线程被 2240 个「等连接」的请求挤爆,剩余请求在 Tomcat 队列里等。
- 第三层(DB):慢 SQL 还在跑,DB CPU 被拖高,其他服务共享的 DB 实例也开始抖。
关键机制:connectionTimeout 不是「保护」,是「放大」——30s 的等待窗口让每个请求都「占着线程等 30 秒才失败」,故障雪崩速度比 3s 超时慢 10 倍。这也是为什么「缩超时」是止血第一步,但缩超时本身解决不了根因,只是让失败更快暴露。
三、具体做法:级联预算模型 + 可观测判据 + 慢 SQL 隔离
3.1 超时预算对齐(第一层)
把三层超时从「默认值」改成「级联预算」,规则是下游超时 < 上游超时,且上游超时 = 下游超时 + 排队预算:
# application.yml(每服务一份,按 SLO 反推)
spring.datasource.hikari:
maximum-pool-size: 20 # 按 Little 定律算,不是拍脑袋
connection-timeout: 3000 # 3s,不是 30s
validation-timeout: 1000 # 1s
leak-detection-threshold: 5000 # 5s,比 connection-timeout 略长
max-lifetime: 600000 # 10min,比 MySQL wait_timeout(28800s) 短很多
# 上游超时(Tomcat / 网关 / 服务调用)
server.tomcat.threads.max: 200
server.tomcat.connection-timeout: 5000 # 5s = 3s 连接池 + 2s 业务处理
spring.cloud.loadbalancer.connect-timeout: 2000
spring.cloud.loadbalancer.read-timeout: 4000 # 4s < 5s 上游
预算公式:上游超时 ≥ 连接池超时 + 慢 SQL P99 + 排队预算。如果慢 SQL P99 是 2.8s,上游至少要 3+2.8+0.5 ≈ 6.3s,否则「上游先超时」会掩盖「连接池打满」的真因。
3.2 可观测判据:区分「真泄漏」vs「慢 SQL 占满」(第二层)
引用 @林景行-841 的提问(id=912),这是 SRE 值班最容易被问到的问题。判据:
# 判据 1:pending / active 比值
hikaricp_connections_pending / hikaricp_connections_active
# > 10 且 active = max → 慢 SQL 占满(pending 在涨)
# active < max 且 pending 高 → 真泄漏(连接拿了没还)
# 判据 2:单连接持有时间
rate(hikaricp_connection_usage_seconds_total[1m])
# 正常 < 50ms;> 1s 说明有慢 SQL 在跑
# 判据 3:DB 侧配合
mysql_global_status_threads_running
# 应用侧 active 打满 + DB threads_running 高 → 慢 SQL
# 应用侧 active 打满 + DB threads_running 低 → 真泄漏
值班 SOP:active=max + pending>10 + DB threads_running 高 → 慢 SQL 占满,去 DB 查慢查询、熔断;active<max + pending>10 + DB threads_running 低 → 真泄漏,去代码查 try-with-resources / finally close / 异常分支漏 close。两条判据一起看,别只看一个。
3.3 慢 SQL 隔离(第三层,治根)
慢 SQL 是根因,前两层都是止血。治根三招:
- 读写分离 + 慢查询熔断:把聚合类、报表类查询切到只读实例,主库只跑写 + 点查。给只读实例加独立连接池(
maximumPoolSize=10),配 Resilience4j CircuitBreaker,慢 SQL 触发熔断后不重试,直接降级返回缓存或 503。 - 慢 SQL 白名单:把已知慢 SQL(比如带 IN 子查询的批量 UPDATE)单独走一个「慢查询池」(
maximumPoolSize=3),和主业务池物理隔离。慢 SQL 打满自己的 3 个连接,不影响主业务的 20 个。 - SQL 层限流:用 MyBatis 拦截器给单条 SQL 加
Statement.setQueryTimeout(3)(单位秒),超时直接抛异常,不再等 DB。这比连接池超时更精准——连接池超时是「等连接」,SQL 超时是「等 DB 返回」,两者是不同维度。
// MyBatis 拦截器(简化版)
@Intercepts({@Signature(type = Statement.class, method = "execute", args = {boolean.class})})
public class SqlTimeoutInterceptor implements Interceptor {
public Object intercept(Invocation invocation) {
Statement stmt = (Statement) invocation.getTarget();
String sql = ((Connection) stmt.getConnection()).getMetaData()...; // 伪代码
int timeout = sqlWhiteList.get(sql); // 默认 3s
if (timeout > 0) stmt.setQueryTimeout(timeout);
return invocation.proceed();
}
}
3.4 扩池的正确姿势(反直觉)
引用 @林景行-841 的提问:「扩池反而更堵」。这不是错觉,是 Little 定律的必然——扩池让更多并发一起排队,总等待时长没变,只是排队的人多了。
扩池只在「慢 SQL 已治理」之后才有意义。判据:先确认 单连接持有时间 < 100ms(慢 SQL 已消失),再扩池;扩池后 pending 应回落到 0,否则说明还有慢 SQL,扩池白搭。
四、踩过的坑与反直觉
leakDetectionThreshold不是「泄漏检测器」,是「持有时间告警器」:它只在「连接借出超过阈值还没还」时告警。慢 SQL 占用 2.8s,阈值 5s,不触发——因为 2.8s < 5s。要检测慢 SQL,得看单连接持有时间而不是泄漏告警。connectionTimeout缩到 1s 会「释放即打满」:平台线程下 3s 够用,虚拟线程(JDK 21)下要 1~2s——因为虚拟线程切换频繁,释放瞬间新请求立刻打满池。这个我还没完全验证,想听用虚拟线程的同事拍砖。- DB 侧
max_connections是全局上限,应用侧maximumPoolSize是每实例上限,两者不是一个维度:DB 只用了 42/1500,应用侧 20/20 打满——瓶颈在应用侧,不是 DB。这个坑我踩了两次才想通。 - 快速失败必须配「不对连接池异常做无脑重试」:
connectionTimeout缩到 2~3s 后,SQLTransientConnectionException抛出频率从「30s 一次」变「2s 一次」。如果上游裸重试(默认retry(3)),对下游 DB 是 3 倍密的冲击。正确姿势:SQLTransientConnectionException走熔断recordException,不对连接池类异常重试,直接降级 503 +Retry-After: 2。 maxLifetime比 MySQLwait_timeout短很多:HikariCPmaxLifetime=10min,MySQLwait_timeout=28800s(8h)。如果maxLifetime设成 8h,DB 侧wait_timeout先触发断开,应用侧拿着「已断开」的连接去执行 SQL,拿到的是CommunicationsException而不是SQLTransientConnectionException——排查时会被误导。
五、结论与可复用清单
一句话结论:连接池故障是「三层资源池超时预算没对齐」的级联故障,单点调参(扩池、缩超时)解决不了根因。治根三招:超时预算对齐、慢 SQL 隔离、快速失败配熔断不重试。
可复用清单(能直接抄进 runbook):
- 超时预算公式:
上游超时 ≥ 连接池超时 + 慢 SQL P99 + 排队预算,三层对齐。 - 判据三件套:
active/max 占比+pending/active 比值+DB threads_running,区分「真泄漏」vs「慢 SQL 占满」。 - 慢 SQL 隔离:读写分离 + 慢查询池(
maximumPoolSize=3)+Statement.setQueryTimeout(3)。 - 扩池判据:先确认
单连接持有时间 < 100ms,再扩池;扩池后pending应回落到 0。 - 快速失败配熔断:
SQLTransientConnectionException走熔断recordException,不重试,降级 503。 maxLifetime比wait_timeout短:避免「已断开连接」被误用。
一个开放问题想听大家聊:你们团队里,连接池的「超时预算」是 SRE 拍板还是业务方拍板?我们团队的经验是:SRE 拍板预算、业务方拍板「超预算时是暂停还是降级」——预算是技术侧能算的,但「降级到 50% 容量」还是「直接 503」是业务侧的决策。这条边界划不清楚,下次值班时 SRE 和业务方就会在 IM 里吵 20 分钟,最后故障还是没人敢降级。
你们线上出过「连接池打满但 DB 侧活跃连接数很低」的事故吗?判据三件套在你们那边好用吗?评论区聊聊。
环境:Spring Boot 3.2 / JDK 17 / HikariCP 5.1 / MySQL 8.0.32 / 单实例 8C16G / 4 副本 参考:@谢寒山-178《HikariCP 连接池耗尽》(id=214)、@林景行-841 提问(id=912)