1. 事故现场:连接池说没就没
先交代一下背景。前两周一个导出功能翻车了,表现很典型:白天平稳运行,上午十点一过接口大面积超时,后台日志滚动刷屏,全是 HikariCP 的Connection is not available, request timed out after 30000ms。我第一反应是慢查询把连接拖死了,结果排查到最后,问题出在一个没人注意的 Stream/Cursor 上——连接池不是被慢查询耗尽的,是被一个没关的游标活活借走不还的。
这篇文章不绕弯子,直接把翻车现场、根因原理、修复代码和排查套路一次讲透,适合用过 Spring Boot + MyBatis + HikariCP,但没深究过连接生命周期的同学。尤其是写过“流式查询”或“游标遍历”的人,这篇文章你大概率用得上。
1.1 报错现象和管理端表现
出问题的是一个数据导出接口,逻辑不复杂:从流水表里按时间范围拉数据,组装成 Excel 流式输出。这个接口平时每天调用量不算大,但因为一次要导几十万行,所以实现时特意用了 MyBatis 的 Cursor 游标,避免一次性把所有记录 load 进内存。
翻车当天的表现分三个阶段:
第一阶段是偶发超时。某个请求等了二十多秒才返回,日志里零星出现HikariPool-1 - Connection is not available。这时候连接池其实已经紧张了,但因为流量被其他接口分摊,症状不明显。
第二阶段是雪崩。导出接口一旦并发上来,每个请求都卡在获取连接这一步。后面所有需要访问数据库的接口跟着遭殃,整个服务的可用性直接崩掉。管理端监控面板上,错误率从 0.5% 飙到 40% 以上,数据库 CPU 反而很闲,这本身就是个强烈的信号——瓶颈不在数据库执行,而在应用和数据库之间的连接分配环节。
第三阶段是表面恢复。重启应用后一切恢复正常,但过了几个小时,同样的剧本又上演一遍。这个“重启就好,过会儿又坏”的特征,几乎是连接泄漏类问题最标准的指纹。
1.2 第一轮排查:慢SQL不是凶手
看到连接池异常,我的第一反应是先找慢 SQL。数据库侧的信息很明确:没有任何一条 SQL 的执行时间超过 500ms,慢查询日志干干净净。从SHOW FULL PROCESSLIST看,数据库端几乎没有在执行的语句,倒是有大量Sleep状态的连接,而且Time字段显示它们已经空闲了很长时间。
这就有意思了。如果数据库端都是空闲连接,那应用侧的连接池为什么拿不到连接?唯一合理的解释是:这些连接虽然在数据库端是空闲的,但在应用侧依然被标记为“已借出”,没有归还到池里。
这时候我顺手查了一下 HikariCP 的指标,activeConnections稳定在 30 左右,而maximumPoolSize正是 30。也就是说,池子里所有连接都被“借”走了,但真正在执行 SQL 的几乎没有。用一句通俗的话说:连接像一堆被借走的书,借书的人早就不看了,但一直没还。
1.3 Hikari指标暴露了真相
HikariCP 在 Spring Boot 2.x 之后可以通过 Actuator 直接暴露指标,路径是/actuator/metrics/hikaricp_connections_active。我拉出当天的数据看了一下,趋势非常扎眼:
hikaricp_connections_active持续上涨,直到触顶;hikaricp_connections_pending(等待获取连接的线程数)从 0 开始一路走高;hikaricp_connections_timeout累计值同步增长;hikaricp_connections_idle无限趋近于 0。
pending这个指标很有意思,它代表的是“想借连接但没借到、正在排队”的线程数。当 active 触顶、pending 持续走高时,几乎可以断定连接是被某段代码占用后没有释放,而不是并发量真的超过了池子上限。毕竟这个服务的 QPS 远没到需要 30 个数据库连接的级别。
定位方向基本锁定:代码里存在“只借不还”的连接占用路径。
2. 根因分析:Cursor、Stream为什么要背这个锅
找到“只借不还”的结论后,下一步就是揪出具体是哪段代码。我的第一反应是导出接口,因为只有它用了游标式的流式查询。顺着这个思路继续挖,很快发现了一个我之前一直没在意的细节:MyBatis 的 Cursor 关闭,并不会连带关闭 SqlSession。
2.1 连接池的租借模型,先搞懂
要理解这个 bug,得先明白 HikariCP 的工作方式。连接池本质上就是一个“借还仓库”,每个数据库连接都是一个可被重复使用的资源对象。业务代码从池里getConnection(),用完以后close(),连接才会回到池子里继续给下一个请求用。
关键点在于:这里的close()不是真的把数据库连接关掉,而是“归还”连接——把连接从 in-use 状态切换回 idle 状态,放回池内。如果一段代码拿到了连接但迟迟不调close(),这个连接就会一直被标记为“已借出”,池子的可用连接数就会不断减少。
HikariCP 的Connection is not available, request timed out报错,就是在告诉你:池子里所有连接都处于“已借出”状态,新的请求拿不到了。这种情况和数据库本身是否繁忙没有必然关系,很多时候数据库 CPU 低位运行,但应用就是拿不到连接。
2.2 MyBatis的SqlSession和数据库连接的绑定关系
在 MyBatis 里,一个SqlSession基本上对应一个数据库连接。你可以把SqlSession理解为“一次与数据库交互的会话”,会话打开时向连接池借一个连接,会话关闭时把连接还回去。
平时用 MyBatis 的时候,大家写的是 Mapper 接口,底层是MapperProxy在帮你默默管理SqlSession。如果你用的是 Spring Boot +mybatis-spring-boot-starter,那日常操作里根本看不到SqlSession的生命周期,因为它们都被SqlSessionTemplate托管了。SqlSessionTemplate内部会保证每个数据库操作结束或事务结束后自动关闭会话,这也是为什么绝大多数时候我们写代码不需要手动管理连接。
但有一个例外:当你主动调用SqlSessionFactory.openSession()或直接操作SqlSession时,资源的关闭责任就转移到了你自己身上。特别是在流式查询场景下,很多人只顾着关Cursor,忘了关SqlSession。
2.3 游标的“隐形持有”机制
游标查询(Cursor)和普通查询的最大区别在于:普通查询一次性把所有结果集拉回应用内存,ResultSet 在数据库端执行完即可释放,连接很快就能还回池中;而游标查询会保持 ResultSet 的打开状态,让应用边遍历边从数据库拉取,避免大结果集撑爆内存。
这就带来一个副作用:只要游标还开着,底层数据库连接就必须一直保持“持有”状态,不能归还给连接池。如果代码里忘了关闭游标,甚至忘了关闭承载游标的SqlSession,连接就会被无限期占用。更隐蔽的是,游标遍历结束后连接依然不释放——因为SqlSession还开着。
简单说,游标就像一个从仓库借出来的“长期占位凭证”。你用完了凭证上的数据,但如果没把凭证本身交回去,仓库管理员就永远认为这个连接还在被使用。一次排查中我就是被这个机制坑了:只关了 Cursor,没关 SqlSession,日志里也没有任何报错,连接池却一点点被榨干。
3. 代码级复现与参数推演
知道了原理,我们再回到代码层面。把当时的错误写法还原出来,你会发现这个 bug 埋伏得有多深。
3.1 我踩过的那个错误写法
先说明一下,下面的代码不是“故意写坏”的版本,而是当时项目里真实存在的写法。第一版实现长这样:
public void exportLargeData(Long startId, Long endId, OutputStream out) { SqlSession sqlSession = sqlSessionFactory.openSession(); try { BillMapper mapper = sqlSession.getMapper(BillMapper.class); try (Cursor<BillRow> cursor = mapper.scanByRange(startId, endId)) { for (BillRow row : cursor) { // 组装Excel行,写入输出流 } } } finally { // 这里原本写了sqlSession.close(),但在一次重构中被误删 } }看这段代码,内层cursor放在 try-with-resources 里,遍历结束后会自动关闭。但问题在于:关闭 Cursor 并不会让底层连接回到池子里。真正决定连接归还是sqlSession.close(),而它在某次重构中被删掉了,恰好finally块又是空的,于是每个请求执行完导出逻辑,连接都落在“已借出”状态不归还。
第一版其实 bug 还比较“温和”,因为是单次请求,连接最多占一个。真正致命的是第二版——把导出功能改成分批并发后,问题就彻底爆炸了。
3.2 多了几行,问题就从偶发变雪崩
第二版为了提升导出速度,把数据按 id 范围切分,交给线程池并行处理:
public void parallelExport() { ExecutorService pool = Executors.newFixedThreadPool(8); for (Range range : splitRanges()) { pool.submit(() -> { SqlSession sqlSession = sqlSessionFactory.openSession(); try (Cursor<BillRow> cursor = sqlSession.getMapper(BillMapper.class) .scanByRange(range.start, range.end)) { for (BillRow row : cursor) { // 写入分片文件 } } finally { // 又一次忘了sqlSession.close() } }); } pool.shutdown(); }这里我用的是“又一次忘了”这个说法,因为这次重构的人把sqlSession的关闭写在了 try 块之外,结果try-with-resources只负责了 cursor 的关闭。每个线程池任务执行完,都留下一个未归还的连接。
线程池里有 8 个线程,如果每个任务都执行几十秒(因为游标要遍历大量数据),那么只要同时来了四五个导出请求,30 个连接就被全部占满。后面的接口只能干等connectionTimeout(默认 30 秒),超时后直接抛异常。
这就是典型的“低级错误,高级破坏力”。不管代码写得多么花样,只要连接生命周期管理有漏洞,并发一上来就是雪崩。
3.3 连接池容量估算的简单公式
排查过程中,我还做了一次连接池容量估算,用来确认 30 个连接是不是本身就给小了。这里有个非常实用的公式,可以快速推算出理论连接数:
连接数 ≈ QPS × 单请求平均持有连接时间(秒)
这是排队论里 Little's Law 的简化版本。假设某个核心接口高峰期 QPS 为 500,每个请求访问数据库平均耗时 20ms,加上网络往返、连接获取的消耗,算 25ms,也就是 0.025 秒。那么理论上需要的并发连接数是:
500 × 0.025 = 12.5 ≈ 13个连接考虑到线程上下文切换、慢查询波动、批量任务等因素,再留 30% 的余量,20 个连接完全够用。但这是“平均持有 25ms”的前提。如果游标查询单次持有连接 20 秒,同样是 500 QPS,需要的连接数就变成 1 万个——这显然不可能,所以只能表现为“获取连接超时”。
这个算式的价值在于:它能帮你区分到底是“池子配小了”还是“连接被不合理地长时间占用”。同理,当你发现池子里 active 连接数远超理论值,优先怀疑资源泄漏,而不是盲目调大maximumPoolSize。
4. 修复方案与Hikari配置调优
定位到根因后,修复并不难,无非是补上sqlSession.close()并统一资源管理方式。但这里我想多说两句:修复连接泄漏,不能只靠“记住要 close”,而要在架构层面减少犯错的概率。
4.1 正确关闭资源的两种姿势
方案一:双层 try-with-resources
最简单直接的方式,把sqlSession和cursor都放进 try-with-resources,让 JVM 保证资源按获取顺序的逆序关闭:
try (SqlSession sqlSession = sqlSessionFactory.openSession(); Cursor<BillRow> cursor = sqlSession.getMapper(BillMapper.class) .scanByRange(startId, endId)) { for (BillRow row : cursor) { // 写入输出流 } }注意,sqlSession声明必须在cursor之前,这样关闭顺序才是 cursor 先关、sqlSession 后关。这个顺序很关键:先关 cursor 释放 ResultSet,再关 sqlSession 归还连接,任何一步提前都不行。
方案二:让 SqlSessionTemplate 托管
如果你的项目已经集成了mybatis-spring-boot-starter,那么 Spring 容器里本身就有一个线程安全的SqlSessionTemplate。它的execute方法会自动帮你在回调结束后关闭会话:
@Autowired private SqlSessionTemplate sqlSessionTemplate; public List<BillRow> scanByRange(Long startId, Long endId) { return sqlSessionTemplate.execute(new SqlSessionCallback<>() { @Override public List<BillRow> doInSqlSession(SqlSession session) { try (Cursor<BillRow> cursor = session.getMapper(BillMapper.class) .scanByRange(startId, endId)) { return cursor.stream().limit(10000).toList(); } } }); }这种方式的好处是:你永远不需要手动管理SqlSession,SqlSessionTemplate会在方法结束前无条件关闭会话。就算中间抛了异常,连接也能安全归还。个人强烈建议:除非有明确的定制需求,否则不要在业务代码里直接 newSqlSessionFactory().openSession()。
4.2 把SqlSession交给SqlSessionTemplate托管
还有一些更“曲线救国”的写法,比如借助 Spring 的@Transactional来绑定连接生命周期。在事务方法里,连接是和当前线程绑定的,事务提交或回滚后自动释放。不过游标场景要特别注意:不要把 Cursor 或 Stream 返回给调用方。一旦方法结束但调用方还在持有着游标对象,连接依然无法归还——因为 MyBatis 判定“会话还活着”。
我的建议是,流式查询的消费逻辑尽量内聚在同一个方法里,要么全部消费完再返回结果,要么返回 DTO 而不是游标本身。这样连接的借还边界非常清晰,不容易出幺蛾子。
4.3 Hikari参数设置的几条军规
除了代码层面的修复,HikariCP 的几个参数也值得重新审视一遍。以下配置是一套经过验证的组合,适合大多数中小型系统:
spring: datasource: hikari: maximum-pool-size: 20 minimum-idle: 5 connection-timeout: 30000 idle-timeout: 600000 max-lifetime: 1800000 leak-detection-threshold: 60000逐个说下要点:
maximum-pool-size:池上限。根据前面的公式算出来的理论值再加 30% 缓冲即可,别盲目往大调。连接数越多,数据库侧的开销越大。minimum-idle:最小空闲连接数。核心服务建议维持 5~10 个,避免流量突增时频繁建连。connection-timeout:获取连接的等待时间,默认 30 秒,一般不用改,但如果你希望快速失败,可以调成 10 秒。idle-timeout:空闲连接回收时间,默认 10 分钟。注意它要小于max-lifetime。max-lifetime:连接最大存活时间,这是最容易踩坑的参数。HikariCP 官方强烈建议max-lifetime必须小于数据库侧的wait_timeout。MySQL 默认wait_timeout是 8 小时,如果 Hikari 的连接空闲超过 8 小时,数据库端已经悄悄断开了连接,但 Hikari 还认为连接可用,等真正用它查询时会报“Connection is not available”或Communications link failure。所以max-lifetime建议设 30~50 分钟,留足安全余量。leak-detection-threshold:连接泄漏检测阈值,单位毫秒。如果连接从借出到归还的时间超过这个值,Hikari 会打印一条警告日志,包含堆栈信息。这是一个非常实用的“防漏网”机制,代价是每次连接归还时多做一次时间对比,性能消耗几乎可以忽略。
leak-detection-threshold强烈建议在生产环境开启,设成 60 秒。如果某段代码持有连接超过 60 秒,日志里会明明白白打出Apparent connection leak detected,并附上线程栈。等下一次泄漏发生时,你根本不用猜,日志直接告诉你凶手是谁。
5. 排查实战:两小时定位过程复盘
最后把完整的排查流程复盘一遍。这里面的排查顺序很重要,如果一上来就查代码,可能要看半天才发现问题;如果一上来就重启,问题永远潜伏。我按“监控指标 → 数据库进程 → 线程栈 → 代码定位”的顺序走,基本是一次命中。
5.1 看监控指标判断是“借不到”还是“借太久”
首先看 Hikari 的几个核心指标,它们分别对应不同的故障类型:
| 指标 | 正常值 | 异常表现 | 可能原因 |
|---|---|---|---|
hikaricp_connections_active | 远小于 maximumPoolSize | 持续触顶 | 连接被长时间占用或泄漏 |
hikaricp_connections_idle | 有一定余量 | 长期接近 0 | 连接借出速度大于归还速度 |
hikaricp_connections_pending | 0 | 持续增长 | 线程在排队等连接,说明池已满 |
hikaricp_connections_timeout | 0 | 递增 | 获取连接超时次数在增加 |
hikaricp_connections_creation_seconds | 稳定 | 频繁出现峰值 | 连接频繁被重建,可能存在泄漏后释放后又重建的情况 |
如果 active 触顶、pending 上涨,基本可以判定是“连接被借走没还”;如果 active 不高但请求依然超时,那可能要去看网络层或数据库连接数限制。
5.2 用MySQL进程表和jstack双管齐下
指标定位到“连接泄漏”后,下一步是找出谁在持有连接。两个工具就够了:MySQL 的PROCESSLIST和 JDK 自带的jstack。
MySQL 侧,执行:
SELECT id, user, host, db, command, time, state, info FROM information_schema.PROCESSLIST WHERE command = 'Sleep' ORDER BY time DESC;我当时的执行结果里,有大量Sleep状态连接,time字段从几百秒到几千秒不等,但info字段全是 NULL——说明它们没有在跑 SQL,就是占着连接不干活。如果这些连接的数量和应用的activeConnections基本吻合,那泄漏路径就差坐实了。
应用侧,找到应用的 PID,抓线程栈:
jstack <pid> > stack.txt重点看那些WAITING状态、有HikariPool.getConnection调用的线程。这些线程就是“拿不到连接而排队等待”的线程。而真正持有连接的线程,往往能在栈里看到selectCursor、Cursor、SqlSession的字样。
我那次抓栈的时候,因为导出接口还在跑,直接看到了好几个线程阻塞在Cursor.hasNext()上,再往上追溯到parallelExport方法,一下就锁定了位置。
5.3 常见问题速查表
| 现象 | 可能原因 | 处理方式 |
|---|---|---|
偶发Connection is not available | 连接池紧张,但还没触顶 | 看 active/pending 指标,分析高峰期并发是否超池 |
| 重启后恢复,过几小时又坏 | 典型的连接泄漏 | 开启leak-detection-threshold,抓堆栈定位 |
数据库端大量Sleep连接 | 应用借了连接没归还 | 检查是否有手动 openSession,尤其注意流式查询 |
hikaricp_connections_pending居高不下 | 连接全部被占,请求排队 | 查看线程栈,确认哪些调用持有连接过久 |
日志提示Connection is not available但数据库连接数正常 | 应用侧连接池配置过小或连接被长时间占用 | 用 Little's Law 估算合理池大小,检查慢查询 |
| MySQL 8小时空闲后突然报错 | wait_timeout和max-lifetime配置不合理 | 调低max-lifetime至 30~50 分钟 |
6. 一些额外的反思和实用建议
修复完代码、把连接池参数调好之后,我又顺手做了两件事,算是给这次翻车上了双保险。
第一件事是在导出接口上加了并发控制。流式查询 + 线程池并发的组合不是不能用,但要限制同时运行的导出任务数量。我用了一个信号量,统一把全局并发导出数限制在 3 个以内。这样即使代码里有漏网的资源未关闭,连接池也不会被瞬间打穿,问题只会以“性能下降”的形式出现,而不是“所有接口挂掉”的形式。
第二件事是写了一个简单的连接池健康检查脚本,定时拉取 Hikari 指标,如果activeConnections在非高峰期连续五分钟超过池上限的 80%,就触发告警。这个阈值可以根据业务灵活调整,目的是在问题积累到不可挽回之前就发现苗头。
最后再分享一个小技巧。当时为了防止再犯同样的错,我把leak-detection-threshold直接设成了 60 秒,上线后果然又在日志里抓到过两次连接泄漏的警告——一次是某段代码在循环里反复openSession但close写在了continue之后,另一次是@Async方法里用了try-with-resources却因为代理类导致关闭逻辑没被执行。如果当时没开这个参数,这两个问题可能又要在生产环境跑很久才能被发现。
连接池这事,说白了就是个资源管理的纪律问题。代码写得多花哨都行,但只要“借”和“还”对不上账,再稳定的系统也会在一个看似不起眼的并发高峰里轰然倒地。希望这篇复盘能帮你避开我踩过的这个坑。