【今天下午正准备摸鱼,测试突然在群里@我:“下单列表一会儿就502,日志里还在报Hikari超时。”我心里一紧,看来不是普通慢SQL,是连接池被玩没了。)


事故现场

  • 现象:
    • 接口偶发30秒卡死,然后统一报错;
    • 业务线程数不满,但DB连接池使用飙到顶;
    • 网关/应用日志里不断出现连接获取超时。

日志随手一段:

2026-03-19 15:02:10 WARN  com.zaxxer.hikari.pool.PoolBase - HikariPool-1 - Failed to validate connection com.mysql.cj.jdbc.ConnectionImpl@1f2c3a
2026-03-19 15:02:40 ERROR c.zaxxer.hikari.pool.HikariPool - HikariPool-1 - Connection is not available, request timed out after 30000ms
org.springframework.jdbc.CannotGetJdbcConnectionException: Failed to obtain JDBC Connection

把 leak 检测阈值开到 10s(线上临时):

-Dhikari.pool.HikariPool-1.leakDetectionThreshold=10000

马上刷出来:

HikariPool-1 - 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)

我当场裂开——十有八九是“流式查询/游标没关”,把连接占住不还,池子直接枯竭。


排查脑回路

  1. 先看DB:SHOW PROCESSLIST; 发现大量Sending data挂着不走,某些会话空闲但没释放。
  2. JFR/JStack看线程:一堆线程停在Stream.filter/findFirst上,调用栈指向jdbcTemplate.queryForStream。
  3. 全局搜“queryForStream / Cursor”,两处可疑:
    • Spring JdbcTemplate 的流式查询返回Stream;
    • MyBatis 用了Cursor<T>做大表扫描。
  4. 打开代码,罪魁2连:

问题代码(反例)

1) queryForStream 没有关闭 Stream

public Optional<OrderDTO> findFirst(LocalDate day) {
    Stream<OrderDTO> 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)
    );
    // 这里直接返回/短路,没有显式 close()
    return stream.filter(o -> o.getAmount().compareTo(BigDecimal.ZERO) > 0)
                 .findFirst();
}
  • queryForStream返回的是“懒读取”的Stream,不close就不归还连接;
  • findFirst()提早返回时,后续 finally 也没机会清理,连接一直被占着。

2) MyBatis Cursor 用完不关

@Mapper
public interface OrderMapper {
    @Select("SELECT id,user_id,amount FROM t_order WHERE status = 1")
    @Options(fetchSize = Integer.MIN_VALUE)
    Cursor<OrderDO> scanPaid();
}

public long sumPaid() {
    Cursor<OrderDO> cursor = orderMapper.scanPaid();
    long total = 0;
    for (OrderDO o : cursor) { // 迭代完还好,异常/提前return 就泄了
        total += o.getAmount().longValue();
        if (total > 100000) return total; // 这里提前 return,cursor 没关
    }
    return total;
}
  • MyBatis 的 Cursor 本质也是流式结果 + 连接占用;
  • 提前 return/异常路径不 close,连接就被长期霸占。

根因

  • 流式读取没配合 try-with-resources 正确关闭;
  • 高峰期请求多,连接被长时间占用不归还,池子很快见底;
  • 连接超时默认 30s,用户感知就是一刀 30s 的 504/500。

解决方案(一次性拔草)

1) 用 try-with-resources 兜住 queryForStream

public Optional<OrderDTO> findFirst(LocalDate day) {
    try (Stream<OrderDTO> 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();
    }
}

或者干脆“物化”到内存再玩(数据量可控时):

List<OrderDTO> list = jdbcTemplate.query(sql, rowMapper, ...);
return list.stream().filter(...).findFirst();

2) MyBatis Cursor 必须包在 try-with-resources

public long sumPaid() {
    try (Cursor<OrderDO> cursor = orderMapper.scanPaid()) {
        long total = 0;
        for (OrderDO o : cursor) {
            total += o.getAmount().longValue();
            if (total > 100000) return total; // try 会在 return 时自动 close
        }
        return total;
    }
}

注意:

  • @Options(fetchSize = Integer.MIN_VALUE) 只在流式场景生效(MySQL 驱动),非必须别乱加;
  • Cursor/Stream 别跨线程传递,读取线程与连接绑定。

3) Hikari 参数与监控

spring:
  datasource:
    hikari:
      maximum-pool-size: 50
      minimum-idle: 10
      connection-timeout: 10000   # 快失败
      validation-timeout: 3000
      max-lifetime: 1700000       # < MySQL wait_timeout(1800s)
      leak-detection-threshold: 10000  # 仅在排障阶段打开
  • maxLifetime 要小于 MySQL wait_timeout,避免“借到快过期的连接”;
  • 排障时开 leakDetectionThreshold,定位堆栈;平时关掉以免有额外开销。

4) 兜底:统一工具封装,防止误用

public final class Streams {
    public static <T,R> R withStream(Stream<T> s, Function<Stream<T>, R> fn) {
        try (s) { return fn.apply(s); }
    }
}
// 使用
return Streams.withStream(jdbcTemplate.queryForStream(sql, ps, rm),
        st -> st.filter(...).findFirst().orElse(null));

验证

  • 开始修复后,连接池 metrics:active 从 50/50 下降到 10~15,等待队列清零;
  • 线上再无 Connection is not available 报警,P99 从 31s 回到 120ms;
  • SHOW PROCESSLIST 无长时间 Sending data 残留。

踩坑总结

  • 流式查询很香,但一定要配 try-with-resources;提前 return/异常也能正常 close;
  • Cursor/Stream = 连接占用,别跨线程,别忘关;
  • Hikari 连接池要有监控和快失败阈值,看到 leak 立刻定位,别让池子被榨干。】
Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