☰
HikariCP连接池耗尽的真凶:未关闭的Stream和游标
2026/10/14 7:15:15 网站建设 项目流程

线上正好好好跑着,突然一堆接口开始报错,仔细一看全是Connection is not available, request timed out。如果你是搞 Java 后端的,看到这句基本就明白——数据库连接池 HikariCP 被掏空了。我那次花了整整一个下午加半个晚上才找到真凶,最后定位到一个特别不起眼的地方:某个查询方法返回了Stream<T>,我在业务里用forEach遍历完就扔了,压根没想过它还能“扣住”一个连接不还。今天就把这个事故从头到尾复盘一遍,包括 HikariCP 池子是怎么被耗尽的过程、Stream/Cursor 和数据库游标到底什么关系、排查时用过哪些有效手段,以及最终怎么改才算彻底止血。如果你也在用 Spring Boot + JPA/MyBatis + HikariCP,这篇应该能帮你少踩一个大坑。

1. 事故现场:从第一批超时到服务半瘫痪

先说现象。那天下午 2 点 45 分左右,告警群里突然开始刷屏,先是几个订单查询接口出现 5xx,然后快速扩展到几乎所有走数据库的读写接口。看日志,异常信息基本一致:

java.sql.SQLTransientConnectionException: HikariPool-1 - Connection is not available, request timed out after 30000ms.

这句话翻译过来是:HikariCP 的请求方在连接池里等了整整 30 秒,依然没有空闲连接可用,于是直接抛出异常。30 秒是connectionTimeout的默认值,也就是说,从第一个拿不到连接的请求开始,所有后续请求都在傻等,等满 30 秒就失败。

我当时的第一反应是查数据库是不是被慢 SQL 拖垮了,马上联系 DBA,结果 DBA 那边的监控说数据库负载非常低,CPU 利用率不到 10%,活跃会话数也正常。那就不是数据库本身的问题。第二反应是看流量,是不是突然有波峰把池子打满了,结果网关数据一拉,流量和平时差不多,根本没到会打爆连接池的量级。流量不高、数据库没事、却出现连接池枯竭,剩下的最大概率就是:连接被借出去了,但一直没有还回来。也就是连接泄漏。

其实从连接池监控曲线也能看到很典型的状态:空闲连接数在几分钟内持续下降,活跃连接数稳定在一个数值上就不再回落,比如我们当时池子配的是 20 个连接,活跃数就死死卡在 20。新请求进来后,只能进入等待队列。池子一旦满,且满着的连接怎么等都不释放,后面就是雪崩——HTTP 线程也被数据库超时拖住,线程池逐渐占满,请求积压,服务基本进入半瘫痪状态。

这就是事故的全貌,很经典,也很要命。下面我详细拆一下 HikariCP 连接池的原理,以及为什么一个没关闭的 Stream 能引发这么严重的问题。

2. 连接池是怎么被“扣住”的:HikariCP 与游标的纠缠

2.1 HikariCP 的工作模型:借出与归还必须成对

先理一下连接池的基本逻辑。数据库连接的创建开销其实很大,网络握手、权限认证、内存结构初始化,一次完整连接建立可能耗时几十到几百毫秒。所以生产环境一般不用“每次请求新建连接”的方式,而是用连接池维护一组长连接。

HikariCP 的模型很简单,像个工具箱:你要用连接,就从池子里getConnection()借一把;用完了,必须调用connection.close()归还。注意这里的close()并不是真的关闭物理连接,而是把连接标记为“空闲”,放回池子里复用。如果只借不还,池子里的可用连接就会越来越少,直到耗尽。

连接池有几个关键参数和这个模型直接相关:

参数默认值含义
maximumPoolSize10池中最大连接数,包含空闲和活跃
minimumIdle等于 max池中最小空闲连接数
connectionTimeout30000ms请求连接时的最大等待时间
idleTimeout600000ms空闲连接超时回收时间
maxLifetime1800000ms连接最大存活时间,避免被数据库侧回收
leakDetectionThreshold0(关闭)连接借用超过该毫秒数未归还,则输出泄漏警告

拿我们出事故的配置来说,maximumPoolSize=20,意味着任何时候最多只有 20 个连接在干活。20 个连接一旦全部处于“活跃但没归还”的状态,第 21 个请求就只能等,等 30 秒拿不到连接,就抛SQLTransientConnectionException。

到这里,问题的关键就很清晰了:到底是什么让这 20 个连接变成了“只借不还”?

2.2 Stream/Cursor 与数据库游标:你遍历的不是内存数据

正常情况下,查询完一个列表,连接会自动归还。但如果你用 JPA 写了一个返回Stream<T>的查询方法,或者 MyBatis 里返回Cursor<T>,情况就不同了。

先看 JPA 的例子:

