2620 字
约 8 分钟
14
S08-A19 · 连接泄漏的三种形态:池子里的连接去哪了

一个服务的连接池配置 20,某天开始报"获取连接超时",重启后恢复。查了一周:代码里有三处漏了 close(),而它们只在"异常路径"上被执行——正常流量下永远不会触发。直到一次下游超时,那三条路径同时被走到,连接一次性漏光。

连接泄漏的特点是它不"持续恶化",而是"事件驱动":一次异常漏一条,而平时不漏。这个"事件驱动"特征解释了为什么它常常在"第一次真正的故障"里被发现:平时的流量路径不经过那些分支,于是脚本化测试永远是绿的。
所以监控上表现为"平时正常、故障时池子瞬间耗尽"——很难归因。


1. 三种泄漏形态

形态 触发条件
异常路径未关闭 下游报错/超时
提前 return 未关闭 校验失败、空结果
资源被缓存持有 连接被放进字段/静态集合

第一项最常见,而它有个"放大器":下游故障时异常率上升,于是泄漏速度也上升第二项(提前 return)还有个"看起来更安全"的版本:在 if 里 return result 而不关连接,因为"这里已经拿到结果了,不需要继续"——前半句对,后半句错。
——正好在最不该出问题的时刻。

第二项在"校验→查询→再校验"的写法里:前一次查询拿到连接,校验失败直接 return,连接没关。

2. 三种形态的识别信号

信号 含义
活跃连接数只增不减 泄漏(非容量不足)
获取等待时间上升而 DB 负载不高 池内可用连接少
重启后立刻恢复 状态在进程内(泄漏)
与异常率同步上升 异常路径泄漏

第四项最有用:把"活跃连接数"与"异常率"画在同一张图上,若两者同步涨,基本可以确定是异常路径泄漏。

第三项能排除"数据库侧限制":如果是 DB 的 max_connections 打满,重启应用不会恢复第四项还有个"时序"读法:异常率上升在前、连接数上升在后,说明是"异常导致泄漏";反过来则是"连接不足导致超时"——两者修法完全不同。
(因为别的实例还占着)。

// 三个数一起看(HikariCP 示例)
hikaricp_connections_active      ← 只增不减 → 泄漏
hikaricp_connections_pending     ← 等待获取的线程数
hikaricp_connections_acquire_us  ← 获取耗时(P99)
// 判据:active 接近 max 且 pending > 0,而 DB 侧连接数也接近上限

3. 代码层面的四种写法

写法 是否安全
try-with-resources 安全
try/finally + close 安全(但易漏 finally)
由框架管理(JdbcTemplate、ORM 会话) 安全
手动 getConnection() 无关闭 泄漏

第四项在现代框架里很少见,但它会以"看起来很正当"的形式出现:"我要用原生 SQL,所以自己拿连接"。

第三项(框架管理)覆盖了多数场景,但它的边界在"跨事务":一个方法里用 JdbcTemplate 查两次、中间夹了业务计算,那么两次查询各自拿连接、各自归还——这没问题;但若中间调了一次外部 HTTP,连接占用时间就被拉长。
第一项是唯一推荐的手写方式:它保证"无论正常还是异常都会关",而 finally 依赖人记得写。

// 反例:异常路径漏关闭
var conn = ds.getConnection();
var st = conn.prepareStatement(SQL);
var rs = st.executeQuery();      // 这里抛异常 → conn 永不关闭
return map(rs);

// 正例:try-with-resources(三层资源都关)
try (var conn = ds.getConnection();
     var st = conn.prepareStatement(SQL);
     var rs = st.executeQuery()) {
    return map(rs);
}

4. 框架层的"隐性持有"

场景 风险
ORM 的懒加载 + 事务外访问 会话未关
流式查询未消费完 连接被占用
批量任务持有连接跨批次 长时间占用
异步任务里用请求作用域的资源 生命周期错配

第一项与"懒加载"呼应:会话在事务结束后仍然"活着"(因为有人持有实体),于是连接不归还。第二项还有个"分页"版本:setFetchSize 不生效时会一次性读全结果,于是"流式"变成"全量",内存与连接时间同时变大。

第二项最隐蔽:流式读结果集时若提前 break,驱动可能不释放,而"提前退出"在业务里很常见(找到就走)。

// 流式查询:必须显式关闭或消费完
try (var st = conn.prepareStatement(SQL)) {
    st.setFetchSize(1000);
    try (var rs = st.executeQuery()) {
        while (rs.next()) {
            if (found(rs)) return map(rs);    // 提前返回 → rs 关闭(try 保证)
        }
    }
}
// 注意:某些驱动要求 setFetchSize 后才真正流式,否则一次读全

