news 2026/10/1 1:21:57

HikariCP连接池泄露定位与排查实战:从告警到防复发

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
HikariCP连接池泄露定位与排查实战:从告警到防复发

凌晨两点半,你被值班群的告警叫醒,日志里躺着一行不起眼的 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: 1800000

maximum-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_timeout

hikaricp_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、连接池指标上报、告警通道这类基础设施当作第一优先级配置好,再写业务代码。数据库连接不是“用完自动消失”的资源,借了不还,迟早是要连本带利还的。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/1 1:20:43

测试用例设计方法:等价类、边界值、判定表与场景法实战

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/1 1:20:03

离谱模拟器开发指南:物理交互与性能调优实战

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/1 1:18:46

苍蝇小目标检测实战:VOC转YOLO格式并用YOLOv8训练全流程

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/1 1:18:17

YOLO山体滑坡落石检测实战:小目标+强干扰场景落地指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/1 1:18:16

TDengine实战:从MySQL迁移到时序数据库的完整指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华