
凌晨 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: 300001.1 告警日志的形态凌晨的日志里核心告警是这两类它们交替出现WARN com.zaxxer.hikari.pool.ProxyLeakTask - Connection leak detected. The connection com.mysql.cj.jdbc.ConnectionImpl1f2e3d4 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 (total50, active50, idle0, waiting28)这里有一个非常关键的细节第一条日志里那个堆栈是 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是连接的最大存活时间默认 1800000ms30 分钟。它必须小于数据库侧的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空闲连接回收阈值需小于maxLifetimemax-lifetime18000001800000连接最大存活时间需小于数据库wait_timeoutleak-detection-threshold300000连接持有超时告警阈值建议略大于正常业务 RT3. 完整排查链路从日志到线程堆栈再到代码事故发生后的定位过程大致经历了五个阶段。这个链路对任何连接池问题都适用值得完整复现一遍。3.1 从监控确认连接池内部状态服务接入了 Micrometer 和 PrometheusHikari 的指标可以直接通过 Actuator 暴露出来。当时拉到的关键指标是hikaricp_connections_active50持续打满hikaricp_connections_idle0hikaricp_connections_pending28排队获取连接的线程数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 t88 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(ListImportItem items) { // 1. 事务外批量校验外部订单状态 ListImportItem 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保持原样这里特别说一下leakDetectionThreshold5000的意义。这个阈值必须比正常业务的最大连接持有时间大但又要小到能在问题刚发生时暴露出来。比如你们业务里有一个天然就要 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-prometheusHikari 的指标就会自动暴露到/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 detectedHikari 其实已经把问题提示得很清楚了有一个连接借出 30 秒没归还。它不会告诉你代码在哪一行但它给了你一个足够清晰的方向。再遇到类似告警我的第一反应已经变了不是去加连接数而是立刻掏出线程堆栈看看谁正举着那张连接不放手。