☰
Hikari连接池泄漏告警排查全过程:从参数到根因的实战复盘
2026/10/6 5:24:02 网站建设 项目流程

凌晨 1 点 20 分,告警平台直接把我从睡梦里拽了起来。订单服务order-service连接池使用率 100%,接口成功率掉到 30%,日志里每隔 30 秒就刷一次Connection leak detected。点开监控面板,50 个 Hikari 连接全部处于 active 状态,还有二十多个线程在排队等待连接。作为这个服务的维护人,大半夜遇到这种事,第一反应不是慌,而是尽快把泄漏点挖出来。

这类事故在 Java 后端服务里并不罕见,但每一次的根因都不一样。有的确实是代码 forgot 关闭连接,有的则是事务被外部调用拖死,还有的是连接池参数配置不合理导致正常业务被误判。这篇文章就把这次完整的排查过程、根因分析和修复方案拆开来讲,也会把 Hikari 连接池里几个经常被搞混的参数一并说清楚,希望对正在和Connection leak detected搏斗的同行有些帮助。

1. 凌晨告警:连接池被打满时发生了什么

先还原一下事故现场。这个服务用的是 Spring Boot 2.7 + HikariCP 5.0.1,数据库是 MySQL 8.0,连接池配置大致如下:

spring: datasource: hikari: pool-name: OrderPool maximum-pool-size: 50 minimum-idle: 10 connection-timeout: 30000 idle-timeout: 600000 max-lifetime: 1800000 leak-detection-threshold: 30000

1.1 告警日志的形态

凌晨的日志里,核心告警是这两类,它们交替出现:

WARN com.zaxxer.hikari.pool.ProxyLeakTask - Connection leak detected. The connection com.mysql.cj.jdbc.ConnectionImpl@1f2e3d4 has been in use for 30000ms. at java.base/java.lang.Throwable.fillInStackTrace(Native Method)
ERROR com.zaxxer.hikari.pool.HikariPool - HikariPool-1 - Connection is not available, request timed out after 30000ms (total=50, active=50, idle=0, waiting=28)

这里有一个非常关键的细节:第一条日志里那个堆栈是 Hikari 内部为了提示"借用时间过长"而生成的占位堆栈,它并不是连接创建或借出点的完整源码堆栈。也就是说,光看这条 WARN 我们只能知道"某个连接被借了 30 秒没还",却没法直接知道是哪个业务方借的。真正要定位,必须靠后续的线程堆栈、链路追踪和代码走查。

1.2 数据库侧为什么没有慢 SQL

当时我第一个动作是登录数据库执行show processlist,结果发现数据库压力一点也不大,CPU 只有 30%,所有连接都是Sleep状态,没有一条在跑慢 SQL。这说明问题不在 SQL 本身,而是连接被借出去之后,应用侧线程并没有在执行数据库操作,而是卡在了某个非数据库的耗时环节上,比如外部 HTTP 调用、本地 IO 或锁等待。

数据库端连接已经借出但没干活,应用端却在排队等连接——这个组合基本可以判定:连接被"借走不还"或者"持有时间过长"。

2. Hikari 连接池的核心机制与参数陷阱

这次事故能快速收敛,归功于对 Hikari 几个参数的理解足够深。这里把最容易混淆的几个机制展开讲一遍,也算顺带做个知识梳理。

2.1 借出与归还的基础流程

HikariCP 本质上是一个生产消费模型。业务线程通过getConnection()从池子里借一个连接,用完之后通过close()归还。它的高性能体现在几个方面:

  • 并发队列ConcurrentBag:借出连接时不需要全局加锁,通过handoffQueue做线程间交接,减少了锁竞争。
  • FastList代替ArrayList:存放打开的Statement和ResultSet,避免迭代时的快照拷贝。
  • 字节码精简:ProxyConnection、ProxyStatement等代理类都做了极致的优化,减少每次借还的额外开销。

但再快的池子也架不住业务代码把连接长时间占着不放。

2.2 connectionTimeout 与 leakDetectionThreshold 的区别

这两个参数最容易混淆,事故发生时很多人会把它们混为一谈,但它们完全是两回事。

connectionTimeout是获取连接时的等待超时。当池子里没有空闲连接,业务线程在getConnection()上最多等多久,默认 30000ms。超过这个时间还拿不到连接,就会抛SQLTransientConnectionException,也就是上面第二条 ERROR 的由来。

leakDetectionThreshold是连接借出后的持有超时。一个连接被业务线程借走之后,如果超过这个阈值还没归还,Hikari 的后台HouseKeeping线程就会打印Connection leak detected警告。默认值是 0,表示不启用检测。

用大白话讲:前者管的是"拿不到连接怎么办",后者管的是"借出去的连接迟迟不还怎么发现"。

