news 2026/10/15 6:35:43

MyBatis Cursor未关闭导致HikariCP连接池枯竭:事故复盘与预防

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
MyBatis Cursor未关闭导致HikariCP连接池枯竭:事故复盘与预防

那天下午三点十七分,我在监控大屏前眼睁睁看着 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()时,代理对象不会把底层连接真正关闭,而是把底层连接归还给池子,并标记状态为“空闲”,等待下一位使用者。

借用关系是这样的:

  1. 业务线程调用dataSource.getConnection(),从池里借出一条连接。
  2. 业务代码执行 SQL,拿到 Statement / ResultSet / Cursor,处理数据。
  3. 业务代码调用conn.close(),代理对象把物理连接归还。
  4. 如果业务代码忘了调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 先别急着重启,留证优先

回到事故当天。我按下“先不重启”的决定,立刻做了三件事:

  1. 把当前 JVM 的线程堆栈完整 dump 一份。
  2. 把 HikariCP 的各项指标曲线截图存档。
  3. 记录下数据库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兜底。

不同数据库表现还略有差异:

数据库流式查询机制未关闭时的典型表现
MySQLuseCursorFetch=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); } }

这里有两个细节非常关键:

  1. 声明顺序:SqlSession 先声明,Cursor 后声明。try-with-resources 的关闭顺序是逆序,所以执行时会先关 Cursor、再关 SqlSession,正好符合“先释放游标、再归还连接”的顺序。
  2. 用 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 个采样点 > 0WARN
timeout 计数1 分钟内新增大于等于 1P1

评审层:

我们内部把“资源释放”列进了 Code Review 的强制检查项。重点看以下场景:

  • Mapper 方法返回Cursor<T>时,调用方是否用 try-with-resources 管理。
  • 代码里出现Stream/parallelStream时,是否有明确的关闭策略。
  • 手动getConnection()的地方,是否保证所有异常路径都能执行close()。
  • 新增的导出、批处理、大报表 SQL,是否重新评估过结果集大小。

回归层:

还有一个土办法特别有效:测试环境把连接池上限调到很小,比如 5,然后跑一遍完整的回归用例。如果存在资源泄漏,正常情况下很轻松就能触发连接超时;修复彻底则一切正常。这个办法成本极低、效果极好,适合在任何新功能上线前做一遍。

5.4 大结果集导出的本分选择

最后再聊一个方向性建议:流式读取不是大结果集处理的唯一解,甚至不是首选解。

流式查询的价值在于内存可控,但代价就是连接占用时间长、资源释放要求高、事务边界复杂。如果你的业务场景是“把数据导出到文件”或者“全量同步到另一个系统”,不妨考虑下面这些替代方案:

  1. 分页查询:在 order by 稳定字段(如主键)的前提下,按页拉取。页大小控制在合理范围,内存开销可控,连接占用是短暂且连续的。
  2. 临时表/中间表:先把大结果集落入临时表或导出表,再由消费端分批读取,降低单条查询压力。
  3. 数据时间窗口:把“一次导出两百万行”拆成“按小时/按天多次导出”,每次的数据量下降几个数量级,连接占用时间也随之缩短。
  4. 异步任务 + 分片:导出任务提交到异步线程池,按分片参数并发处理,避免同步接口长时间占用连接。

流式读取依然有它的场景,但绝不是“能用就用的省内存妙招”。用之前,先评估清楚这些代价:连接能占多久?异常路径是否全部兜住?数据库驱动是否支持?运维侧是否看得到这个连接?

那次翻车之后,我把上面的自查清单打印出来贴在工位上。代价是半小时的线上事故,收获的是一套能复用的防泄漏方法论,这么算下来,也不算太亏。

最后分享一个小技巧:以后凡是写了“返回游标”“流式查询”“遍历大结果集”这类代码,提交前自己先在心里模拟一遍异常路径——如果第 N 行数据死了,刚才拿的资源还得了吗?想清楚这一个问题,能帮你躲掉大多数连接池事故。

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

Python 数据类型详解:从数字到序列的完整入门

一、Python 数据类型概述 Python 是一种动态类型语言&#xff0c;变量在使用时无需预先声明类型&#xff0c;解释器会根据赋值自动推断。理解数据类型是掌握 Python 的基础&#xff0c;本文将系统讲解 Python 中的数字类型与序列类型&#xff0c;并通过代码示例帮助读者快速上手…

作者头像 李华
网站建设 2026/10/15 6:34:57

PyTorch工程深度解析:显存、计算图与设备调度本质

1. 这不是“学PyTorch”&#xff0c;而是重建你对深度学习工程的认知很多人点开一篇叫“pytorch”的文章&#xff0c;下意识以为会看到安装命令、张量创建、nn.Module写法——就像翻一本语法手册。但现实是&#xff1a;当你在真实项目里卡在数据加载慢3倍、GPU显存突然爆掉、模…

作者头像 李华
网站建设 2026/10/15 6:34:54

PyTorch动态图实战:从数据管线到性能调优的踩坑指南

前阵子帮人排查一个深度学习项目&#xff0c;模型在训练集上的loss前两个epoch下降得挺正常&#xff0c;从第三个epoch开始突然变成NaN&#xff0c;而且每次崩溃的时机都差不多。折腾了一阵子之后&#xff0c;问题出在数据处理管线里一个谁都没注意到的角落。这类事情在我用PyT…

作者头像 李华
网站建设 2026/10/15 6:34:18

TensorFlow工程实践:从张量创建到生产部署的深度解析

1. 这不是“又一个TensorFlow教程”&#xff1a;为什么今天还要重谈这个框架很多人看到“tensorflow”四个字&#xff0c;第一反应是“哦&#xff0c;那个做AI的库”&#xff0c;然后划走。但如果你真在一线做过模型开发、部署或维护&#xff0c;就会发现&#xff1a;TensorFlo…

作者头像 李华
网站建设 2026/10/15 6:33:37

AirLLM让消费级显卡跑起几十B大模型:分层推理实战

手头只有一块显存不算充裕的消费级显卡&#xff0c;却想本地跑一个几十B参数的开源大模型&#xff0c;这在一年前听起来像痴人说梦。AirLLM就是奔着这个痛点来的&#xff1a;它用一套按层调度的大模型推理策略&#xff0c;把模型按Transformer层切分&#xff0c;前向传播算到哪…

作者头像 李华
网站建设 2026/10/15 6:33:05

大模型上下文模式实战:从滑动窗口到混合记忆的调优指南

上个月我在调一个本地知识库问答应用时翻了车&#xff1a;同样的文档、同样的提问&#xff0c;模型两次给出的答案竟然前后矛盾&#xff0c;一次说有导出功能&#xff0c;一次说完全没有。我一开始以为是检索器的问题&#xff0c;把切片大小、召回条数翻来覆去调了好几轮&#…

作者头像 李华