這兩個例外都是在說「你以為還開著的連接,其實早就被對端關了」。區別只在於發現的方式:
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 再寫一次。
