
凌晨两点半你被值班群的告警叫醒日志里躺着一行不起眼的 WARNApparent 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分钟做一个必现连接泄露的 Demo2.1 最小工程与依赖准备在排查之前我强烈建议你先在本地把问题“制造”出来亲眼看到告警长什么样。这样到了线上你才能一眼认出它。新建一个最简单的 Spring Boot 工程依赖只需要 Web 和 JDBC 两样parent groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-parent/artifactId version2.7.18/version /parent dependencies dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-jdbc/artifactId /dependency dependency groupIdcom.mysql/groupId artifactIdmysql-connector-j/artifactId scoperuntime/scope /dependency /dependenciesapplication.yml里把 HikariCP 的泄漏检测阈值打开。注意这里是 Demo阈值可以设小一点比如 5 秒spring: datasource: url: jdbc:mysql://localhost:3306/test?useSSLfalseserverTimezoneAsia/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?typeleak 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和TransactionalComponent 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 暴露给 PrometheusGrafana 里配置面板。如果没有这套设施退而求其次也要在应用日志里定期打印连接池状态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、连接池指标上报、告警通道这类基础设施当作第一优先级配置好再写业务代码。数据库连接不是“用完自动消失”的资源借了不还迟早是要连本带利还的。