ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

连接池又双叒枯竭了:Hikari + Stream/Cursor 未关闭的排查与配置复盘

连接池又双叒枯竭了:Hikari + Stream/Cursor 未关闭的排查与配置复盘 1. 连接池又双叒枯竭了从一次 queryForStream 泄漏说起Hikari 连接池枯竭是 Java 后端最常见的线上事故之一而queryForStream返回的 Stream 忘记 close、MyBatis 的 Cursor 提前 return 没关闭是其中最隐蔽的一类。它不像慢 SQL 那样一眼能看出来业务线程数不满、CPU 不高但数据库连接池 active 直接顶到maximumPoolSize接口统一卡 30 秒然后报Connection is not available。这篇就聚焦这个场景Hikari 连接池被 Stream/Cursor 占住不还怎么用leakDetectionThreshold定位、怎么用 try-with-resources 修复、怎么用连接池指标验证泄漏真的消除了。适合正在用 Spring JdbcTemplate 流式查询、MyBatis Cursor 做大表扫描的 Java 后端同学尤其是遇到过「偶发 502、日志刷 Hikari 超时」但没找到根因的人。先说结论queryForStream返回的 Stream 是懒读取的它背后绑着一个真实的 JDBC Connection不 close 就不归还findFirst()、anyMatch()这类短路操作会提前结束但不会自动释放连接。MyBatis 的CursorT同理本质是流式结果集加连接占用异常路径或提前 return 都会泄漏。下面按「现象 → 定位 → 修复 → 验证」走一遍每一步都能直接复制。2. 前置准备TaoToken 与本地环境在动手排查之前先把模型对话和接入文档的入口准备好方便边查 API 边对照参数。我平时用 TaoToken 做接口联调和文档查询它的模型对话入口可以直接问 Hikari 参数含义接入文档里有完整的 Key 申请和调用示例。模型对话问参数、问报错https://taotoken.net/models?utm_sourcetaotoken_aicg_blog_endutm_contentmodel_chatutm_campaignrewrite接入文档看 API 规范https://taotoken.net/doc?utm_sourcetaotoken_aicg_blog_endutm_contentdocutm_campaignrewriteAPI Keys 管理生成调用凭证https://taotoken.net/api-keys?utm_sourcetaotoken_aicg_blog_endutm_contentapi_keysutm_campaignrewrite控制台看用量和状态https://taotoken.net/console?utm_sourcetaotoken_aicg_blog_endutm_contentconsoleutm_campaignrewriteAPI 基址代码里配置https://taotoken.net/api本地环境需要JDK 8、Spring Boot 2.x/3.x、HikariCPSpring Boot 默认自带、MySQL 5.7/8.0。确认spring-boot-starter-jdbc或mybatis-spring-boot-starter已引入Hikari 是默认连接池不需要额外加依赖。如果你用的是 Druid 或 Tomcat JDBC参数名不同但泄漏原理一致。注意leakDetectionThreshold只在排障阶段打开它会给每个连接加一个定时检查生产长期开启有额外开销。定位到堆栈后立刻关掉。3. 可复制配置Hikari 关键参数与修复骨架3.1 Hikari 参数怎么配才不容易被榨干先给一份可以直接抄的application.yml重点看maximum-pool-size、connection-timeout、max-lifetime、leak-detection-threshold四个。spring: datasource: url: jdbc:mysql://127.0.0.1:3306/order_db?useSSLfalseserverTimezoneAsia/Shanghai username: app_user password: your_password hikari: pool-name: HikariPool-Order maximum-pool-size: 50 minimum-idle: 10 connection-timeout: 10000 validation-timeout: 3000 idle-timeout: 600000 max-lifetime: 1700000 leak-detection-threshold: 10000几个参数的关系要理清connection-timeout是拿不到连接时的等待上限默认 30 秒用户感知就是卡 30 秒然后 500建议压到 10 秒快失败max-lifetime必须小于 MySQL 的wait_timeout默认 1800 秒否则会借到已经被服务端断开的连接报Failed to validate connectionleak-detection-threshold设成 10000 表示连接借出超过 10 秒没还就打印堆栈这是定位泄漏的核心开关。3.2 queryForStream 的修复骨架反例长这样直接返回 Stream短路操作后连接不还。// 反例不要这样写 public OptionalOrderDTO findFirst(LocalDate day) { StreamOrderDTO stream jdbcTemplate.queryForStream( SELECT id,user_id,amount FROM t_order WHERE gmt_create ? AND gmt_create ?, ps - { ps.setTimestamp(1, Timestamp.valueOf(day.atStartOfDay())); ps.setTimestamp(2, Timestamp.valueOf(day.plusDays(1).atStartOfDay())); }, (rs, rowNum) - map(rs) ); return stream.filter(o - o.getAmount().compareTo(BigDecimal.ZERO) 0) .findFirst(); }正确写法用 try-with-resources 兜住 Stream无论正常返回、提前 return 还是抛异常close 都会执行。public OptionalOrderDTO findFirst(LocalDate day) { try (StreamOrderDTO stream jdbcTemplate.queryForStream( SELECT id,user_id,amount FROM t_order WHERE gmt_create ? AND gmt_create ?, ps - { ps.setTimestamp(1, Timestamp.valueOf(day.atStartOfDay())); ps.setTimestamp(2, Timestamp.valueOf(day.plusDays(1).atStartOfDay())); }, (rs, rowNum) - map(rs))) { return stream.filter(o - o.getAmount().compareTo(BigDecimal.ZERO) 0) .findFirst(); } }如果数据量可控几千行以内更省心的做法是直接物化到内存用普通query返回 List再对 List 做 stream 操作连接在方法返回前就已经归还。ListOrderDTO list jdbcTemplate.query(sql, rowMapper, day.atStartOfDay(), day.plusDays(1).atStartOfDay()); return list.stream().filter(o - o.getAmount().compareTo(BigDecimal.ZERO) 0).findFirst();3.3 MyBatis Cursor 的修复骨架Cursor 同样必须包在 try-with-resources 里提前 return 时 try 块会自动 close。Mapper public interface OrderMapper { Select(SELECT id,user_id,amount FROM t_order WHERE status 1) Options(fetchSize Integer.MIN_VALUE) CursorOrderDO scanPaid(); } public long sumPaid() { try (CursorOrderDO cursor orderMapper.scanPaid()) { long total 0; for (OrderDO o : cursor) { total o.getAmount().longValue(); if (total 100000) { return total; } } return total; } }Options(fetchSize Integer.MIN_VALUE)是 MySQL 驱动流式读取的开关只在真正需要流式时加普通查询别乱加否则每次都是一行一行取反而更慢。Cursor 和 Stream 都不能跨线程传递读取线程和连接是绑定的丢到线程池里异步消费必然出问题。3.4 统一封装防止团队误用光靠 code review 挡不住最好封一个工具方法让调用方没法忘记 close。public final class Streams { private Streams() {} public static T, R R withStream(StreamT s, FunctionStreamT, R fn) { try (s) { return fn.apply(s); } } } // 使用 return Streams.withStream( jdbcTemplate.queryForStream(sql, ps, rm), st - st.filter(o - o.getAmount().compareTo(BigDecimal.ZERO) 0).findFirst().orElse(null) );4. 验证请求日志与连接池指标怎么确认泄漏消除4.1 用 leakDetectionThreshold 抓堆栈线上临时开启泄漏检测可以通过启动参数或配置中心动态下发。java -Dhikari.pool.HikariPool-Order.leakDetectionThreshold10000 -jar order-service.jar触发后日志会打印类似这样的堆栈直接指向没关的代码行。HikariPool-Order - Connection leak detection triggered, stack trace follows at com.xxx.order.dao.OrderDao.scanByDate(OrderDao.java:58) at com.xxx.order.service.OrderService.listOrders(OrderService.java:143)看到堆栈后对照第 3 节的骨架改代码改完再观察日志里还有没有新的 leak 记录。4.2 看连接池指标Spring Boot Actuator 暴露 Hikari 指标后可以直接查 active 和 pending。curl -s http://127.0.0.1:8080/actuator/metrics/hikaricp.connections.active curl -s http://127.0.0.1:8080/actuator/metrics/hikaricp.connections.pending修复前 active 长期贴着 50/50pending 排队修复后 active 回落到 10~15pending 归零。这是最直观的验证信号。4.3 看数据库侧会话SHOW PROCESSLIST;修复前会看到大量Sending data状态挂着不走或者空闲但没释放的会话修复后这些长时间残留消失。配合SHOW STATUS LIKE Threads_connected看连接数是否稳定。4.4 压测复现与回归用 JMeter 或 wrk 对下单列表接口打 200 并发持续 5 分钟观察 P99 和错误率。修复前 P99 会飙到 30 秒以上并出现 500修复后 P99 回到百毫秒级无Connection is not available报错。这一步做完基本可以确认泄漏被拔掉了。5. 本篇常见错排查报错一Connection is not available, request timed out after 30000ms这是池子被占满的典型表现不是网络问题。先开leakDetectionThreshold抓堆栈重点搜queryForStream、Cursor、Stream关键字。如果堆栈指向业务代码里的流式查询按第 3 节改。报错二Failed to validate connection通常是max-lifetime大于 MySQLwait_timeout借到了服务端已断开的连接。把max-lifetime调到小于wait_timeout比如 1700000 毫秒对 1800 秒并确认validation-timeout合理。报错三改了 try-with-resources 但 active 还是高检查是不是有 Stream 被返回到了方法外或者 Cursor 被丢进了线程池异步消费。Stream/Cursor 的生命周期必须和连接绑定在同一个线程、同一个方法内。另外确认没有在Transactional里做流式查询后长时间持有事务不提交连接也不还。报错四leakDetectionThreshold开了但没日志确认 pool-name 和参数前缀对得上-Dhikari.pool.HikariPool-Order.leakDetectionThreshold里的 pool-name 要和配置里一致。用配置中心动态刷新时注意 Hikari 部分参数不支持热更新需要重启或重建数据源。报错五MyBatis Cursor 迭代到一半抛异常try-with-resources 会在异常路径自动 close但如果你手动cursor.close()又在外层 catch 里再操作 cursor会报已关闭。统一用 try-with-resources不要在 finally 里重复 close。6. 长期编码与 Agent 场景的接入建议如果你在做的是长期编码、Agent 工具链或者需要频繁调用模型做代码审查单次对话入口不够用建议走 Coding Plan把模型能力接进日常开发流。Coding Plan长期编码/Agent 场景https://taotoken.net/coding-plan?utm_sourcetaotoken_aicg_blog_endutm_contentcoding_planutm_campaignrewriteClaude Code 接入说明https://taotoken.net/claude-code?utm_sourcetaotoken_aicg_blog_endutm_contentclaude_codeutm_campaignrewrite回到连接池这件事最后补一个我踩过的坑queryForStream配合Transactional时即使 Stream 关了如果事务没提交连接也不会立刻归还。排查时别只盯着 Stream把事务边界一起看。另外maximum-pool-size不是越大越好50 已经能扛住大多数业务盲目调到 200 只会把数据库连接数打满问题从应用层转移到 DB 层。先把泄漏堵住再谈扩容。
返回列表