JDBC 读 ResultSet 读到一半,报 stream closed

问题背景

一批统计没有生效。日志不是「任务没跑」,而是 JDBC 在读结果时连接已经断了:

text 复制代码
SQL state [HY000]; error code [1023]; stream closed
Cause: java.sql.SQLException: stream closed

堆栈停在读 ResultSet。代码按 500 个 ID 一批,再用 parallelStream 并发查询。ID 总共大约 30 万到 50 万,一个大 IN 优化器很容易改走全表,解析开销也大,所以才拆批:

java 复制代码
List<StatDTO> stats = ListUtil.split(customerIds, 500).parallelStream()
        .map(batch -> baseMapper.selectDayStat(request, batch))
        .flatMap(List::stream)
        .collect(Collectors.toList());

每个批次各自向连接池借一条连接,查完、读完 ResultSet、归还。stream closed 出在读的中途。拆连接的不是 Hikari。

这条连接是谁

HikariCP 是 Java 这边的 JDBC 连接池。业务不要每查一次就跟 MySQL 新建一条 TCP、做一次登录。Hikari 事先建好一批物理连接,谁要用就借出,用完归还。

池里的一条,就是一条到 MySQL 的物理连接:一个 TCP,一个服务端会话(processlist 里的一个线程)。应用拿到的不是另外一条连接,而是同一条连接包了几层:

text 复制代码
业务代码 getConnection()
  → Hikari 的代理 Connection(记账:借出 / 归还 / 超时)
    → JDBC 驱动的 Connection(协议读写)
      → 一条 TCP
        → MySQL 上的一个 session

池子只管借还和空闲回收,不管 SQL 怎么写。当时的配置(单位是毫秒):

yaml 复制代码
hikari:
  minIdle: 3           # 池里至少留 3 条空闲连接
  maxPoolSize: 30      # 最多 30 条物理连接
  idleTimeout: 600000  # 10 分钟。超过 minIdle 的空闲连接,闲满这个时间就关掉
  maxLifetime: 1765000 # 约 29.4 分钟。从创建算起,归还后超过这个年纪就关掉

idleTimeout、maxLifetime 只作用在已经回到池里的连接上。某个批次还借着连接在读 ResultSet 时,这两个时间不会把那条连接拆掉。占用时间超过 maxLifetime 也一样:Hikari 只打上「下次归还再关」的标记,等这次读完还回来才关。

wait_timeout(默认 8 小时)也不走。它只计算 session 处于 Sleep、在等下一条命令的时间。语句还在执行,或者 MySQL 还在往应用写结果时,都不算空闲。

stream closed 说明什么

java.sql.SQLException: stream closed(HY000 / 1023)的意思是:JDBC 驱动去读 ResultSet 时,底层协议流已经没了。1023 是驱动自己编的错误码,MySQL 错误日志里不会有一条 Error 1023。

拿其中一批借出的连接 C 来说:

  1. 业务线程借出 C。Hikari 把 C 标成 in use。MySQL processlist 里能看到这个 session。
  2. 语句发出去之后,驱动开始读 ResultSet,从这条 TCP 的输入流里拿协议包。
  3. 包还没读完,对端先把这条 TCP 拆了。服务端那个 session 从 processlist 消失。
  4. 驱动下一次 read 发现输入流已经关了,抛出 stream closed。

这时 C 还在业务手里,ResultSet 还没读完,连接还没归还。Hikari 碰不到它。

Hikari 自己回收是另一条时间线。查询结束、ResultSet 读完、C 归还进池之后,闲置超过 idleTimeout,或从创建起超过 maxLifetime,Hikari 才 close 这条物理连接。业务已经不拿着这个 ResultSet,所以不会在「正在读行」的栈上冒出 stream closed。

报错栈停在读 ResultSet,同时满足下面几条,就可以认定连接是在借出期间被拆的,拆的一方是 MySQL 或中间网络:

  • 这条连接当时还是借出状态,ResultSet 没读完。
  • 应用日志里没有 Hikari 先打出的 leak、evict、retired。
  • 同一时刻 MySQL error log 有 Aborted connection、Got timeout writing communication packets,或者 processlist 里这个 session 已经没了。防火墙、负载均衡上则是空闲超时掐连接。

当时的数据库日志已经过期,对不上原始 error log 和 processlist。能确定的是:断在读 ResultSet 的中途,Hikari 的空闲回收解释不了。具体是下面哪一条路,定不了。

两条常见的原因

防火墙或负载均衡的空闲超时

语句已经发出,MySQL 还在算(慢查询、大 IN、扫很多行),结果还没开始往回推。TCP 上一段时间没有任何包,链路看起来是闲的。防火墙或负载均衡按空闲把连接掐掉,常见阈值是 60~300 秒。MySQL 还在忙,应用再读,就是 stream closed。

这段时间 net_write_timeout 还没开始计时,因为 MySQL 还没有往这条连接写结果。只要发出去之后很久没有包,就可能中招。

一次丢很多条 SQL,更容易走到这里。parallelStream 是很多条连接各自在算,单批被拖久了,中间就没有包。

