一个服务的连接池配置 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. 自测
- 三种泄漏形态里,哪一种有"放大器"?为什么它正好在最不该出问题的时刻发作?
- 哪两个指标画在一起能指向"异常路径泄漏"?"重启后恢复"排除什么?
- 为什么
try-with-resources是唯一推荐的手写方式?finally的问题在哪? - 流式查询的"提前 break"为什么危险?框架层的"隐性持有"有哪几种?
leakDetectionThreshold的作用是什么?maxLifetime治的是什么问题?- 判断"是否需要追连接泄漏"的那个问题是什么?