建议的配置思路是:

  • connectionTimeout一般保持在 30000ms 或者更短,让拿不到连接的请求快速失败,不要无限排队。
  • leakDetectionThreshold必须大于业务正常情况下的最长事务/连接持有时间,否则会天天误报。比如你们正常接口最慢 3 秒,设成 5 秒比较合理;如果正常就有 20 秒的接口,设 5 秒就是在制造恐慌。

2.3 其他容易踩坑的参数

maxLifetime是连接的最大存活时间,默认 1800000ms(30 分钟)。它必须小于数据库侧的wait_timeout,否则连接可能被数据库先断开,应用还傻乎乎地继续用。MySQL 默认wait_timeout是 8 小时,一般不会触发,但有些 DBA 会把这个值调短,一定要注意。

idleTimeout只在minimumIdle < maximumPoolSize时才生效。如果这两个值相等,Hikari 会认为你希望池子里的连接始终保持满额,不会回收空闲连接——这一点在调优时特别容易被人忽略。

minimumIdle默认等于maximumPoolSize,也就是连接池大小是"固定"的。这次事故中我配置了minimum-idle: 10,意味着高峰过后连接数会逐渐回落到 10,避免空闲连接长期占用数据库资源。

2.4 本次配置的参数速查表

参数本次配置默认值作用与建议
maximum-pool-size5010池中最大连接数,需结合并发量与 RT 估算
minimum-idle10等于 max最小空闲连接数,小于 max 时idleTimeout才生效
connection-timeout3000030000获取连接等待超时,过大会导致请求长时间排队
idle-timeout600000600000空闲连接回收阈值,需小于maxLifetime
max-lifetime18000001800000连接最大存活时间,需小于数据库wait_timeout
leak-detection-threshold300000连接持有超时告警阈值,建议略大于正常业务 RT

3. 完整排查链路:从日志到线程堆栈再到代码

事故发生后的定位过程,大致经历了五个阶段。这个链路对任何连接池问题都适用,值得完整复现一遍。

3.1 从监控确认连接池内部状态

服务接入了 Micrometer 和 Prometheus,Hikari 的指标可以直接通过 Actuator 暴露出来。当时拉到的关键指标是:

  • hikaricp_connections_active:50,持续打满
  • hikaricp_connections_idle:0
  • hikaricp_connections_pending:28,排队获取连接的线程数
  • hikaricp_connections_timeout_total:持续增加

pending > 0意味着已经有请求在getConnection()上干等了,服务正在丧失处理能力。这个时候最忌讳的是不停重启,重启只是把账本清零,根因还在代码里,过一段时间又会打满。

3.2 看数据库侧确认连接没有被执行 SQL

通过show processlist看到的连接全部是Sleep状态。这一步的意义在于排除"慢 SQL 占用连接"的可能。连接借出来了,但没有任何活动 SQL,基本可以确定持有连接的线程停在数据库之外的地方。

3.3 抓线程堆栈锁定嫌疑线程

我连续抓了三次jstack,每次间隔 5 秒,然后把 dump 文件里和连接池相关的线程全部捞出来看。

jstack 23789 > /tmp/jstack_1.txt sleep 5 jstack 23789 > /tmp/jstack_2.txt sleep 5 jstack 23789 > /tmp/jstack_3.txt

抓堆栈的要点是必须连续抓多次,因为单次快照只能看到某一瞬间的状态,连续抓取才能发现哪些线程始终停在同一位置。如果某线程在三次快照中都在同一个业务方法上,那它就是重点嫌疑。

在 dump 里,等待获取连接的线程堆栈通常长这样:

"http-nio-8080-exec-17" - Thread t@88 java.lang.Thread.State: WAITING park at jdk.internal.misc.Unsafe.park(Native Method) at java.util.concurrent.locks.LockSupport.park(LockSupport.java:194) at com.zaxxer.hikari.util.ConcurrentBag.poll(ConcurrentBag.java:151) at com.zaxxer.hikari.pool.HikariPool.getConnection(HikariPool.java:145)

而真正持有连接的线程,堆栈会停在具体的业务方法上。当时我们看到好几个线程卡在OrderImportServiceImpl.batchImport()这个方法里,并且进一步往下看,它们都停在RestTemplate.exchange()上,也就是在等一个外部 HTTP 响应。

这一步基本就锁定了方向:不是连接没归还,而是持有连接的线程被外部调用阻塞了。

3.4 链路追踪还原调用链路