net_write_timeout:MySQL 在推结果,Java 没在读

这里的服务端是 MySQL,客户端是 Java 应用。net_write_timeout 管的是 MySQL 往 JDBC 写结果的方向,默认 60 秒。反方向(MySQL 在等应用发 SQL)用的是 net_read_timeout,默认 30 秒。

MySQL 已经在往这条连接回推结果,Java 没有把字节读走。内核发送缓冲区满了,当前这一次写卡住超过 60 秒,MySQL 拆掉连接。JDBC 再读,就是 stream closed。error log 里是:

text 复制代码
Aborted connection ... (Got timeout writing communication packets)

60 秒看的是这一次写连续卡住多久,不是整份 ResultSet 必须在 60 秒内传完。结果很多、驱动一直在读,这只超时不触发。结果不多、这条连接上的 read 停住超过 60 秒,一样会触发。常见原因是线程卡住、GC,或者网络把 TCP 窗口压死。

这个停发生在 JDBC 还在从 TCP 拿包的时候。驱动已经把这一批读进内存,业务再慢慢处理 List,MySQL 的写已经结束,不再走这个超时。

发送缓冲区是这一条 TCP 自己的。并发高不会直接把某一条的缓冲区堆满,顶多让读结果的线程抢不到 CPU,或者 GC 把 read 停住。同一次并发里,更常见的仍是上面那条:查询还在算,结果还没开始写。

stream closed 只能说明读的时候流已经没了。要认定是 net_write_timeout,得在同一时刻看到 Got timeout writing communication packets。这次日志已经没了,所以定不了。

应该怎么处理

两条路都是这条连接上太久没有成功的数据包。先让单批更快结束,再考虑把超时调大。超时调大之后,几十条并行重查询还在,该断的还是会断。

这次实际改的是把 parallelStream 换成 stream(),一批查完再下一批:

java 复制代码
List<StatDTO> stats = ListUtil.split(customerIds, 500).stream()
        .map(batch -> baseMapper.selectDayStat(request, batch))
        .flatMap(List::stream)
        .collect(Collectors.toList());

同一时刻少占连接,单批更容易在防火墙空闲时间之内算完,读结果的线程也不跟 ForkJoin 任务抢 CPU。单批会变慢,任务能跑完。

另外两件可以一起做:

  • 单批遇到 stream closed 时重试 2~3 次,间隔几百毫秒。原来那条连接已经拆了,重试向池里再借一条。任务能重入的话,失败的那一批再查一次就能补上。
  • 对单批 SQL 做 EXPLAIN,确认 IN 走索引,并且只查要用的列。算得短,没有包的窗口就短;结果行少,要写的数据也少。

防火墙这条路,要缩短的是「语句已经发出、第一包结果还没回来」的那段时间,让它短于设备上的空闲阈值。单批优化之后仍然超时,就把批再拆小,或继续把 SQL 的执行时间降下来。查询本身合理、只是耗时高于设备空闲时间,再让运维把这条链路的空闲超时调高。空闲时间通常覆盖得住正常 SQL,开发侧先管执行时间。

net_write_timeout 调大,对「结果还没开始回」没有作用。error log 里已经确认是 Got timeout writing communication packets,并且多个应用一起中招,再由运维从 60 秒调到 120 秒这一档,给偶发 GC 或网络抖动留余量。应用一直不读的话,调大只是把 MySQL 线程挂得更久。一个开发自己去改这个实例参数,没有必要。

这次先别动:

  • Hikari 的 maxLifetime、idleTimeout 只管已经还回池里的连接。
  • wait_timeout 只管 session 处于 Sleep 的时间。
  • 把 maxPoolSize 再加大,并行查询更多,两条路都更容易碰到。
相关推荐
狂奔的sherry1 小时前
JRuby 多线程反引号偶发 127:GC 提前释放了 posix_spawn 的 argv
java·linux·ruby·gc·jruby
咖啡八杯1 小时前
异常体系设计:三类策略(i18n code、message、受检异常)
java·架构·代码规范
Spatial3Ddddd2 小时前
2026空间三维重建服务商横向对比
java·服务器·人工智能
用户84913717547162 小时前
【技术面试高频】MySQL 存储 IP 地址:你还在用 VARCHAR?
mysql·面试
Rain的Java大神之路2 小时前
Java 三层架构,90% 的人只答对一半:Controller 不是瘦,Service 不是透传,Dao 不是只写 SQL
java·后端·面试
Thomas.Sir2 小时前
第29课:微服务异步调用、线程池优化 & 事件驱动开发
java·redis·微服务
.Hypocritical.2 小时前
【Java 进阶】泛型深度解析|泛型类、泛型方法、通配符、类型擦除、PECS 原则
java·开发语言·泛型
这个DBA有点耶2 小时前
MySQL B-tree索引并发控制优化:写密集场景的锁瓶颈与内核级解法
java·数据库·程序人生·算法·database·dba
壹玖玖肆2 小时前
Docker 部署 openGauss 7.0(aarch64)MySQL 兼容模式建库与数据恢复完整实践
mysql·docker·容器