线上正好好好跑着,突然一堆接口开始报错,仔细一看全是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()并不是真的关闭物理连接,而是把连接标记为“空闲”,放回池子里复用。如果只借不还,池子里的可用连接就会越来越少,直到耗尽。
连接池有几个关键参数和这个模型直接相关:
| 参数 | 默认值 | 含义 |
|---|---|---|
maximumPoolSize | 10 | 池中最大连接数,包含空闲和活跃 |
minimumIdle | 等于 max | 池中最小空闲连接数 |
connectionTimeout | 30000ms | 请求连接时的最大等待时间 |
idleTimeout | 600000ms | 空闲连接超时回收时间 |
maxLifetime | 1800000ms | 连接最大存活时间,避免被数据库侧回收 |
leakDetectionThreshold | 0(关闭) | 连接借用超过该毫秒数未归还,则输出泄漏警告 |
拿我们出事故的配置来说,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 最小化复现实验
定位到嫌疑代码后,我没有直接改,而是先写了一个最小复现工程验证一下,确保“元凶就是它”。这个实验很简单:
- 建一个 Spring Boot 工程,数据源
maximumPoolSize=5; - 写一个 DAO 返回
Stream<User>; - Service 里
stream.forEach(),不关闭; - 并发请求接口 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: 15000leakDetectionThreshold的作用前面说过,是用来在泄漏再次发生时快速定位的。注意它有个副作用:开启后,每个连接被借出超过阈值,就会打印堆栈日志,频繁大查询也可能触发误报,所以阈值不能设得太小。一般比业务允许的最大查询耗时再大一些即可,比如 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 不关 SqlSession | SqlSession 持有连接,仍然泄漏 | 两个都放进 try-with-resources |
| 在循环内调用返回 Stream 的 DAO | 每次循环泄漏一个连接,快速耗尽 | 先取到结果处理完,或逐段关闭 |
| 大事务 + 流式遍历 | 连接占用时间长,池压力大 | 缩小事务单元或使用分页 |
maximumPoolSize配得过大 | 数据库被拖垮,池枯竭掩盖性能问题 | 按核数和压测结果合理设置 |
5.2 快速定位连接泄漏的检查顺序
如果你也遇到连接池枯竭,我的建议排查顺序是这样的:
- 先看
hikaricp_connections_active和idle曲线。如果active长期等于池上限且不回落,基本确定是泄漏。 - 打开 HikariCP 的
leakDetectionThreshold,等下一次泄漏日志打出来,从堆栈里直接找业务代码。 - 如果着急恢复,可以在数据库侧执行:
SELECT id, user, host, db, command, time, state, info FROM information_schema.processlist WHERE command = 'Sleep' ORDER BY time DESC;Sleep状态且持续时间很长的连接,多半就是泄漏连接。状态列里time越大越可疑。
- 对可疑连接对应的代码做代码审查,重点看有没有返回
Stream/Cursor/ResultSet的 DAO 方法,以及这些方法调用处是否都在 finally 或 try-with-resources 里关闭了资源。 - 修复后,用并发测试验证连接池
active数能回到基线,而不是只验证业务功能正常。
这套顺序我后来在另外两个项目上也用过,都能比较快地定位到根因,比盲目调大连接池参数靠谱得多。
5.3 我的个人心得
这次事故给我最深的教训不是“要记得关 Stream”,而是“写代码时对资源要有敬畏心”。凡是实现了AutoCloseable的东西,处理完后要么 try-with-resources,要么在 finally 里关,没有第三种选项。尤其当它藏在 Spring Data 的魔法后面时,太容易让人以为它只是个普通容器操作。
另外我也发现,代码评审阶段是最划算的控制点。一个 Stream 泄漏问题,如果能在 merge request 时被拦下来,成本就是评论一句话;如果漏到线上,成本就是一次事故回应,还要跟一堆人讲清楚“我不是没关数据库连接,我是没关 Stream”。这句话我讲了整整一个下午。
现在,我在所有项目里都加了连接池指标监控和泄漏检测配置。每次发版前,也会跑一个最小并发脚本,模拟调用那些返回 Stream/Cursor 的接口,确认连接池active数能回到基线。这套检查做完,心里才踏实。
说实话,如果这个坑早晚要踩一次,我宁愿你在看完这篇之后,先在测试环境故意写一个不关Stream的代码,把池子调小,亲眼看看active曲线是怎么一步步顶到上限的。亲眼见过一次“连接池被玩没”的过程,比看十篇博客都管用。