服务接入了链路追踪,我根据告警时间段内的 Trace ID 筛选出耗时最高的接口,结果非常集中:POST /order/batchImport这个批量导入接口的 P99 从平时的 200ms 涨到了 12 秒以上。打开一条 Trace,调用链是这样的:

  • 进入batchImport()接口
  • 开启事务
  • 循环处理导入数据,每条数据插入前调用外部订单中台接口queryOrderInfo()校验订单状态
  • 外部中台接口耗时 10-15 秒
  • 事务一直不提交,数据库连接被线程牢牢占住

也就是说,事务把外部 RPC 调用包在了里面。数据库连接是整个事务生命周期内持有的,外部接口有多慢,连接就要被占多久。

正常情况下这个外部接口只要 100ms,但当晚对方正好发了一个有问题的版本,服务端处理异常缓慢,响应时间被拉到了 10 秒以上。几个导入请求同时进来,50 个连接瞬间就被占满,后续请求全部开始排队,30 秒后再触发connectionTimeout,整个服务开始雪崩。

3.5 额外的真泄漏:一个异常分支没有归还连接

在代码走查时还发现了另一个真正的"泄漏"点:某个手工分片插入的方法里,代码通过DataSourceUtils.getConnection()额外拿了一个连接,正常走完会 commit/rollback,但有一条异常分支只做了回滚,没有调用DataSourceUtils.releaseConnection()归还连接。这个分支不常走到,但每走到一次就丢一个连接,日积月累池子就少了几个连接。

标准写法其实应该是这样:

