那天下午三点十七分,我在监控大屏前眼睁睁看着 HikariCP 的 active 连接数从 4 一路冲到 20,然后卡在 20 一动也不动。紧接着,应用日志开始疯狂刷Connection is not available, request timed out after 30000ms,群里 @ 我的人从 1 个变成 10 个。整个过程不超过半小时。
后来定位到根因时我才意识到,罪魁祸首不是慢 SQL,也不是流量突增,而是一段用 Stream 遍历 MyBatis Cursor 的报表导出代码——游标没关,连接没还,连接池被活活玩没了。
这篇文章想把这次完整的翻车过程记录下来:从现象、原理、排查链路,到最终修复和预防措施。如果你是做 Java 后端的,用的恰好是 Spring Boot + MyBatis + HikariCP 这套组合,建议认真看完,尤其是涉及大结果集导出、流式查询的功能,几乎就是踩在了同一个坑边上。
1. 事故现场:监控曲线比你的直觉更诚实
1.1 上线半小时,接口先集体“迟到了”
当天下午上线的是一个“历史报表导出”功能,逻辑不复杂:从订单流水表里按时间范围捞数据,解析后写到文件再传到对象存储。数据量不小,有接近两百万行,所以实现的时候用了流式读取,避免一次性把数据全加载进内存。
上线前我们在测试环境压过,内存表现良好,耗时也在预期内。于是很自信地推到了生产。
结果半小时后,第一个告警来了:导出接口 P95 延迟从 300ms 涨到 15s 以上。我以为是任务线程池被打满,跑去看了下线程池指标,发现根本没满,反而是数据库连接池的指标不对劲:
- Active connections = 20(等于 maximumPoolSize)
- Idle connections = 0
- Threads awaiting connection 持续增长
很快,其他无关接口也开始超时。因为它们也走同一个数据源,拿不到连接,全部阻塞在getConnection()这步。整个服务像是被一只看不见的手扼住了喉咙。
1.2 第一时间能看到的三个关键信号
事故发生时,我手头同时有三个信号来源,它们分别指向不同的原因。这里我整理成了一张表,回顾时非常清晰:
| 信号 | 表现 | 我当时的第一反应 |
|---|---|---|
| 应用日志 | hikari pool timed out、Connection is not available | 连接池满了 |
| 连接池指标 | active=20、pending 线程数上涨 | 有人拿了连接没归还 |
| 数据库 | SHOW PROCESSLIST里出现大量Sleep状态的长连接 | 应用侧持有连接不释放 |
这三个信号单独拆开看都不罕见,但它们同时出现时,基本可以判定就是连接泄漏——连接借出去了,但调用方没有按约定归还。
这里我想强调一个经验:连接池耗尽时,别急着重启服务“恢复”再说。重启确实会让指标清零,但问题会在流量上来后再次出现,而且重启会把现场的线程堆栈、连接状态全冲掉,后面再想定位就难了。先留证据,再止损。
2. 先把 HikariCP 的“借还账本”看明白
2.1 一次正常的 getConnection/close 背后发生了什么
要理解为什么“游标没关”会导致连接池枯竭,得先弄清 HikariCP 的借还机制。
HikariCP 的getConnection()并不是直接把物理连接丢给你,它返回的是一个HikariProxyConnection代理对象。这个代理对象内部关联着一条真实的物理连接。当你调用conn.close()时,代理对象不会把底层连接真正关闭,而是把底层连接归还给池子,并标记状态为“空闲”,等待下一位使用者。
借用关系是这样的:
- 业务线程调用
dataSource.getConnection(),从池里借出一条连接。 - 业务代码执行 SQL,拿到 Statement / ResultSet / Cursor,处理数据。
- 业务代码调用
conn.close(),代理对象把物理连接归还。 - 如果业务代码忘了调
close(),那连接就一直处于“借出”状态。
也就是说,连接池本身并没办法主动把“借出去”的连接收回来,它只能被动等待调用方还回来。这就像图书馆借书:管理员把书登记借给你之后,你一直不还,系统里这本书的状态永远是“借出”,别人自然借不到。
2.2 池枯竭的三类常见推手
把连接池耗尽归因到同一种情况是不对的,实际操作中我见过三类不同成因:
第一类:真的不够用。流量超过了池的上限,比如 maximumPoolSize 配了 20,但瞬时并发需要 50 条连接。这种情况连接池指标会表现为 active=20、pending 涨、但数据库连接数正常。解决办法是评估并发量,适当调大池子,或者引入读写分离。
第二类:借了不还。业务代码忘关Connection/Cursor/Stream,连接被默默吞掉。这就是本文这次事故的类型。指标表现最典型:active 持续满格,数据库侧出现大量 Sleep 连接,且数量只增不减。
第三类:底层连接已经死了,但应用不知道。数据库端网络闪断、会话被 kill,连接池里的连接其实已经失效。HikariCP 默认依赖 JDBC4 的Connection.isValid()来检测,如果这个检测不及时,业务线程借到死连接后会先抛异常,再触发连接驱逐。这种情况通常伴随数据库告警,定位起来相对容易。
这次事故严格来说是第二类,只是藏得更深:代码里不是没写close(),而是写了,但没写在一个“无论如何都会执行”的位置上,所以异常路径绕了过去。
2.3 为什么池子“感觉不到”连接被占死
HikariCP 有一个特性容易让人误会:它默认不会主动去检查借出的连接是否健康。连接一旦借出,池子只负责计数,不负责监督。哪怕业务线程拿着连接去睡着了、去远程调接口了、去等锁了,池子都无感知,只是在 Pending 队列里继续等。
只有当连接归还时,HikariCP 才会通过isValid()或connectionTestQuery校验连接是否还可用。如果连接一直不归还,那这个校验永远不会发生。
流式 Cursor 恰好卡在这个盲区里:业务线程确实在处理数据,连接也确实“活着”,但它没有回到池子;而池子也没有任何机制去催促。于是一个、两个、多个请求都卡在资源未释放的状态,池子最终被耗干。
3. 从告警到根因:一次完整的现场复盘
3.1 先别急着重启,留证优先
回到事故当天。我按下“先不重启”的决定,立刻做了三件事:
- 把当前 JVM 的线程堆栈完整 dump 一份。
- 把 HikariCP 的各项指标曲线截图存档。
- 记录下数据库
SHOW PROCESSLIST的结果。
这三份证据分别对应“代码卡在哪”“连接池状态如何”“数据库视角的连接长什么样”。等到问题恢复后再回头对着看,根因会非常清晰。
如果当时手一抖重启了服务,线程堆栈全丢,就只能靠猜了。在线排查里,保留现场永远是第一优先级。
3.2 三路并查:应用层、连接池层、数据库层
我习惯把这种泄漏问题的排查拆成三层并行验证:
应用层:用jstack -l <pid>抓线程栈,重点找那些拿到了HikariProxyConnection却长时间停留在业务代码里的线程;或者卡在Connection.isValid()、Statement.execute()等位置的线程。把多个时间点的 dump 对比,确认这些线程是不是一直“活着但没干活”。
连接池层:通过 Spring Boot Actuator 暴露的指标,看hikaricp.connections.active、hikaricp.connections.pending、hikaricp.connections.timeout的变化趋势。如果 active 长期等于池上限,timeout 计数持续增加,那么“借了没还”几乎是确定的。
数据库层:执行SHOW PROCESSLIST,观察来自应用服务器 IP 的连接,重点关注Command=Sleep、Time很大的会话。这些就是被应用“借走”后没有归还的物理连接。
三层证据一拼,方向就很明确了:连接在应用侧被借出后没回来。只是还差最后一步——到底是哪段代码,哪个线程?
3.3 锁定肇事线程:堆栈里那行豁然开朗的代码
当时我用jstack抓了三份 dump,间隔 10 秒一份。grepHikariProxyConnection之后,发现好几个线程都停在一个导出任务的业务代码里,栈顶指向类似这样的调用链:
at com.example.report.ReportExportService.lambda$export$0(ReportExportService.java:37) at java.util.stream.StreamSpliterators$WrappingSpliterator.forEachRemaining(...) at org.apache.ibatis.cursor.defaults.DefaultCursor$CursorIterator.hasNext(...) at java.util.Spliterators$IteratorSpliterator.forEachRemaining(...)看到DefaultCursor和StreamSupport同时出现时,我心里基本已经有数了。这服务里用 MyBatis 的Cursor做了流式查询,然后包装成了Stream,遍历过程中业务代码抛了异常,后续的cursor.close()没有执行,连接就一直被占着。
后来把代码调出来,问题比我想的还典型。下面这一版是肇事代码的简化还原:
public void export(String startDate, String endDate) { SqlSession sqlSession = null; try { sqlSession = sqlSessionFactory.openSession(); ReportMapper mapper = sqlSession.getMapper(ReportMapper.class); Cursor<ReportRow> cursor = mapper.scanReport(startDate, endDate); StreamSupport.stream(cursor.spliterator(), false) .map(ReportRow::getPayload) .forEach(payload -> { // 这里某一行数据解析失败,抛出了异常 ossClient.put(bucket, buildKey(payload), payload); }); // 问题就在这里:上面的 forEach 一旦抛异常,这行 close 根本执行不到 cursor.close(); } catch (Exception e) { log.error("导出任务中断", e); } finally { if (sqlSession != null) { sqlSession.close(); } } }看到没?cursor.close()被放在了forEach正常结束之后,而不是finally里。只要遍历过程中任意一行数据抛异常,这条游标就不会被关闭。服务端接口被调一次失败一次,但每次失败都“吞”掉一条连接,连接池就这么一点一点被拖垮。
3.4 复现压测:用一个接口把问题定格在证据里
锁定嫌疑代码后,我没急着“修一下上线”,而是在测试环境做了复现验证。
方法很简单:把同样的导出接口用并发工具循环调用 30 次,每次请求都故意触发那行数据解析异常。然后观察连接池指标。结果非常吻合:
- 前 10 次调用,active 从 2 涨到 12;
- 第 20 次以后,active 稳定在池上限 20;
- pending 开始上涨,后续接口全部超时。
这个复现实验的价值在于:它验证了我们的推断,也为修复后的效果提供了对比基线。修复后再跑同样的 30 次,active 始终在个位数波动,异常请求结束后连接正常归还,池子恢复弹性。
4. 病根拆解:Stream/Cursor 的资源链为什么这么容易断
4.1 JDBC 资源链的“木桶理论”
先说一个很多初级开发者容易忽略的知识点:一次完整的 JDBC 查询,涉及的资源不是单个,而是一条链:
Connection(连接) -> Statement(预编译语句) -> ResultSet(结果集) -> Cursor/Stream(游标/流)它们嵌套依赖,要想不泄漏,每一层都需要被显式关闭,或者依赖外层关闭时向内层传递“关闭信号”。但在实践里,很多人只记住了其中一层:
- 有人觉得:“我关了 Connection,Statement 和 ResultSet 不就自动关了?”
- 也有人觉得:“我关了 ResultSet,连接应该就释放了吧?”
两种说法在 JDBC 规范层面各有一定道理,但连接池场景下有巨大的认知陷阱。
连接池的场景里,物理连接归池子管理,代理连接才是业务代码持有的对象。如果你只关 ResultSet、不关代理 Connection,池子永远等不到归还信号;如果你只关 Connection、但流式查询的 ResultSet/Cursor 还处于“半消费”状态,那么代理连接在归还时可能会遇到底层会话尚未释放的情况,甚至导致归还失败或连接被后续校验误判。
所以最稳妥、最不会出错的姿势只有一个:从里到外,逐个关闭;或者使用 try-with-resources 一次性收编。
4.2 流式读取如何把连接“焊死”在业务线程上
普通查询为什么很少出问题?因为普通查询的 ResultSet 是一次性拉取到客户端,遍历完或者直接close()后,连接的状态是干净、可复用的。
流式读取不一样。比如 MySQL 的流式查询需要设置useCursorFetch=true并且配合较小的fetchSize,此时驱动不会一次性把结果全部加载到内存,而是在服务端保留游标,客户端按批拉取。ResultSet 每next()一次,可能就从网络缓冲区里取一批数据。
这个机制下,连接必须保持“活跃”状态:服务端游标未关闭、未消费完的数据还在网络流里。如果代码在遍历到一半时中断,且没有正确关闭 ResultSet / Statement / Cursor,那么这个连接就会一直被“焊死”在一个未完成的状态。
更坑的是,这种连接在 HikariCP 眼里未必是“坏的”。有些驱动在连接未完成结果集消费时,isValid()依然能返回 true,因为 TCP 连接本身还活着。于是池子里的连接既不会归还,也不会被判定为失效,只能等maxLifetime兜底。
不同数据库表现还略有差异:
| 数据库 | 流式查询机制 | 未关闭时的典型表现 |
|---|---|---|
| MySQL | useCursorFetch=true+ fetchSize | 服务端临时表/游标未释放,连接一直 Sleep |
| PostgreSQL | 自动使用游标 + fetchSize | 事务内游标悬挂,连接无法回到 idle |
| Oracle | 隐式游标,ResultSet 不关则会话游标堆积 | 会话内存上涨,连接迟迟不释放 |
我这里不展开每个数据库的细节,但结论是通用的:流式查询的资源释放要求比普通查询严格得多,任何一条异常路径都可能造成“看起来连接还活着、实际上已经废了”的局面。
4.3 那段“肇事代码”还踩了另外两个隐形坑
回看肇事代码,除了cursor.close()没放对位置,还有两个隐藏问题值得拿出来讲。
第一个是Stream 使用不当。StreamSupport.stream(cursor.spliterator(), false)之后,这条 Stream 同样实现了AutoCloseable,但很多人根本不会去关它。其实如果你坚持用 Stream 处理,应该连同它一起放进资源管理里。最省心的方式是完全不要构造 Stream,直接用Cursor自身的iterator()走 for 循环,循坏结束只关一个 Cursor 就好。
第二个是finally 里只关了 SqlSession。肇事代码里sqlSession.close()是有的,但 MyBatis 的 Cursor 持有者是 SqlSession 吗?严格说,Cursor 的生命周期和 SqlSession 有关,但依赖 SqlSession.close() 去顺带关闭游标,并不是所有版本、所有场景下都可靠。尤其当游标存在未消费数据时,关闭 SqlSession 的回收路径未必能及时把底层 ResultSet 和 Statement 关干净。
所以我会建议:谁创建的资源,谁负责关闭;相邻层之间可以互相兜底,但不要互相指望。
5. 修复与预防:把“关资源”做成肌肉记忆
5.1 第一版修复:try-with-resources 收编所有资源
当时我们给导出方法改的第一版,就是老老实实把 SqlSession 和 Cursor 都放进 try-with-resources 里:
public void export(String startDate, String endDate) throws Exception { try (SqlSession sqlSession = sqlSessionFactory.openSession(); Cursor<ReportRow> cursor = sqlSession .getMapper(ReportMapper.class) .scanReport(startDate, endDate)) { Iterator<ReportRow> iterator = cursor.iterator(); while (iterator.hasNext()) { ReportRow row = iterator.next(); process(row); // 异常会在 try 块内被抛出 } } catch (Exception e) { log.error("导出失败,游标和会话已被自动关闭", e); throw new BizException("导出失败", e); } }这里有两个细节非常关键:
- 声明顺序:SqlSession 先声明,Cursor 后声明。try-with-resources 的关闭顺序是逆序,所以执行时会先关 Cursor、再关 SqlSession,正好符合“先释放游标、再归还连接”的顺序。
- 用 iterator() 代替 stream():不是不能包装成 Stream,而是 Cursor 原生迭代器已经足够,少一层封装就少一个出错的维度。真要用 Stream 也别用
.parallel(),流式查询和并行流搭配几乎没有好处,反而会加剧游标状态混乱。
如果不用 MyBatis,直接用 JDBC 原生 API,最稳的写法也差不多:
try (Connection conn = dataSource.getConnection(); PreparedStatement ps = conn.prepareStatement(sql)) { ps.setFetchSize(1000); try (ResultSet rs = ps.executeQuery()) { while (rs.next()) { process(rs); } } }同样,Connection 最先声明、最后关闭,逆序关闭的顺序天然满足 ResultSet -> Statement -> Connection。
5.2 用参数让 Hikari 自己“报告”泄漏
连接泄漏这种问题,靠人肉排查成本太高,更现实的方案是让连接池自己发声。HikariCP 里有个参数叫leakDetectionThreshold,专门干这个。
配置示例如下:
spring: datasource: hikari: maximum-pool-size: 20 minimum-idle: 5 connection-timeout: 30000 idle-timeout: 600000 max-lifetime: 1800000 leak-detection-threshold: 60000当连接被借出超过leakDetectionThreshold设置的时间(比如 60 秒)还未归还时,HikariCP 会在下次归还(或触发检测)时打印一条 WARN 日志,附带获取连接时的完整堆栈:
WARN ... HikariPool-1 - ... A connection leak was detected ... java.lang.Exception: Apparent connection leak detected at com.zaxxer.hikari.HikariDataSource.getConnection(...) at com.example.report.ReportExportService.export(ReportExportService.java:32)这一段堆栈就是定位泄漏点的最强线索。我在这次事故之后,把leak-detection-threshold配成了 60 秒。要注意这个值必须小于max-lifetime,否则会出现冲突;同时也不要设得太小,否则正常的慢事务、长报表查询会被误报成泄漏。
顺带说一句,connection-timeout也值得关注。它代表“线程等在池外拿连接”的最大时间。泄漏不解决,调大这个值只会让请求卡更久;调小则是让系统更快暴露问题。线上建议根据业务容忍度来,我自己一般习惯设 3 到 5 秒,宁可快速失败,也不要让线程无限等下去。
5.3 监控告警与发布前的自查清单
修复代码只是第一步,真正避免再次翻车,需要把事后发现变成事前拦截。我们后来做了一套组合拳:
监控层:
通过 Spring Boot Actuator 暴露 HikariCP 指标,然后接入现有监控平台:
hikaricp.connections:当前总连接数hikaricp.connections.active:活跃连接数hikaricp.connections.idle:空闲连接数hikaricp.connections.pending:等待连接线程数hikaricp.connections.timeout:获取连接超时次数
告警规则可以这么设计:
| 告警指标 | 触发条件 | 级别 |
|---|---|---|
| active 比例 | active 超过池上限的 80%,持续 1 分钟 | WARN |
| active 比例 | active 等于池上限,持续 1 分钟 | P1 |
| pending | 连续 3 个采样点 > 0 | WARN |
| timeout 计数 | 1 分钟内新增大于等于 1 | P1 |
评审层:
我们内部把“资源释放”列进了 Code Review 的强制检查项。重点看以下场景:
- Mapper 方法返回
Cursor<T>时,调用方是否用 try-with-resources 管理。 - 代码里出现
Stream/parallelStream时,是否有明确的关闭策略。 - 手动
getConnection()的地方,是否保证所有异常路径都能执行close()。 - 新增的导出、批处理、大报表 SQL,是否重新评估过结果集大小。
回归层:
还有一个土办法特别有效:测试环境把连接池上限调到很小,比如 5,然后跑一遍完整的回归用例。如果存在资源泄漏,正常情况下很轻松就能触发连接超时;修复彻底则一切正常。这个办法成本极低、效果极好,适合在任何新功能上线前做一遍。
5.4 大结果集导出的本分选择
最后再聊一个方向性建议:流式读取不是大结果集处理的唯一解,甚至不是首选解。
流式查询的价值在于内存可控,但代价就是连接占用时间长、资源释放要求高、事务边界复杂。如果你的业务场景是“把数据导出到文件”或者“全量同步到另一个系统”,不妨考虑下面这些替代方案:
- 分页查询:在 order by 稳定字段(如主键)的前提下,按页拉取。页大小控制在合理范围,内存开销可控,连接占用是短暂且连续的。
- 临时表/中间表:先把大结果集落入临时表或导出表,再由消费端分批读取,降低单条查询压力。
- 数据时间窗口:把“一次导出两百万行”拆成“按小时/按天多次导出”,每次的数据量下降几个数量级,连接占用时间也随之缩短。
- 异步任务 + 分片:导出任务提交到异步线程池,按分片参数并发处理,避免同步接口长时间占用连接。
流式读取依然有它的场景,但绝不是“能用就用的省内存妙招”。用之前,先评估清楚这些代价:连接能占多久?异常路径是否全部兜住?数据库驱动是否支持?运维侧是否看得到这个连接?
那次翻车之后,我把上面的自查清单打印出来贴在工位上。代价是半小时的线上事故,收获的是一套能复用的防泄漏方法论,这么算下来,也不算太亏。
最后分享一个小技巧:以后凡是写了“返回游标”“流式查询”“遍历大结果集”这类代码,提交前自己先在心里模拟一遍异常路径——如果第 N 行数据死了,刚才拿的资源还得了吗?想清楚这一个问题,能帮你躲掉大多数连接池事故。