
先交代一下事故背景。上周三凌晨订单中心一台应用实例突然开始刷屏式打印日志关键词就是那条我们都不愿见到的 Hikari WARNConnection leak detected。紧接着业务告警群炸了——“订单查询接口 RT 飙升”“数据库活跃连接数打满”“部分消息积压”。当晚值班的同学一边翻日志一边挠头连接池不是会自动把连接收回去吗怎么还会泄漏这篇文章是那次事故的完整复盘。我会从 HikariCP 连接池的工作原理讲起拆解连接泄漏的根因、排查过程、修复方案以及后续团队沉淀的防泄漏体系。如果你也维护着基于 Spring Boot MySQL 的数据库连接池应用这篇文章应该能帮你少踩几个大坑。1. 事故现场连接池报警的深夜1.1 现象从一条 WARN 日志开始凌晨 00:12监控平台弹出异常。日志里滚动着类似的输出WARN [HikariPool-1 housekeeper] com.zaxxer.hikari.pool.ProxyLeakTask - Connection leak detected. There are 15 outstanding connections. There were 32 connections used in the last 29 seconds. The last stack traces are as follows: at com.order.service.BatchOrderService.processBatch(BatchOrderService.java:88) at com.order.service.BatchOrderService.lambda$runBatch$2(BatchOrderService.java:95) ...我说一下第一反应先看业务是否真的受影响再看是否有明显的代码异常。当时我们有两个判断一是连接池短暂拥挤导致的偶发告警二是真的有连接只借不还。监控面板上的active connections已经稳定在maximum-pool-size上限附近pending connections持续大于 0说明已经有线程在排队等连接了。这基本确认不是偶发。1.2 影响面一条警告能把整个服务拖垮连接池一旦被打满后果是链式放大的。新请求到数据库时拿不到连接会一直阻塞在connectionTimeout内如果超过 30 秒还没等到连接就会抛SQLTransientConnectionException。前端拿到的就是 500 或者超时。更麻烦的是已持有连接的线程因为下游接口变慢迟迟不归还连接形成恶性循环。我们的订单查询接口平时 P99 在 80ms 以内当时直接飙到 3 秒以上。好在这个服务不是强一致核心链路通过熔断和降级把影响控制住了没有产生用户资金损失但已经足够敲响警钟。1.3 背景交代这次事故为什么会发生复盘前先交代一下系统背景。Spring Boot 2.7 项目默认数据源就是 HikariCP也就是大家常说的 MySQL 数据库连接池。服务部署了 6 个节点每个节点maximum-pool-size25总连接数 150。按平时的 QPS 来说这个池子绰绰有余。那为什么一次批量任务就能把池子打满问题就出在那条Connection leak detected日志对应的代码路径上。我们在排查过程中反复确认了一个事实HikariCP 的泄漏检测机制并不是它能自动回收连接它只是报警器真正让连接回归池子的只能是业务代码。2. 原理拆解HikariCP 连接池的借、还与泄漏2.1 先搞清楚连接池在池子里干了什么连接池本质上是一个资源管理器。应用程序要操作数据库时不再每次新建 TCP 连接而是从池子里“借”一个现成的Connection用完“还”回去。HikariCP 的设计目标就是让这个借还过程足够快。它内部维护了一个ConcurrentBag来存放连接。借出时从 bag 里取一个空闲连接并标记为在用归还时把连接放回 bag。如果池子里没有空闲连接并且当前连接数还没达到maximumPoolSize它就会新建连接。如果已经达到上限那么请求线程就要进入等待队列直到connectionTimeout超时。这里有一个很容易被忽略的细节连接池不负责强制收回连接。连接借用关系是“信任”制的池子认为你在事务或者业务逻辑执行完之后一定会调用connection.close()。如果你不调用池子只是觉得“这个连接还在用”它不会去抢因为线程是不是真的还在执行 SQL池子根本无法判断。用图书馆借书来类比HikariCP 是书架管理员连接就是书。你借了书不还管理员只知道书“不在馆”但他没有办法从你手上把书抢回来。直到其他读者来借书发现没有书可借管理员才开始焦虑。这个类比唯一不同的是管理员至少能查到是谁借走了书——这就是泄漏检测的作用。2.2 HikariCP 是怎么发现“连接泄漏”的HikariCP 的leakDetectionThreshold参数默认值是 0也就是关闭泄漏检测。开启后它会在连接借出时启动一个延时的LeakTask如果连接在leakDetectionThreshold毫秒内没有被归还就会通过 HouseKeeper 线程输出一条 WARN 日志。那条 WARN 日志正是我们在事故现场看到的Connection leak detected它后面跟着的堆栈就是连接借出时的调用位置。需要特别强调的是HikariCP 只负责报告不负责回收。它不会因为检测到泄漏就把连接强制关掉或归还。被泄漏的连接要么等业务代码自己归还要么等连接的maxLifetime到了之后被池子清理。这意味着如果你依赖 HikariCP 自动解决泄漏那问题只会越积越深。泄漏的连接占着池子名额新连接不断创建直到数据库端最大连接数也被打满那就不是应用层的故障而是数据库整体不可用的问题了。2.3 leakDetectionThreshold 到底应该怎么配置配置这个参数的关键是“阈值必须大于正常业务中最慢的 SQL 或事务耗时”。如果设置得过小比如 2000ms而某条查询因为慢 SQL 执行了 5 秒HikariCP 就会误报泄漏。这样白天日志里全是噪音反而掩盖了真正的泄漏告警。业内常见的做法是先给一个比较大的保险值比如 60000ms。跑一段时间观察业务高峰期的慢请求耗时再逐步下调。如果你们业务最短的事务要 3 秒慢查询要 8 秒那 30000ms 是一个相对安全的起调点。我见过有些团队直接把leak-detection-threshold设成 10000ms结果慢查询一多就疯狂刷告警最后不得不又调大。我当时的排查第一步就是把这个参数从默认 0 改成 60000先让告警能出来再逐步收敛。这个动作本身不修复任何问题但是有了堆栈日志排查才能从“大海捞针”变成“按图索骥”。3. 根因定位两只手才掐住泄漏点3.1 第一只手代码走查发现的资源管理漏洞开启泄漏检测后下一次批量任务执行时很快就抓到了堆栈。日志里的位置是BatchOrderService.processBatch第 88 行。走查代码时我们看到了一个很典型的错误——老式 JDBC 场景下存在两条异常分支没有关闭连接。当时代码的简化版本长这样public void processBatch(ListString orderIds) { Connection conn null; Statement stmt null; ResultSet rs null; try { conn dataSource.getConnection(); stmt conn.createStatement(); for (String orderId : orderIds) { rs stmt.executeQuery(SELECT ... WHERE order_id orderId ); // 处理结果集逻辑 if (rs.next()) { // 更新状态的逻辑 stmt.executeUpdate(UPDATE t_order SET status2 WHERE ...); } } } catch (SQLException e) { log.error(batch process error, e); // 这里没有关闭连接也没有回滚 } finally { // 正常情况下会关闭 // 但异常分支提前 return 时finally 并没有覆盖到所有情况 } }这类代码的最大问题是一个连接在单次批量任务内执行了大量 SQL其中任何一条语句抛异常就可能让conn无法走到finally的关闭逻辑。更不要说代码里还有多个return分支。你只有逐个分支排查才能发现有几个地方把关闭逻辑漏掉了。这种手写 JDBC 的祖传代码在存量项目里非常常见。平时流量低、任务频率低泄漏的连接数量不明显一旦批量任务周期性触发或者并发量上来了池子就会被一点一点吃干。这次事故里单个泄漏点并不致命致命的是异步调度把同一个批量任务在短时间内重复触发了很多次。3.2 第二只手异步线程与事务的纠缠如果说手写 JDBC 是明面上的漏洞那异步线程与事务的纠缠就是暗地里的放大器。在排查过程中我们发现这个批量任务是通过CompletableFuture.runAsync提交到自定义线程池执行的。任务内部有一段逻辑被Transactional注解修饰但它不在主线程调用而是在异步回调里通过一个注入的 Service 对象触发事务。Spring 的事务管理基于ThreadLocal事务上下文绑定在当前线程上。你在主线程开启的事务子线程是感知不到的。反过来子线程里直接调用一个Transactional方法Spring 会在子线程里重新开启一个新事务这个新事务会从 Hikari 连接池借出另一个连接。问题出现在异常处理上。异步回调里的代码这么写的CompletableFuture.runAsync(() - { try { orderStateService.markOrderState(orderId); pushService.notifyWarehouse(orderId); } catch (Exception e) { log.error(async task failed, orderId{}, orderId, e); // 轻描淡写地吞掉了异常 } }, bizThreadPool);如果markOrderState抛出了 RuntimeException事务会被 Spring 标记为 rollback-only异常向上抛到catch时被记录日志后“消化”了。事务管理器在方法抛出异常后会尝试回滚并释放连接但回调线程已经“认为”任务结束了继续往后走。这里最诡异的是如果事务管理器回滚过程中依赖的Connection已经处于异常状态或者因为网络超时导致回滚失败连接就会滞留在未归还状态。更常见的场景是事务方法内部自己 catch 了异常没有继续向上抛Spring 认为事务正常提交但连接在提交时才发现已经失效抛出异常后没有走归还逻辑。这种问题反应在连接池上就是active connections不断增长但idle connections始终为 0。所以说异步线程和 Transactional 是天然的“连接泄漏培养皿”。不是说你不能在子线程里用事务而是你需要非常清楚事务边界在哪里异常发生后连接归还的路径是什么。3.3 其他高压场景下常见的泄漏姿势除了我们踩到的两个坑基于 MySQL 的数据库连接池在线上还容易出现下面几类泄漏有些我早年也遇过流式查询忘记关闭 ResultSet。MySQL 的useCursorFetch模式下ResultSet 会持续占用连接直到结果集被完全读取并关闭。如果只关闭了 Statement没有关闭 ResultSet连接一样回不去。多数据源切换时事务管理器配置错乱。Transactional没有显式指定transactionManager而 Spring 容器里又存在多个PlatformTransactionManager事务可能走错数据源连接借来借去就丢了。动态代理与字节码增强的边界问题。某些旧版本 ORM 框架或自制 AOP 切面在方法返回后才创建代理导致真正执行 SQL 时拿到的是未被代理包裹的裸连接关闭逻辑完全失效。第三方连接池工具混用。应用中同时存在 Druid 和 Hikari或者自己封装了一个连接工具类和 Spring 的 DataSource 互相嵌套连接被复制了一份引用关闭时只关了一个副本。这些都不是“连接池自身缺陷”而是资源管理责任混乱导致的。你要记住一点连接池只管理池子里的连接业务代码没把连接交还给池子池子就永远认为连接还被占用。4. 排查实录从日志到证据链4.1 开启泄漏检测让问题现形我们当时的排查是有踩坑的。一开始只看监控发现连接池使用率到了 80% 就开始紧张但并不知道泄漏发生在哪条代码路径上。后来想确认Connection leak detected是不是周期性出现就把leakDetectionThreshold从 0 改成了 30000同时把日志级别调到 WARN。漏检不靠猜靠日志。修改配置后我们在二十分钟内等到了第一条新告警。堆栈明确指向BatchOrderService.processBatch。这一步的价值非常大它把排查范围从“整个订单服务”缩小到了“一个批量任务类”。给一个建议线上开启leakDetectionThreshold时不要直接上很小的值。先 60000跑一个业务高峰周期确认没有误报再调到 30000。等告警清晰了再让它长期保持开启。有些团队担心日志量太大会影响性能其实leakDetectionThreshold的检查是 housekeeper 线程周期性做的不是每条 SQL 都检查性能开销可以忽略。4.2 线程 Dump 与指标交叉定位拿到了代码位置还不够我们还要确认泄漏时的现场线程状态。用jstack连续抓了三次线程 Dump间隔 10 秒。截图里的关键信息是batch-pool-3-thread-6处于WAITING状态堆栈顶部停在java.lang.Object.wait再往下能看到SQLServerConnection相关调用。虽然线程处于等待但它持有的Connection并没有被释放。结合 HikariCP 的active connections数量可以推断这条线程在执行完 SQL 后因为某个锁等待或者远程调用阻塞迟迟没有走到连接关闭逻辑。线程 Dump 是一个非常有用的确认工具。它不能直接告诉你“连接泄漏了”但它能告诉你“哪些线程长时间没有归还连接”。把堆栈和 Hikari 的 WARN 日志放到一起证据链就完整了。4.3 SQL 与代码逐行对账确认泄漏现场定位到BatchOrderService后我们做了两件事。第一通过慢 SQL 日志和数据库端SHOW PROCESSLIST查看这条连接最后一次执行的 SQL。第二把processBatch代码一行一行过把所有可能的return、throw、catch分支全列出来。这一步也发现了一个容易被忽略的点processBatch在处理过程中调用了远程 RPC 接口checkInventory。这个 RPC 没有设置超时时间最坏情况下会阻塞 2 分钟。也就是说即使代码没有漏写close连接也会被白白占用 2 分钟。在泄漏量不大的时候这种长时间占用只是会让池子看起来“水位偏高”并不会立刻打满。但叠加真正的泄漏漏洞就变成了压垮池子的最后一根稻草。排查结果汇总下来泄漏主因是原生 JDBC 的关闭逻辑不够健壮放大器是异步任务里的事务异常被吞帮凶是 RPC 调用没有超时。三件事凑在一起直接让连接池翻了车。5. 修复方案止血、补漏、加固5.1 代码修复把连接管理收口到统一模板批量任务的数据库操作必须改造。我们做的第一件事是废弃那段手写 JDBC改用 Spring 的JdbcTemplate。JdbcTemplate本身会管理连接和关闭资源异常时也保证连接归还帮我们省掉大量重复代码。改造后的核心逻辑是这样Autowired private JdbcTemplate jdbcTemplate; public void processBatch(ListString orderIds) { for (String orderId : orderIds) { // 单条执行异常时单独记录避免一条失败拖垮整个批次 try { jdbcTemplate.update(UPDATE t_order SET status ? WHERE order_id ?, 2, orderId); } catch (DataAccessException e) { log.error(batch update error, orderId{}, orderId, e); } } }对于异步回调里的Transactional我们换成了编程式事务TransactionTemplate。这样事务的提交、回滚、异常处理都在一个显式代码块里业务开发一眼就能看到边界Autowired private TransactionTemplate transactionTemplate; public void markOrderState(String orderId) { transactionTemplate.executeWithoutResult(status - { orderStateDao.updateStatus(orderId, 2); }); }TransactionTemplate的execute方法内部有完整的try/finally逻辑事务提交失败或发生异常时会主动回滚并释放连接不存在“异常被吞导致连接不归还”的问题。这也是我们后来在团队内推行的标准写法。5.2 参数调优给连接池留出缓冲和熔断空间修复代码后我们重新梳理了 HikariCP 的配置参数。在 MySQL 场景下连接池参数不是一个固定值而是要配合业务并发模型来设置。我们最终确认的参数如下spring: datasource: hikari: pool-name: OrderServiceHikariPool minimum-idle: 10 maximum-pool-size: 25 idle-timeout: 300000 max-lifetime: 600000 connection-timeout: 30000 validation-timeout: 5000 leak-detection-threshold: 30000解释一下几个关键参数maximum-pool-size25是结合了节点数、数据库最大连接数和业务 QPS 算出来的。总连接数 150不超过 MySQL 的max_connections的一半给数据库和其他应用留有余量。leak-detection-threshold30000是我们压测后定的值。正常业务最慢 SQL 不超过 5 秒远程 RPC 最长 10 秒30 秒的阈值既能避免误报又能及时暴露问题。max-lifetime600000是 10 分钟比 MySQL 默认的wait_timeout8 小时短得多。这是为了防止 MySQL 服务端悄然断开空闲连接后应用还拿着一个半死的连接做 SQL。connection-timeout30000是获取连接的最大等待时间。超过这个时间会直接抛出异常避免线程无限期阻塞在池子上。为什么要强调连接池参数要调“为了性能”其实是次要的真正的目的是让问题尽早暴露并且把故障范围控制住。如果connectionTimeout设得过大请求线程会长时间阻塞故障从数据库连接池蔓延到应用线程池最终造成整机雪崩。5.3 验证回归从压测到灰度上线的复查流程代码改完不能直接全量上线。我们走了一套验证流程先恢复了数据库连接池的活动连接数确保没有历史遗留的泄漏连接。然后压测。压测分两层一是单接口的数据库查询压测确认连接池可以稳定维持在一个水位二是全链路压测用实际业务流量混合异步批量任务观察active connections是否出现持续上涨。压测过程中我们故意在测试环境模拟了下游 RPC 慢响应检查异步任务在异常场景下是否有新泄漏。确认修复后再灰度一台实例跑 24 小时观察 Hikari 监控指标没有异常爬升才逐步扩大到全量。这里面有一个经验验证连接池修复是否有效不能只看功能是否正常必须看连接数的稳态曲线。如果修复后active connections在任何负载下都不会无限增长说明池子的借还达到平衡了。6. 防泄漏体系让连接池事故不再重演6.1 监控告警必须盯住这四个指标靠人肉盯日志不现实。我们需要把连接池的关键指标接入监控并设置合适的告警阈值。HikariCP 配合 Spring Boot Actuator 可以暴露以下指标指标名含义健康阈值说明hikaricp.connections.active当前借出的连接数active max × 0.8连续 5 分钟超过阈值触发告警hikaricp.connections.idle当前空闲连接数idle 0长期为 0 说明池子被榨干hikaricp.connections.pending等待获取连接的排队线程数应为 0出现持续排队要立刻定位hikaricp.connections.timeout获取连接超时的累计次数应为 0任何一次 timeout 都要查原因这四个指标组合起来能帮你在泄漏还没造成大范围故障前发现苗头。我们后来把pending 0持续 3 分钟作为 P1 告警电话叫醒的那种。这条规则如果能早一周上线这次事故可能根本不会变成“事故”。6.2 团队约定与代码评审规范技术手段只是防线之一人祸还需要规则来约束。这次事故之后我们团队立了几条规矩禁止在业务代码中手写Connection、Statement、ResultSet统一使用JdbcTemplate、MyBatis 或其他 ORM 框架。Transactional只允许标注在 Service 层的对外方法上不允许标注在私有方法或自调用方法上。事务方法内部不允许 catch 掉异常后不抛除非确认回滚语义不受影响。若必须 catch要使用TransactionAspectSupport.currentTransactionStatus().setRollbackOnly()显式标记回滚。异步线程中需要操作数据库时优先使用TransactionTemplate编程式事务禁止在CompletableFuture回调中依赖Transactional的隐式事务传播。所有外部 RPC 调用必须配置连接超时和读取超时避免网络故障时线程长时间阻塞、长时间占用数据库连接。代码评审时我们会专门过一遍数据库连接的获取和释放路径检查有没有提前返回、异常吞掉、资源未关闭的情况。这听起来繁琐但一个 5 分钟的人工走查很可能省下一个通宵的排查。6.3 季度化巡检主动排查而不是被动救火连接池泄漏不一定每次都像这次一样暴烈。很多时候是“缓慢泄漏”一天泄漏几百条连接池子勉强撑着直到某天流量波峰一来突然就崩了。我们现在的做法是每季度做一次连接池健康巡检。内容包括拉取线上 Hikari 的监控曲线查看active、idle、pending是否长期处于异常状态抽查线程 Dump确认没有异常持有的连接检查近期版本中与数据库操作相关的代码变更重新跑一遍代码评审在压测环境模拟“下游慢调用 批量任务高并发”观察池子的恢复能力。这个巡检其实花不了太多时间但它能把很多潜在问题消灭在萌芽状态。我个人的体会是连接池事故是典型的“平时不显山露水出事就天塌地陷”的问题与其依赖临场反应不如建立一套日常防御体系。最后再分享一个排查时学到的实用技巧。如果在线上遇到Connection leak detected但暂时无法停服务可以先调大maximum-pool-size接住流量然后逐步把老实例重启恢复池子到干净状态。重启不是根治手段但它是止血手段。等到排查完代码根因再通过灰度上线修复版本才能算彻底解决。这次事故虽然凌晨把人折腾得不轻但它给我们换来的是一整套可复用的排查方法论和工程规范。数据库连接池这个东西平时像空气一样被无视一旦出问题就是全链路的第一块多米诺骨牌。希望这篇文章能帮你提前识别自己系统里的风险点别等Connection leak detected刷屏了才后悔。