Connection conn = DataSourceUtils.getConnection(dataSource); try { // 业务逻辑 conn.commit(); } catch (Exception e) { conn.rollback(); throw e; } finally { DataSourceUtils.releaseConnection(conn, dataSource); }

或者更优雅的 Java 7 写法:

try (Connection conn = DataSourceUtils.getConnection(dataSource)) { // 业务逻辑 }

这里有个小细节要注意:如果用的是 Spring 管理的事务,DataSourceUtils.getConnection()拿到的连接和事务是绑定的,close()并不会真正关闭连接,只是把参与事务的计数减一,所以在finally里同样要调用releaseConnection,确保事务同步器里的引用被正确释放。

4. 根因修复与验证:从应急止血到代码落地的完整过程

定位清楚之后,修复反而显得没那么多悬念了。但要注意顺序:先把线上止血,再做代码修复,最后调整参数并压测验证。

4.1 短期应急:重启与服务降级

凌晨的事情,最重要的不是立刻改进代码,而是先让服务恢复。当时做了三步:

  • 重启订单服务,清空被占死的连接池状态。
  • 在网关层面把批量导入接口暂时限流,避免再次冲垮。
  • 把外部中台的调用改成异步消息,不阻塞主流程。

这几个动作做完之后,连接池使用率在几分钟内回落到 10% 左右,接口成功率恢复。但所有人都知道,这只是把症状压下去了。

4.2 代码修复的核心:把外部调用移出事务

长期修复的第一件事,是把外部中台接口的调用从事务里挪出去。业务上完全不需要在事务内实时校验对方订单状态,完全可以在进入事务之前先把数据校验好,然后只对本地库做写入。

改造后的逻辑大概是:

public void batchImport(List<ImportItem> items) { // 1. 事务外批量校验外部订单状态 List<ImportItem> validated = validateWithExternal(items); // 2. 事务内只做本地写库 transactionTemplate.executeWithoutResult(status -> { for (ImportItem item : validated) { orderMapper.insert(item); } }); }

这样事务的持有时间就只跟本地数据库写入耗时相关,外部接口再慢也不会占用连接池里的连接。

4.3 对外部调用显式设置超时

第二个修复是给外部调用加上超时时间。之前的RestTemplate是直接把超时时间放在配置文件里的,看似配了,实际上用的是默认值,也就是无限等待。改成显式构造:

@Bean public RestTemplate orderCenterRestTemplate() { SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory(); factory.setConnectTimeout(1000); factory.setReadTimeout(2000); return new RestTemplate(factory); }

核心原则很简单:外部调用绝对不能无限等待。读超时 2 秒,连接超时 1 秒,快速失败,让服务自己保护自己。

4.4 修正 Hikari 参数配置

这次事故之后,我把 Hikari 的参数做了调整,核心变化如下:

参数修改前修改后修改理由
leak-detection-threshold30000500030 秒太长,等到告警已经是灾难性局面;业务正常事务 P99 约 2 秒,5 秒足够敏感又不至于误报
connection-timeout300005000拿不到连接 5 秒就快速失败,避免请求积压拖垮整个服务
maximum-pool-size5050修复外部阻塞后,50 连接足够日常流量,不需要无脑加大
minimum-idle1010保持原样

这里特别说一下leakDetectionThreshold=5000的意义。这个阈值必须比正常业务的最大连接持有时间大,但又要小到能在问题刚发生时暴露出来。比如你们业务里有一个天然就要 8 秒的复杂查询,那设 5 秒就是在给自己找麻烦,这种场景宁可设成 10000ms。关键是要对业务耗时基线有数。

4.5 验证过程

修复发布之后,我做了三层验证:

第一层是线上观察。连续观察一周,连接池 active 峰值回落到 15 个以内,Connection leak detected不再出现,接口成功率恢复到 99.9% 以上。

第二层是有意模拟。我把外部中台接口的响应时间人为放大到 10 秒,模拟它再次故障的情况,结果批量导入接口在 2 秒读超时之后快速失败,本地事务正常回滚,连接池没有任何被打满的迹象,其他接口完全不受影响。

第三层是回归检查。跑了一遍全量功能测试,确认事务内和事务外的数据一致性没有问题。

5. 沉淀下来的连接池排障经验与预防清单

事故复盘最有价值的产出,是一套可以复用的事故排查方法和预防机制。这里把这次沉淀下来的经验做个系统性总结。

5.1 提前发现连接池风险的关键指标

不要等到Connection leak detected刷屏才关注连接池,日常就应当把 Hikari 的指标纳入监控,重点盯三件事:

指标含义预警建议
hikaricp_connections_active当前活跃连接数持续超过最大连接数的 80% 并维持超过 1 分钟
hikaricp_connections_pending正在等待连接的线程数大于 0 就要注意,大于 10 要立刻介入
hikaricp_connections_timeout_total获取连接超时次数只要递增就是严重异常,说明有连接没按时归还

这些指标在 Spring Boot 里只要引入了micrometer-registry-prometheus,Hikari 的指标就会自动暴露到/actuator/prometheus,不需要写任何代码,纯配置就能接进来。

5.2 线程堆栈的实用抓取技巧

jstack是解决连接池问题最趁手的工具之一,但新手经常会犯两个错:

第一,只抓一次。单次快照有随机性,至少要连续抓三次,间隔 5 到 10 秒。如果一个线程三次都停在RestTemplate.exchange(),基本可以断定它就是罪魁祸首;如果只是偶尔出现,那可能是正常的偶发慢请求。

第二,不知道抓完之后看什么。这里分享一个实用命令组合:

grep -A 20 "HikariPool" /tmp/jstack_1.txt | grep java.lang.Thread.State

先快速统计所有线程的状态分布,再找那些BLOCKED、WAITING状态的线程,逐个看完整堆栈。持有连接的线程一般不会处于连接池相关的等待上,它会停在业务代码的某一个调用点,一眼就能和排队等连接的线程区分开。

5.3 连接泄漏的常见模式

结合我在多个项目里见到的坑,连接池泄漏大致有这么几类高发模式:

泄漏模式典型表现修复思路
查询结果未关闭 Statement/ResultSet单独看每个泄漏量小,但调用量大时不断累积统一用try-with-resources,小心嵌套关闭顺序
事务内做外部 RPC/IO 调用连接建立后长时间不归还,Sleep连接暴增把耗时操作移出事务,或设置外部调用超时
分支逻辑漏掉 finally特定异常路径下连接永不归还代码审查重点检查所有getConnection()的路径
DataSourceUtils.getConnection()使用后未 release连接依附于事务同步器,长期不释放finally中调用releaseConnection
手动创建的连接放入了静态缓存应用内缓存了Connection实例禁止缓存连接,必须即借即还

5.4 写代码时的硬性约定

经过这次事故,我给团队定的规矩就三条,简单但有效:

事务方法内不允许出现非数据库 IO 调用。这里的非数据库 IO 包括 HTTP 调用、消息发送、文件读写、Redis 操作等一切可能阻塞的环节。如果有人确实需要,必须走代码评审解释清楚为什么避不开。

外部调用必须显式配置超时,永远不要依赖框架默认值。默认值消失了通常意味着无限制等待。

凡是在代码里手工通过DataSourceUtils.getConnection()拿连接,finally里必然要有releaseConnection。这一点配合代码扫描规则可以自动化,在 CI 阶段加一个自定义规则就能拦截大部分情况。

5.5 复盘过程中的个人体会

事后我把这次事故的复盘写成了团队运维手册里的一页。写完之后发现,连接池这类问题真正吓人的不是那一条 WARN 日志,而是它背后暴露出来的结构性隐患——事务边界混乱、外部依赖不加超时、异常路径缺乏兜底。

回到最初那条Connection leak detected,Hikari 其实已经把问题提示得很清楚了:有一个连接借出 30 秒没归还。它不会告诉你代码在哪一行,但它给了你一个足够清晰的方向。再遇到类似告警,我的第一反应已经变了,不是去加连接数,而是立刻掏出线程堆栈,看看谁正举着那张连接不放手。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询