这两个异常都是在说「你以为还开着的连接,其实早就被对端关了」。区别只在于发现的方式:
Connection reset是读/写时收到对端发来的 RST 复位包;Broken pipe是往一个对端已经关闭的 socket 里写数据。它们大多不是网络真断了,而是 keep-alive 超时时间两头没对齐,竞态撞出来的。
Issue & Env(案发现场与环境)
- 现象:调用外部接口或对外服务时,极低比例(0.01%~1%)请求偶发失败,本地复现不了,日志里是
Connection reset或Broken pipe。 - 环境:Java 客户端(HttpClient / RestTemplate / WebClient)+ 服务端(Tomcat / 网关 / 负载均衡),中间可能隔 nginx、ALB、K8s Ingress。
StackTrace(堆栈日志)
java.net.SocketException: Connection reset
at java.base/sun.nio.ch.SocketChannelImpl.throwConnectionReset(SocketChannelImpl.java:401)
at java.base/sun.nio.ch.SocketChannelImpl.read(SocketChannelImpl.java:434)
at org.apache.http.impl.io.SessionInputBufferImpl.streamRead(SessionInputBufferImpl.java:137)
at org.apache.http.impl.io.SessionInputBufferImpl.fillBuffer(SessionInputBufferImpl.java:153)
at org.apache.http.impl.conn.LoggingInputStream.read(LoggingInputStream.java:87)
at org.apache.http.impl.io.ContentLengthInputStream.read(ContentLengthInputStream.java:178)
at org.apache.http.conn.EofSensorInputStream.read(EofSensorInputStream.java:137)
写侧被掐断则是:
java.io.IOException: Broken pipe
at java.base/sun.nio.ch.FileDispatcherImpl.write0(Native Method)
at java.base/sun.nio.ch.SocketDispatcher.write(SocketDispatcher.java:62)
at java.base/sun.nio.ch.IOUtil.writeFromNativeBuffer(IOUtil.java:113)
at java.base/sun.nio.ch.IOUtil.write(IOUtil.java:79)
at java.base/sun.nio.ch.SocketChannelImpl.write(SocketChannelImpl.java:537)
at org.apache.http.impl.io.SessionOutputBufferImpl.streamWrite(SessionOutputBufferImpl.java:124)
at org.apache.http.impl.io.SessionOutputBufferImpl.flushBuffer(SessionOutputBufferImpl.java:131)
at org.apache.http.impl.io.ContentLengthOutputStream.write(ContentLengthOutputStream.java:114)
注意:这里不是「Java 主动断了连接」,而是「Java 使用中的连接已经被别人断掉了,此时才发现」。
Reproduce(复现代码)
典型竞态:服务端 keep-alive 超时 60 秒就关空闲连接,客户端却 90 秒还在复用同一条连接。
T+0s Request 1 完成,连接进入空闲
T+60s 服务端超时,向客户端发 FIN 关连接
T+60s 客户端不知道,复用这条「以为还活着」的连接发新请求
T+60s 服务端收到发往已关闭 socket 的请求,回 RST 复位
T+60s 客户端读到 RST → Connection reset
Root Cause(根因分析)
HTTP keep-alive 让客户端和服务器复用同一条 TCP 连接,双方各自有一个「空闲多久就关」的超时。当服务端的超时比客户端短,就会在客户端毫不知情的时候把连接关掉,客户端下一次复用正好撞上这个时间窗,于是 0.1% 的请求空窗失败。
除了这个错配,还有几类常见成因:
- 客户端 read timeout 设得太短,服务端还在处理,客户端先放弃并把连接关了,服务端写完才发现对方没了。
- 中间的负载均衡、代理、防火墙有自己的 idle timeout,比两端都短,先动手清连接。
- 服务端在客户端还没读完响应时就把连接关了,客户端写数据时触发 Broken pipe。
它们共同点在于:都不是网络「断了」,而是「谁先关、谁不知道」的时序问题,所以本地、低并发下几乎必然不出现。
The Fix(解决方案)
核心原则:让客户端在服务端之前主动关空闲连接,留出安全余量,消除竞态窗口。
服务端 keep-alive 60 秒,客户端就把空闲连接回收设成 55 秒:
server:
tomcat:
keep-alive-timeout: 60s
connection-timeout: 30s
客户端(Apache HttpClient 5)在服务端之前清理空闲连接:
PoolingHttpClientConnectionManager cm = new PoolingHttpClientConnectionManager();
cm.setMaxTotal(100);
cm.setDefaultMaxPerRoute(10);
CloseableHttpClient client = HttpClients.custom()
.setConnectionManager(cm)
.evictIdleConnections(TimeValue.ofSeconds(55)) // 低于服务端的 60s
.build();
对外调用时,重试要有节制:读操作可以重试,写/变更类请求必须在幂等有保障的前提下重试,否则「服务端其实处理成功了、客户端超时重试」会重复扣款、重复下单。Broken pipe 一旦发生,同一条连接已经不能用,重试必须建立在新建连接之上,而不是对着旧 socket 再写一次。