public interface UserRepository extends JpaRepository<User, Long> { @Query("select u from User u where u.status = :status") Stream<User> findByStatus(@Param("status") Integer status); }

当 Spring Data JPA 执行这个方法时,底层不是一次性把结果全部加载到内存,而是拿了一个数据库游标(也就是 JDBC 层面的ResultSet),一行一行地从数据库服务端拉取。这就是流式查询的语义:它允许你用很小的内存开销遍历一个很大的结果集。

但代价是:只要游标还开着,底层那个数据库连接就必须一直被占用。因为游标是“活”的,它对应着数据库服务端的一段结果快照,连接一旦还回去,游标可能就没法继续读了。

JDBC 规范里,关闭连接的顺序是这样的:Connection被关闭前,它下面的Statement和ResultSet会被自动释放。反过来,只要你没有关闭ResultSet(或者说没有关闭持有它的Stream),Connection就无法归还到池子里。

MyBatis 的Cursor<T>是同一个道理:

Cursor<User> cursor = userMapper.scanUsers(status); for (User user : cursor) { // 处理逻辑 }

这段代码表面上看只是一个普通的迭代,但Cursor底层通过SqlSession持有一个真正的数据库游标。迭代完不代表游标关闭了,只有调用cursor.close()或者关闭对应的SqlSession,连接才会回到池子里。

2.3 为什么 Stream 会被误认为是“安全的”

这里就是坑人的地方。很多人(包括当时的我)会觉得:Stream不就是 Java 8 集合操作嘛,list.stream().forEach(...)用完了就完事了,哪来的关闭一说?但这里有个关键区别:

  • 集合的stream()是基于内存数据的,数据已经在内存里了,迭代完就结束,不需要特殊处理;
  • 数据库查询返回的Stream<T>是一个“资源流”,它背后连着游标和连接,不是普通的内存流。

更要命的是,Spring Data JPA 的Stream<T>实现类实现了AutoCloseable接口,但它不会在你forEach完事之后自动调用close()。你没有显式关闭它,它就永远开着。这就像你拧开了水龙头接水,水盆满了你不再接了,但水龙头阀门还开着——水没在流,可水管和阀门之间的压力还占着,别人想接水就得等。

所以总结一句话:凡是和数据库游标绑定的Stream<T>或Cursor<T>,都必须显式关闭。这个“数据库游标绑定的连接”就是池子枯竭的最直接原因。

3. 定位与排查:从监控指标到揪出元凶

3.1 先看监控,再拍脑袋

事故发生后,我第一时间打开连接池的监控面板。当时用的方式很简单,Spring Boot Actuator 里暴露了/actuator/health和相关 metrics,配合 Micrometer 可以把 HikariCP 的指标打到监控系统里。重点看这几个指标:

  • hikaricp_connections_active:当前借出的连接数
  • hikaricp_connections_idle:当前空闲连接数
  • hikaricp_connections_pending:正在等待连接的任务数
  • hikaricp_connections:池中总连接数

故障期间我看到的曲线是:active一直等于 20,idle等于 0,pending从几十涨到几百。这说明所有连接都在“被使用中”,但迟迟没有归还。

光看监控只能确认方向是泄漏,还得定位到具体代码。我先用jstack导了一份线程堆栈,发现大量业务线程卡在:

at com.zaxxer.hikari.pool.HikariPool.getConnection at com.zaxxer.hikari.pool.HikariPool.getConnection

也就是说,业务线程在等连接,而真正持有连接的线程却没有做任何和数据库相关的操作。它们在干嘛?它们在执行业务代码,困在一个forEach循环里,有的甚至在循环里打日志。全是“看起来正常干活但永远不会释放连接”的线程。

3.2 打开 HikariCP 泄漏检测

HikariCP 自带一个非常有用的排查开关:leakDetectionThreshold。它本身默认是 0,也就是关闭状态。你可以把它设置成一个你觉得“异常时长”的阈值,比如 60 秒。当某个连接被借出超过 60 秒还没有归还时,HikariCP 会在日志里打印一条警告,并且把创建/借用这个连接时的业务代码堆栈一起打出来。

配置方式:

spring: datasource: hikari: leak-detection-threshold: 60000

设置后重启服务,等到泄漏再次发生时,日志里会出现类似这样的内容:

WARN com.zaxxer.hikari.pool.ProxyLeakTask - ...Connection leak detection triggered... Apparent connection leak detected

最关键的是堆栈信息,它会精确指出这个连接是从哪个方法借出去的。比如:

at com.xxx.mapper.UserMapper.scanUsers at com.xxx.service.UserService.processAllUsers(UserService.java:88)

看到scanUsers这个方法,我心里基本就有数了——它返回的是Cursor<User>,而我确实没关。

3.3 最小化复现实验

定位到嫌疑代码后,我没有直接改,而是先写了一个最小复现工程验证一下,确保“元凶就是它”。这个实验很简单:

  1. 建一个 Spring Boot 工程,数据源maximumPoolSize=5;
  2. 写一个 DAO 返回Stream<User>;
  3. Service 里stream.forEach(),不关闭;
  4. 并发请求接口 100 次。

跑起来后观察,前 5 个请求很正常,第 6 个请求开始报Connection is not available, request timed out after 30000ms。连接池的active数量稳定在 5,idle为 0。这和线上现象完全一致。

接着我把代码改成 try-with-resources 关闭 Stream,再跑一遍同样的并发,100 个请求全部成功,active数平均在 1 到 2 之间波动,请求结束后回落到 0。实验闭环了。

4. 解决方案:让每一滴连接都有归处

4.1 标准操作:try-with-resources 显式关闭

不管你是用 JPA 还是 MyBatis,正确写法都很固定。

JPA 返回 Stream 时的写法:

try (Stream<User> stream = userRepository.findByStatus(1)) { stream.forEach(user -> process(user)); }

这段代码里,try-with-resources会在forEach执行完后自动调用stream.close(),从而释放底层游标和连接。这是最简单、最不容易遗漏的写法。

MyBatis 返回 Cursor 时的写法要更小心一点:

try (SqlSession session = sqlSessionFactory.openSession(); Cursor<User> cursor = session.getMapper(UserMapper.class).scanUsers(status)) { for (User user : cursor) { process(user); } }

注意这里不但要关Cursor,还要关SqlSession。MyBatis 的Cursor虽然实现了Closeable,但真正持有连接的地方是SqlSession。所以两个都要放在 try-with-resources 里,顺序上先创建的SqlSession放前面,后创建的Cursor放后面,关闭时会按照逆序执行。

4.2 事务边界:别把“事务提交”当成“游标关闭”

还有一种常见误解是:我在 Service 方法上加了@Transactional,事务提交了连接总会还吧?

实际情况是:加了事务确实会让连接的生命周期拉长到事务结束,但游标的关闭时机并不保证和事务提交同步。尤其是一些查询优化器会在只读事务里使用服务端游标,事务提交后游标可能还挂着。实践中最稳妥的做法是:不要依赖事务来帮你关闭游标,该显式关闭的一定要显式关闭。

另外要小心事务范围过大。如果你在事务里处理一批数据,处理很久,即使游标关了,连接也要等到事务结束才能还。所以处理大量数据时,尽量别让大事务包住整个遍历过程。如果确实需要事务,也把事务划分到更小的单元里,比如每处理 1000 条做一次独立事务提交。

4.3 兜底配置:泄漏检测和连接池参数

修复代码之后,我把两个参数加到了所有相关服务的配置里:

spring: datasource: hikari: leak-detection-threshold: 60000 connection-timeout: 15000

leakDetectionThreshold的作用前面说过,是用来在泄漏再次发生时快速定位的。注意它有个副作用:开启后,每个连接被借出超过阈值,就会打印堆栈日志,频繁大查询也可能触发误报,所以阈值不能设得太小。一般比业务允许的最大查询耗时再大一些即可,比如 60 秒。

connectionTimeout从默认的 30 秒降到了 15 秒。这样做的目的不是修复问题,而是让系统在连接耗尽时更快失败,避免所有 HTTP 线程都傻等 30 秒,导致线程池被拖垮。快速失败比长时间阻塞好——至少后面的请求能立刻返回错误,网关可能还有重试或降级的空间。

还有一个参数值得提:maximumPoolSize不是越大越好。很多人觉得池子小了容易满,于是一口气配到 200。实际上,连接池大小和数据库吞吐量是有匹配关系的。连接数太多,数据库上下文切换开销变大,反而拖慢整体性能。比较合理的做法是根据数据库 CPU 核数和单连接处理能力估算,一般core数 * 2 + 1附近可以做一个初始值,再通过压测调整。

4.4 防御性编程:从代码规范到代码扫描

修复一个点很简单,但你要保证以后不再踩,需要把“强制性”写进开发流程里。

我在团队里定了一条规则:所有 DAO 层方法,凡是返回Stream<T>、Cursor<T>、Iterator<T>这类带有游标语义的类型,方法注释里必须写明“调用方必须使用 try-with-resources 或显式关闭”。同时在代码评审的时候,重点审查这些方法的调用方。

更进一步,可以用静态代码扫描工具把这类问题自动拦下来。比如 SonarQube 有一条规则java:S2095,提示Closeable资源没有被关闭,它能够识别Stream上的forEach后没有close的情况。把它接进 CI,出现类似代码直接让构建失败,比事后排查省事太多。

如果你用的是 MyBatis,还可以在 DAO 层做一个“统一关闭”的封装,不让Cursor直接逃逸到 Service 层。比如封装一个泛型方法:

public <T> void scanAndConsume(Consumer<Cursor<T>> consumer, SqlSession session) { try (SqlSession s = session) { // ... } }

不过这个封装方式只是把资源管理上移,底层仍然要保证关闭。最重要的是无论谁拿到这个资源,都必须在一个明确的边界里释放。

4.5 更好的选择:能不分页就别流式

从设计角度说,很多人的Stream/Cursor其实用得没必要。需求通常只是“要把这几万条数据处理一遍”,并不一定要追求流式内存优势。这种情况下,直接用分页查询更稳妥:

int pageSize = 1000; int page = 0; while (true) { Page<User> users = userRepository.findByStatus(1, PageRequest.of(page, pageSize)); if (users.isEmpty()) break; users.getContent().forEach(user -> process(user)); page++; }

分页查询每次返回一个独立结果集,JpaRepository的findAll(Pageable)方法本身就管理好了资源释放,连接用完就还。数据量不是特别大的时候(比如百万以内),这种方式的内存开销完全可接受。

真正需要流式查询的场景,通常是数据量已经到了上千万甚至上亿,这时候分页也会有深分页问题。折中方案是用“主键游标分页”:每次查询where id > lastId,只取 1000 条,处理完后更新lastId,循环下去。这样既控制了内存,也避免 Offset 深翻页的性能问题,连接的使用粒度也很小。

5. 避坑清单与实用排查速查

5.1 常见错误模式速查表

我把这类事故里最常见的几种错误写法整理成了一张表,你可以对照检查自己项目的代码。

错误模式后果正确做法
stream.forEach()后不关连接泄漏,池耗尽try-with-resources 包裹
以为事务提交会关闭游标事务内仍长时间占用连接显式关闭 Stream/Cursor
MyBatis 只关 Cursor 不关 SqlSessionSqlSession 持有连接,仍然泄漏两个都放进 try-with-resources
在循环内调用返回 Stream 的 DAO每次循环泄漏一个连接,快速耗尽先取到结果处理完,或逐段关闭
大事务 + 流式遍历连接占用时间长,池压力大缩小事务单元或使用分页
maximumPoolSize配得过大数据库被拖垮,池枯竭掩盖性能问题按核数和压测结果合理设置

5.2 快速定位连接泄漏的检查顺序

如果你也遇到连接池枯竭,我的建议排查顺序是这样的:

  1. 先看hikaricp_connections_active和idle曲线。如果active长期等于池上限且不回落,基本确定是泄漏。
  2. 打开 HikariCP 的leakDetectionThreshold,等下一次泄漏日志打出来,从堆栈里直接找业务代码。
  3. 如果着急恢复,可以在数据库侧执行:
SELECT id, user, host, db, command, time, state, info FROM information_schema.processlist WHERE command = 'Sleep' ORDER BY time DESC;

Sleep状态且持续时间很长的连接,多半就是泄漏连接。状态列里time越大越可疑。

  1. 对可疑连接对应的代码做代码审查,重点看有没有返回Stream/Cursor/ResultSet的 DAO 方法,以及这些方法调用处是否都在 finally 或 try-with-resources 里关闭了资源。
  2. 修复后,用并发测试验证连接池active数能回到基线,而不是只验证业务功能正常。

这套顺序我后来在另外两个项目上也用过,都能比较快地定位到根因,比盲目调大连接池参数靠谱得多。

5.3 我的个人心得

这次事故给我最深的教训不是“要记得关 Stream”,而是“写代码时对资源要有敬畏心”。凡是实现了AutoCloseable的东西,处理完后要么 try-with-resources,要么在 finally 里关,没有第三种选项。尤其当它藏在 Spring Data 的魔法后面时,太容易让人以为它只是个普通容器操作。

另外我也发现,代码评审阶段是最划算的控制点。一个 Stream 泄漏问题,如果能在 merge request 时被拦下来,成本就是评论一句话;如果漏到线上,成本就是一次事故回应,还要跟一堆人讲清楚“我不是没关数据库连接,我是没关 Stream”。这句话我讲了整整一个下午。

现在,我在所有项目里都加了连接池指标监控和泄漏检测配置。每次发版前,也会跑一个最小并发脚本,模拟调用那些返回 Stream/Cursor 的接口,确认连接池active数能回到基线。这套检查做完,心里才踏实。

说实话,如果这个坑早晚要踩一次,我宁愿你在看完这篇之后,先在测试环境故意写一个不关Stream的代码,把池子调小,亲眼看看active曲线是怎么一步步顶到上限的。亲眼见过一次“连接池被玩没”的过程,比看十篇博客都管用。

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

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

立即咨询