结论先行:连接「死掉」有两种,可检测性完全不同——服务端
wait_timeout触发是服务端主动发 FIN,Hikari 借出时能识别并丢弃;而 NAT / 代理层 idle 超时是静默丢包,客户端 socket 仍显示 open,isValid()只查本地状态、不发探测包,于是死连接被正常借出,卡 30 秒才失败。这篇文章把「借出即死」这条链路的机制讲透,并给maxLifetime与keepaliveTime的一组可落地取值。
一、现象:57 个请求借到连接后各卡 30 秒
- 服务:Spring Boot 3.2.5,JDK 17.0.10,4 个 Pod(K8s 1.28),HikariCP 5.1.0,
maximumPoolSize=20、minimumIdle=20、connectionTimeout=30s、validationTimeout=5s、maxLifetime未显式配置(默认 1800000ms = 30min)。 - 数据库:MySQL 8.0.32 云 RDS,走代理层(读写分离代理),应用直连地址是代理 VIP,不直连物理实例。
- 业务曲线:白天 QPS 约 3200;凌晨 03:00 掉到 QPS 约 5;03:10–03:20 的 10 分钟窗口内 57 个请求,每个都 hang 住整整 30 秒(=
connectionTimeout),然后抛CommunicationsException: Communications link failure,P99 从 15ms 飙到 30012ms。 - 诡异点:这 57 个请求全部借到了连接(HikariCP 日志无
Connection is not available, request timed out),也没触发validationTimeout(无Connection is not available, connection test query failed)。连接是「借出成功」后在执行第一条 SQL 时才死的。 - 同时段 RDS 监控:连接建立数从 12/min 飙到 480/min(失败重试 + 池补充叠加),
Aborted_clients涨 117、Aborted_connects涨 93。
二、根因:两条「死法」在 TCP 层的表现完全不同
2.1 谁先杀连接:wait_timeout 不是唯一变量
MySQL 侧 wait_timeout 默认 28800(8 小时),但实际生效的是 wait_timeout、interactive_timeout、以及代理层 idle 超时三者中最小的那个。我们 RDS 的代理层 idle 超时默认 1800s(30 分钟)——这个值在 RDS 控制台的「代理参数」里,不在 SHOW VARIABLES 里,DBA 文档默认没人查。
2.2 两种死法的差异
| 死法 | 触发者 | 客户端是否收到 FIN/RST | socket.isOpen() | Connection.isValid(1) | Hikari 借出时行为 |
|---|---|---|---|---|---|
| wait_timeout | 服务端主动 | 是(FIN) | false | false | 识别死连接,丢弃并重建,不借出 |
| NAT / 代理 idle 超时 | 中间设备静默 | 否 | true | true | 识别不出来,正常借出 |
关键在第二行。MySQL Connector/J 的 ConnectionImpl.isValid(timeout) 实现是:
// 简化后的等价逻辑
public boolean isValid(int seconds) {
if (isClosed()) return false;
return true; // ← 没有发任何网络探测包
}
isClosed() 只查本地 socket 状态。NAT 静默丢弃时,客户端没收到 FIN/RST,socket 仍显示 open,isValid(1) 返回 true,HikariCP 的 isConnectionAlive() 检查通过,死连接被正常借出。执行 SQL 时写入触发 TCP 重传,挂 30 秒后收到 RST(或超时),抛 CommunicationsException——这就是那 57 个请求的完整链路。
2.3 HikariCP 的借出检查到底查了什么
HikariPool.getConnection() 的核心循环(简化):
// 伪代码,对应 5.1.0 实现
for (int i = 0; i < MAX_LIFESPAN_CHECKS; i++) {
Connection conn = idleConnections.poll();
if (conn == null) break;
if (conn.isClosed()) { close(conn); continue; } // 路径 A:FIN 已到
if (!isConnectionAlive(conn)) { close(conn); continue; } // 路径 B:isValid(1) 失败
return conn; // ← 静默死连接走到这里,被借出
}
isConnectionAlive() 调 Connection.isValid(validationTimeout),对 MySQL 驱动就是上面那段「只查本地状态」的代码。HikariCP 在借出时不发任何探测包——这是设计选择(避免每次借出都 RTT 一次),但代价就是:对「静默死」无能为力,只能靠 maxLifetime 和 keepaliveTime 主动预防。
三、具体做法:maxLifetime 与 keepaliveTime 的正确取值
3.1 取值原则
maxLifetime 必须小于所有中间层 idle 超时的最小值(代理层、NAT、RDS 代理,逐一查控制台);keepaliveTime 必须小于 maxLifetime,且小于代理层 idle 超时,让保活 ping 在连接被杀之前先把连接「顶活」。
我们环境的最小 idle 超时是代理层的 1800s,所以:
spring:
datasource:
hikari:
maximum-pool-size: 20
minimum-idle: 20 # 与 maximum 一致,避免低峰缩池后高峰重建
max-lifetime: 1500000 # 25min < 代理 1800s,留出 5min 余量
keepalive-time: 300000 # 5min < maxLifetime,周期发 COM_PING
connection-timeout: 30000
validation-timeout: 5000
keepaliveTime=300s:每 5 分钟对空闲连接发一次COM_PING,代理层 idle 计时器被重置,连接不会被静默杀。注意keepaliveTime默认 0 = 关闭,很多人不知道这个参数存在。maxLifetime=25min:即使keepaliveTime因某种原因没生效,连接也会在 25min 到期重建,不会活到代理层 30min 的 idle 超时。- 不要用
connectionTestQuery:HikariCP 已废弃此参数,isValid(1)路径对 MySQL 驱动就是查本地状态,配SELECT 1反而绕过了isConnectionAlive的快速路径。
3.2 发布时的连接建立风暴
滚动重启时 4 个 Pod 同时冷启动,每 Pod 建 20 条连接,瞬时 80 条新连接。如果 minimumIdle < maximumPoolSize,低峰缩池后高峰又要重建,叠加重启窗口,RDS 连接建立数会尖刺。做法:
minimumIdle = maximumPoolSize(固定池大小,避免缩扩抖动);- 分批发布(
maxUnavailable: 1),避免 4 Pod 同时建连; - 监控 RDS 的
Connections_created速率,发布窗口内 > 200/min 视为异常。
3.3 监控清单
# HikariCP 指标(需引入 micrometer)
hikaricp_connections_active # 活跃连接
hikaricp_connections_idle # 空闲连接
hikaricp_connections_total # 总连接(含正在建立的)
hikaricp_connections_pending # 等待借出的线程数(>0 持续 10s 告警)
hikaricp_connections_timeout # 借出超时计数(>0 立即告警)
RDS 侧关注 Aborted_clients(客户端断连)和 Aborted_connects(连接建立失败)的速率,发布窗口内这两个指标会涨,属预期;非发布窗口内涨则异常。
四、踩过的坑(反直觉)
-
maxLifetime默认 30min 看起来「足够长」,但代理层 idle 超时可能更短。我们 RDS 代理层默认 1800s,恰好等于maxLifetime——连接在 30min 到期重建和代理层杀连接之间「赛跑」,结果代理层赢了一部分。maxLifetime必须严格小于中间层最小 idle 超时,不能等于。 -
isValid(1)不发探测包。MySQL Connector/J 的isValid()只查本地isClosed(),对「静默死」连接返回 true。如果你以为「配了validationTimeout就能兜底」,那是错的——validationTimeout只是给isValid()设的超时上限,不改变「查本地状态」这个行为。 -
keepaliveTime默认 0 = 关闭。HikariCP 5.0 才引入此参数,默认不启用。很多人升级了 HikariCP 版本但没意识到这个默认值,导致「明明配了连接池,连接还是被杀」。 -
isClosed()对 FIN 已到、RST 未到、NAT 静默丢弃三种情况返回值不同。FIN 已到 →isClosed()=true,Hikari 能识别;RST 已到 →isClosed()=true,同理;NAT 静默丢弃 →isClosed()=false,识别不出来。这就是为什么「借出成功」不等于「连接可用」。 -
connectionTestQuery是反模式。HikariCP 文档明确说「除非你的 JDBC 驱动不支持isValid(),否则不要配」。MySQL Connector/J 支持isValid(),配SELECT 1反而绕过了快速路径,且SELECT 1在代理层会被路由到只读实例,行为不确定。 -
minimumIdle < maximumPoolSize在低峰服务上是定时炸弹。低峰缩池后,高峰第一波请求要重建连接,叠加滚动发布窗口,连接建立数尖刺,RDS 侧Aborted_connects上涨。固定池大小(minimumIdle = maximumPoolSize)是更稳的选择。
五、结论与可复用清单
一句话结论:HikariCP 的借出检查只查本地 socket 状态,对「NAT / 代理层静默丢弃」无能为力;maxLifetime 和 keepaliveTime 是唯一的主动预防手段,取值必须严格小于中间层最小 idle 超时。
可复用清单
- [ ] 查 RDS / 代理层 / NAT 的 idle 超时,取最小值,
maxLifetime设为该值的 80% 以下; - [ ]
keepaliveTime设为maxLifetime的 1/5(至少 60s),且小于中间层 idle 超时; - [ ]
minimumIdle = maximumPoolSize,固定池大小; - [ ] 不用
connectionTestQuery,依赖isValid(1)快速路径; - [ ] 监控
hikaricp_connections_timeout(>0 立即告警)和 RDSAborted_clients/Aborted_connects速率; - [ ] 滚动发布分批(
maxUnavailable: 1),监控连接建立数峰值; - [ ] 发布前 checklist:代理层 idle 超时、
maxLifetime、keepaliveTime三者关系确认。
一句话记忆
FIN 能查出来,RST 能查出来,NAT 静默丢包查不出来——对后一种,只有
keepaliveTime的COM_PING能救。