凌晨两点半,你被值班群的告警叫醒,日志里躺着一行不起眼的 WARN:Apparent connection leak detected。别不当回事,接下来大概率还有更狠的报错:Connection is not available, request timed out after 30000ms。我见过不止一个团队因为在流量高峰忽略这行日志,最终数据库连接池被打穿,线上接口全线超时,重启后短暂恢复,过两小时又重演。这个问题的本质很简单:连接池里被借走的连接,没有按约定归还。但定位它往往没有想象中那么容易,尤其是当你的服务架构里混着 Spring、MyBatis、异步线程、消息队列的时候。这篇文章我会从 HikariCP 连接泄露的检测原理讲起,带你在本地复现一次完整泄露,再给出线上排查的整套方法论和防复发手段,适合正在为连接池告警头疼的后端开发,也适合想做数据库连接池巡检的团队参考。
1. 问题初现:先看懂这行告警在说什么
1.1 一个让你半夜被叫醒的日志长什么样
正常情况下,HikariCP 的日志是很安静的。当你的代码里出现连接未归还,只要配置了泄漏检测阈值,日志里就会突然冒出一段带完整调用堆栈的 WARN,核心内容大概长这样:
WARN c.z.h.pool.ProxyLeakTask - Connection leak detection triggered, stack trace follows: java.lang.Exception: Apparent connection leak detected at com.zaxxer.hikari.pool.ProxyLeakTask.run(ProxyLeakTask.java:84) at java.util.concurrent.Executors$RunnableAdapter.call(Executors.java:511) ... at com.example.LeakService.leak(LeakService.java:35) at com.example.LeakService.run(LeakService.java:17) at com.example.LeakController.demo(LeakController.java:21)注意告警里的单词用的是Apparent,翻译成“表面上看起来”,这是 HikariCP 故意留的口子。它并不确定这个连接百分百泄露了,只是检测到某个连接从池里借出去的时间超过了阈值,看起来像没人还。这时候你如果太早下结论,容易被误导,因为它也可能是长事务、慢 SQL 或者连接被业务线程长时间持有的正常情况。
1.2 HikariCP 报警背后的“侦查机制”
连接池本质是一个“借书系统”。应用从池里getConnection()是借书,close()是还书,池里空闲连接就是书架上的书。HikariCP 的泄漏检测机制,就像给每本借出去的书装了一个“防盗磁条”,它在每次连接被借出的时候启动一个定时任务:
- 借出连接时,HikariCP 通过内部调度器安排一个延迟任务,延迟时间就是
leakDetectionThreshold配置的毫秒数。 - 如果连接在超时前正常归还,这个延迟任务会被取消,神不知鬼不觉。
- 如果连接一直没有归还,延迟任务触发,生成一个
TimeoutException风格的异常对象,携带当时借出连接的调用堆栈,打印到日志里。
这个设计很精妙,但有一个代价:每一个getConnection()都要在调度器里注册一个任务,高并发下有一定性能开销。所以 HikariCP 给了个默认值0,也就是默认完全关闭泄漏检测。很多人从来没在线上见过这行告警,不是代码没问题,而是根本没开启这项告警能力。
1.3 为什么这类问题特别阴险
连接泄露并不是调用一次就崩。它像水管漏水,一开始只是一滴,连接池里有大量空闲连接,完全感觉不到。直到水位慢慢升高,所有连接都被借走,下一个请求在connectionTimeout内拿不到连接,才会抛SQLTransientConnectionException。
最讨厌的是,泄露的连接往往分布在不同业务线程里,而每个线程都觉得自己的代码没问题,因为“连接是用完才离开方法的”,但现实里可能存在提前 return、异常分支忘关、线程池复用导致 ThreadLocal 残留等问题。等到接口大面积超时,你很难通过普通日志逆推到源头,因为普通日志只能告诉你“池子空了”,告诉不了你“是哪个方法把连接拿走了”。
2. 动手复现:5分钟做一个必现连接泄露的 Demo
2.1 最小工程与依赖准备
在排查之前,我强烈建议你先在本地把问题“制造”出来,亲眼看到告警长什么样。这样到了线上,你才能一眼认出它。新建一个最简单的 Spring Boot 工程,依赖只需要 Web 和 JDBC 两样:
<parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>2.7.18</version> </parent> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-jdbc</artifactId> </dependency> <dependency> <groupId>com.mysql</groupId> <artifactId>mysql-connector-j</artifactId> <scope>runtime</scope> </dependency> </dependencies>application.yml里把 HikariCP 的泄漏检测阈值打开。注意这里是 Demo,阈值可以设小一点,比如 5 秒:
spring: datasource: url: jdbc:mysql://localhost:3306/test?useSSL=false&serverTimezone=Asia/Shanghai username: root password: root hikari: pool-name: LeakDemoPool maximum-pool-size: 10 minimum-idle: 2 connection-timeout: 10000 leak-detection-threshold: 5000 max-lifetime: 1800000maximum-pool-size设为 10,是为了让演示效果更快出现。连接池越大,需要越多的请求才能把它打爆。
2.2 故意写一个忘记关连接的方法
接下来是最关键的代码。我要写一个接口,内部用原生 JDBC 获取连接,并在其中一个分支直接返回,完全不执行任何关闭操作。现实中很多泄露就是这种写法演变来的——早期的接口逻辑简单,拿到连接后顺着一条路径走到黑;后来加了一个校验分支,校验不通过就 return,连接就漏掉了。
@Service public class LeakService { private final DataSource dataSource; public LeakService(DataSource dataSource) { this.dataSource = dataSource; } public String run(String type) throws SQLException { if ("leak".equals(type)) { return leak(); } return safe(); } private String leak() throws SQLException { Connection conn = dataSource.getConnection(); Statement stmt = conn.createStatement(); ResultSet rs = stmt.executeQuery("SELECT SLEEP(1)"); if (rs.next()) { // 模拟业务条件分支:提前返回,连接未关闭 return "leak done"; } // 理论上这里也应该关闭,但 return 在前面已经结束了 rs.close(); stmt.close(); conn.close(); return "done"; } private String safe() throws SQLException { try (Connection conn = dataSource.getConnection(); Statement stmt = conn.createStatement(); ResultSet rs = stmt.executeQuery("SELECT 1")) { return rs.next() ? "ok" : "no"; } } }注意leak()方法里的return "leak done",这个 return 之前没有任何close(),也没有finally,所以连接、Statement、ResultSet 全部留在那。Java 语言的垃圾回收不会帮你关闭数据库连接,连接只能靠代码显式释放。
再配一个 Controller 暴露接口:
@RestController @RequestMapping("/leak") public class LeakController { private final LeakService leakService; public LeakController(LeakService leakService) { this.leakService = leakService; } @GetMapping("/demo") public String demo(@RequestParam(defaultValue = "leak") String type) throws SQLException { return leakService.run(type); } }2.3 从日志到连接池耗尽的全过程观察
启动应用后,直接用循环请求打个十几遍:
for i in $(seq 1 15); do curl "http://localhost:8080/leak/demo?type=leak" echo "" done大约 5 秒后,控制台会出现一段堆栈,核心指向就是LeakService.leak里的dataSource.getConnection()。这就把你从“猜哪里的问题”变成了“看证据在哪里”。
等第 11 个请求过来时,因为连接池里 10 个连接已经全部被借走,新请求会卡住等待。10 秒后connection-timeout达到上限,日志里出现另一个经典错误:
java.sql.SQLTransientConnectionException: LeakDemoPool - Connection is not available, request timed out after 10000ms.到这里,你已经完整复现了一个连接泄露从发生到爆发的全过程。把leakDetectionThreshold这段堆栈截图保存下来,它就是线上排查时最有利的线索。
3. 定位泄露:三种排查路径与实操细节
3.1 首选路径:打开泄漏检测,让堆栈自己跳出来
遇到线上连接池告警,第一反应不是去翻数据库慢查询,也不是盲目重启,而是确认服务有没有打开leakDetectionThreshold。
提示:这个参数不仅是告警开关,更是“定位开关”。打开它之后,HikariCP 会在连接借出超时时打印出当时的堆栈,告诉你连接是被谁借走的。线上建议设置为 30000ms 或 60000ms,不要低于 10000ms,否则很容易把普通的慢 SQL 误报成连接泄露。
具体配置方式有两种,Spring Boot 工程直接在配置里写就行:
spring: datasource: hikari: leak-detection-threshold: 30000如果是手动创建 HikariCP 数据源,则这样设置:
HikariConfig config = new HikariConfig(); config.setJdbcUrl(jdbcUrl); config.setUsername(username); config.setPassword(password); config.setMaximumPoolSize(50); config.setLeakDetectionThreshold(30000); HikariDataSource dataSource = new HikariDataSource(config);拿到堆栈后,很多人会犯一个错误:看到堆栈底层是 Spring 或 MyBatis 的类,就觉得不是自己的代码问题。这个认识是错的。HikariCP 的泄漏检测打印的是连接“借出位置”的堆栈,而连接可能是在框架深处被借出去的。你要做的是从堆栈的中段去找自己的业务代码,通常是一段像com.example.xxx.service.xxxService.methodName这样的内容。
3.2 辅助手段:jstack 配合线程状态分析
如果线上没有开启泄漏检测,或者堆栈被截断了,就需要通过线程快照来交叉验证。先通过jps或ps -ef找到 Java 进程 PID,然后执行:
jstack -l <pid> > /tmp/jstack_$(date +%s).log关注两类线程:
- 卡在
HikariPool.getConnection上的线程,说明它们在等待连接,是连接池耗尽的受害者。 - 那些持有连接但长时间停在业务代码里的线程,才是泄露的候选者。
怎么判断一个线程持有连接?一个常见的迹象是线程栈里出现了com.mysql.cj.jdbc.ConnectionImpl、com.zaxxer.hikari.pool.ProxyConnection这类类名,而且线程状态是TIMED_WAITING或RUNNABLE,却长时间没有进展。
jstack 只是辅助手段,它给你一个“谁在干活、谁在等待”的快照,不会直接告诉你“谁拿走了连接不还”。所以我的经验是:jstack 要连续抓三次,间隔 10 秒,对比线程栈变化。如果某个线程三次快照都停在同一个业务方法里,且该方法涉及数据库操作,那么它就极有可能就是泄露源。
3.3 数据库侧反查:processlist 定位“假 Sleep”连接
连接泄露在数据库侧也有痕迹。登录数据库执行:
SELECT id, user, host, db, command, time, state, info FROM information_schema.processlist WHERE command = 'Sleep' ORDER BY time DESC;正常情况下,连接池的空闲连接会处于Sleep状态,但time不会长时间离谱。如果你看到大量Sleep状态的连接,time 已经几十分钟甚至几小时,且数量明显超过minimum-idle的设定值,那这些多半就是泄漏出去的连接。
这里有个容易混淆的地方:Sleep时间长不一定就是泄露。连接池本来就会保活一些空闲连接,它们也会处于Sleep状态。所以要用排除法,先看连接池的minimum-idle,比如设的是 10,那么正常情况下最多应该只有 10 个左右的Sleep连接长期存在。当Sleep连接数明显超过这个值,并且还在随请求量增长,基本可以断定有连接没归还。
3.4 更细的工具:HikariCP 指标与 Arthas 实战
如果你的监控体系里已经接入了 Micrometer 或 Prometheus,可以直接看 HikariCP 暴露的指标:
hikaricp_connections_active hikaricp_connections_idle hikaricp_connections_pending hikaricp_connections_timeouthikaricp_connections_active表示当前从池中借出但未归还的连接数。如果服务处于低峰期,这个指标应该趋近于 0。如果长期大于 0,说明有人在“借书不还”。
没有监控的时候,可以用 Arthas 动态观察。Attach 到目标进程后,先看连接池状态:
vmtool -x 3 --action getInstances --className com.zaxxer.hikari.pool.HikariPool这个命令可以看到 HikariPool 对象的字段,包括totalConnections、activeConnections、idleConnections等。再配合stack java.sql.Connection close或trace业务方法,往往能抓到第一手证据。
Arthas 的局限在于它适合测试环境复现问题,生产环境要谨慎使用,尽量在低峰期操作,也不要长时间挂载。
4. 高频泄露场景与修复模板
4.1 短路返回忘记释放
最经典的低级错误,就是前面 Demo 里的写法。很多老项目中能看到这种代码:
public User findUser(String id) throws SQLException { Connection conn = dataSource.getConnection(); PreparedStatement ps = conn.prepareStatement("SELECT * FROM user WHERE id = ?"); ps.setString(1, id); ResultSet rs = ps.executeQuery(); if (!rs.next()) { return null; // 连接、语句、结果集全都没关 } User user = new User(); user.setName(rs.getString("name")); rs.close(); ps.close(); conn.close(); return user; }修复模板是加finally,或者干脆用 try-with-resources。Java 7 以后,这是最干净的写法:
public User findUser(String id) throws SQLException { String sql = "SELECT * FROM user WHERE id = ?"; try (Connection conn = dataSource.getConnection(); PreparedStatement ps = conn.prepareStatement(sql)) { ps.setString(1, id); try (ResultSet rs = ps.executeQuery()) { if (!rs.next()) { return null; } User user = new User(); user.setName(rs.getString("name")); return user; } } }这种写法的好处是,不管代码走了哪个分支,包括抛异常,连接都会被释放。我建议团队内直接禁用裸写 JDBC,所有数据访问都走 JdbcTemplate 或 ORM,从制度上杜绝这个问题。
4.2 异步线程与事务上下文错配
这个场景就隐蔽得多了。我遇到过一次线上告警,堆栈指向 Spring 的DataSourceTransactionManager.doBegin,看起来像事务管理器本身的问题。后来排查才发现,问题出在消息监听器上。
当时有这样一个监听器,方法上同时标了@Async和@Transactional:
@Component public class OrderMessageListener { @Async @Transactional public void onMessage(String orderId) { orderService.process(orderId); } }@Async会让方法在线程池里执行,@Transactional则会把事务上下文绑定到当前执行线程的 ThreadLocal 上。由于执行顺序问题,事务可能在线程池线程里开启,但提交/回滚逻辑却没有正确被执行,连接就一直挂在那个线程的 ThreadLocal 上。而异步线程池的线程是复用的,下一次任务进来又复用同一个线程,可能继续带着残留的事务上下文。
这种问题的修复方式不是去调连接池参数,而是把@Async和@Transactional拆开,让事务边界在独立的 Service 层管理,异步入口只做转发:
@Component public class OrderMessageListener { private final OrderService orderService; public OrderMessageListener(OrderService orderService) { this.orderService = orderService; } @Async public void onMessage(String orderId) { orderService.processInTransaction(orderId); } }4.3 Spring 事务代理失效引发的假泄露
还有一种情况,告警确实报出来了,但你检查代码发现连接都有关闭,逻辑上不构成泄露。这时候要考虑 Spring 事务代理失效问题。
典型场景是同类内部调用:
@Service public class OrderService { public void process(String orderId) { // 事务方法被同类内部 this 调用,事务不生效 updateStatus(orderId); } @Transactional public void updateStatus(String orderId) { // 正常业务逻辑 } }@Transactional依赖 Spring AOP 代理,只有通过代理对象调用时事务注解才会生效。同类内部this.updateStatus()绕过了代理,事务根本没开启。这种情况下连接确实不会泄露,但如果你在这个方法里手动拿到了 Connection,又没有明确释放,就很容易因为“事务上下文不存在”而出现异常和连接残留。
排查时如果怀疑这类问题,可以看业务方法有没有真正进入事务。在日志里开启 Spring 事务日志也行,但更简单的是在代码里打印或断点查看TransactionSynchronizationManager.isActualTransactionActive()的返回值。
4.4 存储过程与游标处理忘关
企业级系统里经常有调用存储过程的逻辑,比如:
public void callProcedure(String code) throws SQLException { Connection conn = dataSource.getConnection(); CallableStatement cs = conn.prepareCall("{call BATCH_PROC(?, ?)}"); cs.setString(1, code); cs.registerOutParameter(2, Types.INTEGER); cs.execute(); int result = cs.getInt(2); // 没有关闭 cs 和 conn }存储过程执行完成后,结果集已经消费完,但 Statement 和 Connection 如果不关闭,连接会被一直占用。特别是一些存储过程内部开启了事务但没提交,那连接会占得更久。
修复方式还是统一的 finally 或 try-with-resources。另外提醒一句,存储过程内部如果有临时表或游标操作,也要在存储过程内做清理,否则连接归还后数据库端资源不一定释放干净,时间久了会引发更奇怪的错误。
5. 线上治理:从救火到防火
5.1 连接池参数的正确姿势
复盘完常见场景,我想强调一个观点:连接池参数不是一成不变的模板,它要根据业务特征动态调整。下面是生产环境相对保守的配置模板,这套配置我用了挺久,实测下来比较稳。
spring: datasource: hikari: pool-name: BizPool maximum-pool-size: 50 minimum-idle: 10 idle-timeout: 300000 connection-timeout: 3000 max-lifetime: 1740000 leak-detection-threshold: 30000几个参数的考虑:
maximum-pool-size不要盲目往上加。连接越多,数据库侧负载越高,建议结合压测结果。一般每个实例 20~50 够用,过大的连接池反而会因为上下文切换和锁竞争拖慢性能。connection-timeout设为 3000ms,意思是拿不到连接最多等 3 秒。如果你的数据库响应特别慢,可以放宽到 5000ms,但别设成 30 秒,否则连接池耗尽时请求会全部堆积,线程池被打满,连锁反应更严重。max-lifetime建议比数据库的wait_timeout短,最好留 10 分钟以上的余量。比如 MySQL 的wait_timeout默认 8 小时,HikariCP 的max-lifetime设 29 分钟没问题,因为 HikariCP 默认就是 30 分钟。leak-detection-threshold一定要小于max-lifetime,否则可能出现连接已经超时但还没触发检测的尴尬情况。
5.2 监控:主动盯住几个关键指标
连接池出问题不是突然的,它会有一个积累过程。如果监控到位,完全可以在接口超时之前发现苗头。重点盯这几个指标:
| 指标 | 正常表现 | 危险信号 |
|---|---|---|
| 活跃连接数 | 和并发请求量匹配,空闲时归零 | 空闲时持续大于 0 |
| 空闲连接数 | 接近 minimum-idle | 高于 minimum-idle 且持续不降低 |
| pending 等待数 | 低峰期为 0 | 持续大于 0 |
| 连接获取超时次数 | 0 | 开始随机出现 |
| 连接创建数 | 平稳 | 频繁创建,说明连接存活异常 |
具体实现可以用 Micrometer 暴露给 Prometheus,Grafana 里配置面板。如果没有这套设施,退而求其次也要在应用日志里定期打印连接池状态:
@Scheduled(fixedDelay = 60000) public void reportPoolStatus() { HikariDataSource ds = (HikariDataSource) dataSource; HikariPoolMXBean poolMXBean = ds.getHikariPoolMXBean(); log.info("pool - active: {}, idle: {}, waiting: {}, total: {}", poolMXBean.getActiveConnections(), poolMXBean.getIdleConnections(), poolMXBean.getThreadsAwaitingConnection(), poolMXBean.getTotalConnections()); }看到waiting长期大于 0,就要准备排查了,不要等Connection is not available出来才动手。
5.3 代码层面的硬规范
跟连接泄露打交道多了,我总结了几条写进团队开发规范的硬规矩:
- 禁止在业务代码里直接注入
DataSource后手动getConnection(),除非是复用已封装的基础类。 - 手动获取连接必须使用 try-with-resources,并且
Connection、Statement、ResultSet三者都要管理。 - DAO 方法的每个 return 之前,确保所有数据库对象都进入关闭路径。
@Transactional和@Async不要标在同一个方法上,事务方法必须通过 Spring 代理调用。- 每个事务方法只做一件事,不要在里面嵌套远程调用或长时间循环,事务里拿着连接的时间越短,泄露风险越低。
代码评审的时候,检查dataSource.getConnection()和DriverManager.getConnection()的所有调用点,逐一确认关闭逻辑。听起来笨,但这个方法真的能拦住大部分低级泄露。
5.4 定期在预发环境做“红队演练”
我会建议团队每个季度做一次连接泄露演练。方法很简单,在预发环境故意注入一个泄漏洞口,比如写一个接口,内部拿连接后睡 5 分钟再返回,然后看监控告警能不能在预期时间内触发。
演练验证的是三件事:
- 泄漏检测堆栈是否清晰可读。
- 活跃连接数趋势图有没有明显爬坡。
- 告警渠道是否能正常送达,而不是告警发出来没人处理。
这套演练成本很低,但收益很高。等到线上真出了事,你不想第一次在凌晨三点学习怎么看 HikariCP 堆栈。
我个人在实际操作中的体会是,连接池问题九成以上不是 HikariCP 本身的问题,而是业务代码或使用姿势的问题。HikariCP 的泄漏检测堆栈是定位这类问题最直接、最省事的入口,关键是要先打开它。新项目上线前,我会把leakDetectionThreshold、连接池指标上报、告警通道这类基础设施当作第一优先级配置好,再写业务代码。数据库连接不是“用完自动消失”的资源,借了不还,迟早是要连本带利还的。