数据库连接池泄露的“捕猎”过程:从现象、定位到修复的完整闭环

大家好,我是33。今天想和大家分享一个在线上系统中“捕猎”数据库连接池泄露的完整经历。这不像解决一个普通的Bug,更像是一场侦探游戏——从一些模糊的异常现象开始,抽丝剥茧,最终锁定那个偷偷“吃掉”我们数据库连接的“元凶”。整个过程充满了实战的细节和踩坑的教训,希望对你有所帮助。
一、现象:平静水面下的暗流涌动
一切始于一个看似平静的下午。监控系统突然告警:某核心服务的数据库连接数持续攀升,已经逼近我们在连接池(这里用的是HikariCP)中设置的最大值(比如50个)。更诡异的是,流量并没有显著增加。
起初,我们以为是偶发的慢查询导致连接持有时间变长。但观察一段时间后,发现连接数在高位稳定不降,甚至在服务低峰期(如深夜)也维持在高位。同时,应用日志开始零星出现 `HikariPool-1 – Connection is not available, request timed out after 30000ms.` 这样的错误。用户侧反馈则是部分操作偶尔会“转圈圈”很久然后报错。
踩坑提示: 不要一看到连接数高就盲目调大 `maximumPoolSize`。这如同河道堵塞了不去疏通反而加大水流量,很可能导致数据库服务器因连接过多而崩溃,掩盖了真正的泄露问题。
二、定位:拿起我们的“侦查工具”
确认了连接池泄露的嫌疑后,我们开始系统性排查。目标是找到那些被申请了但从未被归还(`close`)的数据库连接。
1. 启用连接池的泄露检测
大多数成熟的连接池都内置了泄露检测功能。以HikariCP为例,我们可以在配置中开启它:
# application.yml 配置示例
spring:
datasource:
hikari:
leak-detection-threshold: 60000 # 单位毫秒,连接超过此时间未归还则记录警告日志
设置一个合理的阈值(略长于你应用中最长的合法查询时间)。之后,应用日志中就会开始打印出详细的泄露警告,其中会包含创建连接时的调用栈信息,这是最关键的线索!
实战经验: 这个日志通常会指向一个具体的业务方法或代码块,告诉你“疑犯”最后出现在哪里。
2. 从调用栈锁定可疑代码
我们当时看到的日志类似这样:
WARN HikariPool-1 - Connection leak detection triggered for connection ...
Stack trace of the connection‘s creation:
at com.zaxxer.hikari.pool.HikariPool.getConnection(HikariPool.java:200)
...
at <strong>com.xxx.service.OrderService.processBatchOrder(OrderService.java:123)</strong>
...
目光立刻聚焦到了 `OrderService.processBatchOrder` 这个方法。接下来,就是深入代码进行“现场勘查”。
三、修复:常见的“泄露”陷阱与填坑
来到可疑方法,我们发现了以下几种经典的问题模式:
陷阱一:在try-catch-finally中,`close`调用被绕过
这是最常见的原因。看看我们当时有问题的代码原型:
public void processBatchOrder(List<Order> orders) {
Connection conn = dataSource.getConnection();
PreparedStatement pstmt = null;
ResultSet rs = null;
try {
conn.setAutoCommit(false);
pstmt = conn.prepareStatement("SELECT ... FOR UPDATE");
// ... 复杂的业务逻辑和循环操作
conn.commit();
} catch (SQLException e) {
// 回滚写在catch里
if (conn != null) {
try { conn.rollback(); } catch (SQLException ex) { log.error(...); }
}
throw new RuntimeException("处理失败", e);
} finally {
// 问题所在:只关闭了 Statement 和 ResultSet!
try { if (rs != null) rs.close(); } catch (SQLException e) { log.error(...);}
try { if (pstmt != null) pstmt.close(); } catch (SQLException e) { log.error(...);}
// 忘记了 conn.close() !!!
}
}
修复: 确保在 `finally` 块中,无论如何都要释放连接。更佳实践是使用 `try-with-resources` 语法(Java 7+),它能自动关闭实现了 `AutoCloseable` 的资源。
public void processBatchOrder(List<Order> orders) {
// 使用 try-with-resources,确保 Connection, Statement, ResultSet 都能自动关闭
try (Connection conn = dataSource.getConnection();
PreparedStatement pstmt = conn.prepareStatement("SELECT ... FOR UPDATE")) {
conn.setAutoCommit(false);
// ... 业务逻辑
conn.commit();
} catch (SQLException e) {
// 注意:try-with-resources 会在 catch 之前自动调用 close(),
// 如果 close 抛出异常,e 会被抑制,可以通过 e.getSuppressed() 获取。
throw new RuntimeException("处理失败", e);
}
// finally 块不再需要,资源已自动管理
}
陷阱二:在@Transactional方法内手动获取连接
另一个隐蔽的坑。当方法被 `@Transactional` 注解时,Spring会通过AOP代理管理一个线程绑定的连接。如果此时又在方法内手动 `dataSource.getConnection()`,你就拿到了一个独立的新连接,这个连接完全在Spring事务管理之外,极易忘记关闭。
@Transactional
public void updateOrderWithReport(Order order) {
// 1. 此处的数据库操作由Spring管理连接(安全)
orderRepository.save(order);
// 2. 危险操作:手动获取了一个“野”连接
Connection conn = dataSource.getConnection();
try {
// 生成一些报表数据插入另一个表...
} finally {
conn.close(); // 必须手动关闭!
}
}
修复: 避免在事务方法中手动获取连接。如果必须,确保在 `finally` 中绝对关闭。更好的设计是将报表生成这类操作抽离到另一个没有 `@Transactional` 注解的服务方法中,或者使用Spring的 `TransactionTemplate` 或 `JdbcTemplate` 来帮你管理资源。
四、验证:确认“捕猎”成功
修复代码并部署后,监控是检验成果的唯一标准。
- 观察连接数曲线: 连接数应该从高位迅速回落,并随着业务流量呈现有规律的波动,在低峰期回到接近最小空闲连接数(`minimumIdle`)的水平。
- 监控泄露警告日志: 之前刷屏的泄露警告应该消失或减少到极少数(需排除误报)。
- 压力测试: 对修复后的接口进行压测,连接数应稳定在池的最大值以下,且无超时错误。
当我们看到连接数从稳定的50个下降到在5-20个之间健康波动时,就知道这次“捕猎”行动圆满成功了。
总结与心得
数据库连接池泄露的排查,是一个典型的“监控告警 -> 日志分析 -> 代码定位 -> 修复验证”的运维闭环。关键在于:
- 善用工具: 连接池的泄露检测是首选利器。
- 规范编码: 优先使用 `try-with-resources`,避免手动管理资源。
- 理解框架: 清楚Spring等框架的事务和连接管理机制,不要混用不同层面的资源获取方式。
- 持续观察: 修复后通过监控确认效果,形成闭环。
希望这次详细的“捕猎”记录,能让你在下一次面对连接池泄露时,更加从容不迫。我们下次见!


这问题太真实了,我们上周也碰到了类似的连接泄露,差点炸库。