还有一个"跨层"的泄漏来源值得单独列:事务与连接池的关系。在"手动开事务"的写法里,begin 占一条连接、commit/rollback 才归还,而异常路径若跳过 rollback,连接就一直被那个事务占着。

// 反例:手动事务,异常时不 rollback
var conn = ds.getConnection();
conn.setAutoCommit(false);
try {
    work(conn);
    conn.commit();
} catch (Exception e) {
    // 忘了 rollback 与 close → 连接被占用直到超时
}
// 正解:用框架的 @Transactional,或 try-with-resources + rollback in catch

5. 池子的"看门狗"

手段 作用
leakDetectionThreshold 超时未归还就打印堆栈
最大存活时间 强制回收(治标)
连接测试查询 发现"死连接"
池大小上限 限制爆炸半径

第一项是最有效的诊断工具:HikariCP 的 leakDetectionThreshold=20000 会在连接被持有超过 20 秒时打印"谁拿的"堆栈,直接指向泄漏点。

第三项治的是"连接被中间设备断开":防火墙空闲超时会把连接悄悄断掉第三项(connection-test-query)的价值在于"借出前验证":它把"借到一条死连接"变成"借出前发现并重建"。代价是每次借出多一个往返——所以高频场景更常用 keepalive 而不是每次验证。
,而池子以为它还活着,于是"借出去的连接第一次用就失败"。

// HikariCP 的关键参数(诊断 + 自愈)
spring.datasource.hikari:
  maximum-pool-size: 20
  leak-detection-threshold: 20000      # 诊断:20 秒未归还就打堆栈
  max-lifetime: 1800000                # 30 分钟,短于防火墙空闲超时
  keepalive-time: 300000               # 5 分钟探活
  connection-test-query: SELECT 1

定位到"哪一行"之后,还要判断"是修代码还是修配置"。判据是"这条路径在正常流量里会不会被走到":

// 两类问题的不同修法
路径稀薄(异常/校验失败):改代码,用 try-with-resources
路径常走(高频查询):查"是否占用过久",可能是查询慢而非泄漏
// 反例:把所有问题都当"池太小" → 调大池后泄漏变慢,故障规模变大

6. 排错:五种"连接不够用"

现象 成因 处理方向
平时正常、故障时耗尽 异常路径泄漏 leakDetection 定位
重启就好 进程内泄漏 同上
借出连接首次使用失败 连接被中间设备断开 maxLifetime + 探活
池满但 DB 空闲 应用侧泄漏 看堆栈
池满且 DB 也满 池总和超 DB 上限 全局核算

第五项的解法不是"把池调小"这么简单:池调小会让单实例并发下降,于是"提高实例数"与"缩小池"要一起算。而 DB max_connections 往往还有一堆其它消费方(运维工具、报表),不全是应用。
第五项是"容量"问题而非泄漏:10 个实例 × 20 连接 = 200,而 DB 上限 150,于是"每个池都没满,但 DB 拒连"。这类问题在扩容时突然出现。

最后一个"压测"补充:常规压测通常测不出连接泄漏,因为它只走正常路径。要测出来,需要"混合失败注入"——让一部分请求的下游超时,才可能踩到那些 catch 分支。

// 三类注入(组合起来才覆盖)
1 下游超时(10% 请求)→ 触发异常路径
2 参数非法(5% 请求)→ 触发校验失败分支
3 空结果(5% 请求)→ 触发提前 return
// 观察:注入期间 active 连接数是否持续上升且不回落

7. 边界:什么情况不必追连接泄漏

场景 为什么 做法
全用 ORM/JdbcTemplate 框架管连接 不操心
无原生 SQL、无流式查询 无手写路径 不操心
短生命周期批处理 结束即释放 只加池上限
单实例开发环境 池足够大 不在开发环境调

判断口径一句话:代码里有没有"手动 getConnection 或手动开流"。最后一条与"演练"有关的边界:连接泄漏只能靠"注入异常"来验证——因为在正常路径上它不发作。所以"故意让下游超时"这类演练,是发现这类问题的唯一低成本方式。
有,就必须有 try-with-resources 与泄漏检测;全都交给框架,泄漏概率极低。


8. 自测

  1. 三种泄漏形态里,哪一种有"放大器"?为什么它正好在最不该出问题的时刻发作?
  2. 哪两个指标画在一起能指向"异常路径泄漏"?"重启后恢复"排除什么?
  3. 为什么 try-with-resources 是唯一推荐的手写方式?finally 的问题在哪?
  4. 流式查询的"提前 break"为什么危险?框架层的"隐性持有"有哪几种?
  5. leakDetectionThreshold 的作用是什么?maxLifetime 治的是什么问题?
  6. 判断"是否需要追连接泄漏"的那个问题是什么?
S08-A19 · 连接泄漏的三种形态:池子里的连接去哪了
https://www.clxhxhhr.top/posts/6543/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。