结论先行:HikariCP 对一条连接有两把「寿命尺」——
idleTimeout是「闲置回收线」,但它只对超出minimumIdle的富余连接生效;maxLifetime是「保质期」,对每条连接生效。两把尺子共用一个「收尸人」(Housekeeper 线程,每 30 秒巡视一次),且退役都是「先退后补」。这就是为什么「我设了 idleTimeout=30s 连接数却没降」和「我设了 maxLifetime=30min 却有连接活了 33min」都会发生。
一、现象与背景
栈:Spring Boot 3.2.4、JDK 17、HikariCP 5.1.0、MySQL 8.0.32(云 RDS,开 SSL)。一个对账结算服务,4 实例、每实例 8C16G,maximumPoolSize=30,minimumIdle 默认(等于 maximumPoolSize,即 30),idleTimeout 默认 600000ms(10min),maxLifetime 默认 1800000ms(30min)。
业务曲线非常「批处理」:每天 01:00–03:00 对账高峰 QPS 约 5000,其余时段 QPS 只有 2~10。也就是说这 30 条连接一天 26 小时基本闲着。
DBA 来敲门:「你们 4 实例占了 120 条连接,砍一砍。」于是有人「优化」:idleTimeout=30000(30s)、minimumIdle=5,逻辑是「闲置 30 秒就还,低谷只留 5 条」。
上线当晚 22:00 生效,次日 01:02 对账高峰一来,监控尖叫:高峰前 30 秒 P99 从 15ms 涨到 1.4s,5xx 占比短时 2%。日志里 HikariPool-1 疯狂 add connection——一口气新建了 25 条(池子从 5 涨回 30)。内网单条新连接(TCP 握手 + SSL 握手 + MySQL 鉴权)实测 60~90ms,25 条串行创建 ≈ 1.9s 的「拿连接」成本全压在高峰第一批请求头上。
我一开始以为是 DB 抖动,把池子的 JMX 指标翻出来才反应过来:池子白天缩了、晚上要重新长,长的代价由高峰的第一批用户买单。
二、原理:两条生命线,一个收尸人
HikariCP 里一条连接的生死由两个计时器决定:
| 参数 | 默认值 | 作用对象 | 检查时机 |
|---|---|---|---|
| idleTimeout | 600000ms(10min) | 仅超出 minimumIdle 的富余空闲连接 | Housekeeper 每 30000ms 巡视一次 |
| maxLifetime | 1800000ms(30min) | 每条连接(含活跃) | 每次 getConnection() 借出时检查 + Housekeeper 巡视 |
三个关键机制:
- 收尸人 30 秒才醒一次。 Housekeeper 线程周期是固定 30000ms,所以「到期即退」不成立——一条连接实际最长可活 maxLifetime+30s。而且 HikariCP 会给每条连接的 maxLifetime 随机加 ±10% 抖动(1800000ms 会变成 1620000~1980000ms),防止全池连接同一刻到期触发「换池风暴」。所以「设了 30min 怎么有连接活了 33min」——那是 1800000×1.1=1980000ms,正好在抖动上界。
- 先退后补(lazy replacement)。 连接退役时池子不会立刻开新的,要等 Housekeeper 发现「idle 数 < minimumIdle」或新请求进来才补。所以退役瞬间
connections.total会低于maximumPoolSize——这是正常现象,不是 bug。 minimumIdle是「免回收区」。 空闲连接只会被回收到minimumIdle为止,低于等于这个数的部分不受idleTimeout约束。默认配置下 minIdle=maxPool=30,你只改idleTimeout=30s而不动 minIdle,一条都不会被回收——因为池底就是 30,不存在「富余」。
打个比方:连接池像一池可重复使用的筷子。idleTimeout 是「搁桌上太久的筷子收回去」,但池里必须常备 minimumIdle 双;maxLifetime 是筷子上的「保质期」,到期即使有人正拿着也要换。池长(Housekeeper)每 30 秒才巡一次场,筷子在两次巡场之间到期,得等下一轮才被收。
三、具体做法
3.1 批处理服务的正确组合(我最后落地的配置)
spring:
datasource:
hikari:
jdbc-url: jdbc:mysql://rds.internal:3306/settle?useSSL=true&socketTimeout=30000&connectTimeout=5000
maximum-pool-size: 30
minimum-idle: 30 # 关键:低谷不缩池,高峰就不用重新长
idle-timeout: 600000 # minIdle=maxPool 时它形同虚设,保持默认即可
max-lifetime: 1800000 # 30min,远短于 MySQL wait_timeout(28800s),留足量级差
keepalive-time: 120000 # HikariCP 5.0.0+,每 2min 对空闲连接 ping 一次
connection-timeout: 30000
为什么 minimum-idle=30:批处理场景下「低谷省 25 条连接」的收益,远小于「高峰前 30 秒不重新长池」的收益。新连接成本(SSL+鉴权 60~90ms/条)是由你自己的用户在高峰期付的。真要省连接,把 maximumPoolSize 按 Little 定律砍到 20,minIdle 保持等于 max——砍上限,不砍下限。
3.2 keepaliveTime:在池层解决「僵尸连接」
如果链路中间有 NAT / LB(idle timeout 300s),长时间空闲的连接会被 LB 清掉表项,TCP 两端还以为活着——社区已有人写过完整的 NAT 假死复盘(见引用)。不想动内核 sysctl 的替代方案就是池层的 keepaliveTime:
keepalive-time: 120000 # 2min < 300s NAT idle timeout,定时 ping 刷新 NAT 表项
它只作用于空闲连接,默认 0(关闭)。比全局 tcp_keepalive_time=60 更精准,不影响同机其他服务。
3.3 观测:把「悄悄退役」看明白
logging:
level:
com.zaxxer.hikari.pool: DEBUG # 可见 "soft retiring pool connection" / "closing pool connection"
JMX 指标(HikariPool-1):connections.total / connections.idle / connections.active / connections.max。出故障时看 total 是否「先降后升」,配合日志里 add connection 的时间戳,就能还原出「回收—重长」的时间线,把它和「真打满」区分开。
四、踩过的坑与反直觉
- 「设了 idleTimeout=30s 连接数不动」不是 bug——你没降
minimumIdle。minIdle 以下的连接免疫 idleTimeout,默认配置(minIdle=maxPool)下一条都不会被回收。 - 连接数会低于 maximumPoolSize——先退后补,
total < max是正常现象,补建是惰性的,别把它当泄漏。 - maxLifetime 不是精确时间——±10% 抖动,「30 分钟」实际可能是 27~33 分钟;这是防批量到期的设计,不是缺陷。
- 缩池省连接是伪优化——省 25 条连接 × 26 小时,换来高峰第一秒 1.9s 的拿连接延迟。批处理服务该砍 maxPool,不该砍 minIdle。
- maxLifetime 要远短于 MySQL
wait_timeout——若 maxLifetime 设到 8h,DB 侧wait_timeout先断,应用拿着已断连接执行会拿到CommunicationsException而不是干净的退役路径,排查时会被误导(见引用文)。 keepaliveTime救不了活跃连接——它只 ping 空闲连接。慢 SQL 握着连接 5 分钟,keepalive 不生效,得靠socketTimeout兜底。
五、结论与可复用清单
一句话:idleTimeout 管「富余」,maxLifetime 管「保质期」,30 秒一轮的 Housekeeper 管执行,退役一律「先退后补」。 批处理/尖峰型服务的策略是「砍上限、保下限、keepalive 刷表项」,而不是「砍下限省连接」。
清单(可直接抄进 review 清单):
minimum-idle = maximum-pool-size(尖峰型服务);要省连接先砍 maxPool。max-lifetime远短于 DBwait_timeout,且记住 ±10% 抖动窗口。- 链路有 LB/NAT 时:
keepalive-time< NAT idle timeout(300s → 设 120s)。 - 排障三看:JMX
total/idle/active曲线 +soft retiring/add connection日志时间线 +SHOW PROCESSLIST,区分「回收重长」与「真打满」。 connection-timeout与上游超时按级联预算对齐,连接池类异常不裸重试(见引用文)。