OkHttp 偶发 EOFException:limit=0 的根因是复用了半关闭连接
线上有一个走 Nginx 的 HTTP 接口,客户端用 OkHttp,请求频率不高。日志里偶尔冒出这么一条。
java.io.EOFException: limit=0 content=三个现象凑在一起,指向的方向很明确。
- 只有服务端先断开 keep-alive 连接时才出现,客户端主动关连接从来没有过
- 请求频率越低越容易出现,压测的时候反而一次都碰不到
- 抓包看,出问题的连接以
RST收尾,不是完整的四次挥手
先给结论。客户端连接池复用了一条服务端应用层已经关闭、但 TCP 层还处在半关闭状态的连接,在这条连接上发了新的 GET 请求,服务端内核回了 RST。
下面是怎么查到这一步的。
抓包看到了什么

关键在最后六帧,从 128 帧往下看。
| 帧号 | 时间 | 方向 | 内容 |
|---|---|---|---|
| 128 | 329.221 | S → C | FIN, ACK Seq=17454 Ack=13672 |
| 129 | 329.263 | C → S | ACK Seq=13672 Ack=17455 |
| 130 | 332.695 | C → S | GET /... HTTP/1.1 |
| 131 | 332.696 | C → S | FIN, ACK Seq=14113 Ack=17455 |
| 132 | 332.697 | S → C | RST Seq=17455 Win=0 Len=0 |
| 133 | 332.697 | S → C | RST(重传) |
有三处反常。
第一,128 帧是服务端先发的 FIN,不是客户端。第二,客户端在 129 帧回了 ACK 之后,隔了 3.4 秒,在 130 帧又往这条连接上发了一个 GET。第三,服务端收到这个 GET 之后约 2 毫秒就回了 RST,连 ACK 都没给。
第二处是问题的核心。这条连接在 129 帧之后就已经半死了,客户端却还当它是活的。
半关闭状态,和一条被错误复用的连接
TCP 的四次挥手里,一方发 FIN 只表示我不再发数据了,不表示我不能收。收到 FIN 的一方进入 CLOSE_WAIT,此时它继续往对面发数据,在协议层面完全合法。这就是半关闭(half-close)。
问题出在客户端的连接池上。OkHttp 的 ConnectionPool 里存着这条连接,它不知道对端已经发过 FIN 了,下一个请求来的时候照样把它取出来用。
sequenceDiagram
autonumber
participant C as Client
participant S as Server
Note over S: keepalive_timeout 到期 -> close(fd)
S->>C: FIN, ACK (Seq 17454)
Note right of S: ESTABLISHED → FIN_WAIT_1
Note left of C: ESTABLISHED → CLOSE_WAIT
C-->>S: ACK (Ack 17455)
Note right of S: FIN_WAIT_1 → FIN_WAIT_2
Note left of C: CLOSE_WAIT
Note over C: 连接池错误复用half-closed连接
C->>S: HTTP GET (PSH, ACK)
C->>S: FIN, ACK (Seq 14113)
Note over C: 几乎同时发出
Note over S: 孤儿FIN_WAIT_2收到数据 -> 立即RST
S-->>C: RST (Seq 17455)
S-->>C: RST (重传)
Note left of C: 收到RST -> CLOSED
Note right of S: CLOSED把正常的四次挥手和实际抓到的对比一下,分岔就在第三步。
| 序号 | 正常四次挥手 | 实际异常情况 | 说明 |
|---|---|---|---|
| ① | S→C FIN,ACK | S→C FIN,ACK | 服务器主动关闭 |
| ② | C→S ACK | C→S ACK | 进入 half-closed 状态 |
| ③ | C→S FIN,ACK | C→S HTTP GET | 错误复用连接 |
| ④ | S→C ACK | S→C RST | 内核发送 RST |
服务端为什么收不了这个 GET
协议说半关闭之后接收方向仍然打开,那服务端为什么不收下这个 GET,反而回了 RST。
差别在于 Nginx 关连接调用的是 close(fd),不是 shutdown(fd, SHUT_WR)。
这两个系统调用差别很大。shutdown(SHUT_WR) 只关写方向,进程还握着这个 fd,还能继续 read()。而 close(fd) 做了两件事,一是让内核发出 FIN,二是把文件描述符彻底交还内核,进程里再没有任何代码能读这条连接。
Nginx 的 keepalive_timeout 到期走的是后者。请求响应完毕后连接进 idle 链表并启动定时器,定时器到期回调 ngx_http_close_connection(),里面直接 close(c->fd)。
于是这条连接在内核里变成了孤儿 socket(orphan socket),处于 FIN_WAIT_2,但没有任何进程拥有它。Linux 对这种 socket 的规则很干脆,收到带数据的报文,立即回 RST 并丢弃数据。
sequenceDiagram
participant App as Nginx进程
participant Kernel as Linux内核
participant Client as 客户端
Note over App: keepalive_timeout到期
App->>Kernel: close(fd)
Note over App: fd被释放,进程无法再read()
Note over Kernel: 发送FIN,连接变成orphan
Kernel->>Client: FIN,ACK
Client->>Kernel: ACK
Note over Kernel: 进入FIN_WAIT_2 + orphan状态
Client->>Kernel: HTTP GET请求数据
Note over Kernel: 收到数据,但已无应用程序可接收
Kernel->>Client: RST (保护机制)这么设计有两个理由,一是防止攻击者不停往孤儿 socket 灌数据占用内核缓存,二是 RFC 793 本来就允许端点在无法把数据交付给用户时用 RST 复位连接。
所以三层规则叠在一起,落差就出来了。
| 层次 | 关闭方式 | 后果 |
|---|---|---|
| TCP 规范 | 发送 FIN 仅关闭发送方向,接收方向理论上仍保持打开 | 可以继续接收对端数据直到收到对端 FIN |
| Nginx 行为 | close(fd) 而非 shutdown(fd, SHUT_WR) | 内核代发 FIN,进程彻底释放 fd,再没人能 read() |
| Linux 内核 | FIN_WAIT_2 且无进程拥有(orphan) | 收到带数据的段立即回复 RST 并丢弃数据 |
有一处要单独说清楚。RST 是针对那个 GET 报文的响应,不是针对 FIN 的。 内核会回 RST 的场景一共三类,本端已完全关闭却收到数据、本端向已关闭的 socket 写数据、以及 FIN_WAIT_2 加 orphan 状态下收到数据。抓包里命中的是第三类。
为什么是 GET,POST 从来没出过这个错
这是整件事里最反直觉的一点。同一个连接池,同一批连接,POST 请求就是不会踩到。
答案在 OkHttp 的 ConnectInterceptor 里,一行代码。本文源码全部取自 3.14.9。下面这段不用细读,看那个布尔值怎么算出来的就够了。
// okhttp3/internal/http/ConnectInterceptor.java 行 34-61
@Override
public Response intercept(Chain chain) throws IOException {
RealInterceptorChain real = (RealInterceptorChain) chain;
Request request = real.request();
Transmitter transmitter = real.transmitter();
// 仅「非 GET」做额外健康检查
boolean doExtensiveHealthChecks = !request.method().equals("GET");
Exchange exchange =
transmitter.newExchange(real, doExtensiveHealthChecks);
return real.proceed(request, transmitter, exchange);
}doExtensiveHealthChecks 这个布尔量一路传下去,经过 Transmitter#newExchange、ExchangeFinder#find、findHealthyConnection,最后落到 RealConnection.isHealthy()。整条链路是这样。
RealCall.execute()
→ getResponseWithInterceptorChain()
→ RetryAndFollowUpInterceptor 重试、重定向
→ BridgeInterceptor 加 UA/Host/压缩
→ CacheInterceptor 本地缓存
→ ConnectInterceptor ① 算出 doExtensiveHealthChecks
→ StreamAllocation.newStream()
→ findHealthyConnection() ② 选连接
→ RealConnection.isHealthy(doExtensive)
├─ false → 直接返回 true GET 走这里
└─ true → 读 1 byte 探活 POST 走这里
→ CallServerInterceptor 写请求、读响应真正的探活逻辑在这里,注意第 3 步和第 4 步的分岔。
public boolean isHealthy(boolean doExtensiveChecks) {
// 1. Socket 三个基本状态位,有任一 true 即失效
if (socket.isClosed() || socket.isInputShutdown() || socket.isOutputShutdown()) {
return false;
}
// 2. HTTP/2 连接交给 http2Connection 自己判定
if (http2Connection != null) {
return http2Connection.isHealthy(System.nanoTime());
}
// 3. 只有 doExtensiveChecks==true 才做主动探活
if (doExtensiveChecks) {
try {
int readTimeout = socket.getSoTimeout();
try {
socket.setSoTimeout(1); // 非阻塞 1 ms
if (source.exhausted()) { // 判断是否还有可读字节
return false; // 缓冲已空,远端已发送 FIN/RST
}
return true;
} finally {
socket.setSoTimeout(readTimeout);
}
} catch (SocketTimeoutException ignored) {
// 1 ms 内没有数据,大概率连接仍好,判健康
} catch (IOException e) {
return false;
}
}
// 4. doExtensiveChecks==false(典型是 GET)直接认为健康
return true;
}第 1 步那三个状态位为什么拦不住。因为 socket.isClosed() 判断的是本端有没有关闭这个 socket,isInputShutdown() 判断的是本端有没有调过 shutdownInput()。对端发来的 FIN 不会改变这三个值中的任何一个。半关闭连接在这三项检查下看起来完全健康。
于是分工就清楚了。
| HTTP 方法 | doExtensiveHealthChecks | isHealthy 行为 | 失效连接结果 |
|---|---|---|---|
| GET / HEAD | false | 不读 socket,假健康 | 复用后读响应头阶段抛 EOFException: limit=0 |
| POST / PUT / DELETE… | true | 读 1 byte 探活 | socket 已关就丢弃并新建连接,业务无感知 |
EOFException 具体在哪一步抛出来也能对上。GET 拿到这条死连接之后进 CallServerInterceptor 读响应头,RealBufferedSource.read() 立刻返回 -1,被 Http1ExchangeCodec.readResponseHeaders 和 Okio 包装成 EOFException: limit=0 content=。
limit=0 的意思,就是一个字节都没读到。
顺带把帧表里最后一个没解释的细节焊上。131 帧客户端在 GET 发出后 1 毫秒自己也发了 FIN,那正是 EOFException 抛出的时刻。客户端写完 GET 就去读响应头,接收缓冲里排在最前面的是 128 帧那个 FIN,read() 立即返回 -1,异常抛出,OkHttp 关闭连接,内核顺手把 FIN 发了出去。也就是说客户端抛异常比 132 帧的 RST 到达还要早,RST 只是给一条已经死透的连接补了一刀。
为什么低频才复现,压测反而碰不到
keepalive_timeout 计的是最后一次数据交换完成后的空闲时长,跟这条连接一共活了多久没有关系。
Nginx 每发完一次响应的最后一个字节,就把这条连接重新丢回空闲链表,并把倒计时归零。所以只要请求间隔小于超时时间,这个计时器会被一次次刷新,FIN 永远发不出来。
我们这边的两个数字是这样的。服务端 Nginx 用默认的 keepalive_timeout 75s,客户端 OkHttp 用默认的 keepAliveDuration 5 分钟。

两边都在计时,都在被刷新,但服务端的表短,客户端的表长。空窗期落在 75 秒到 300 秒之间的时候,服务端已经 FIN 了,客户端还觉得这条连接能再用四分钟。
所以触发条件是三个同时成立。服务端 idle timeout 小于客户端 idle timeout,连接真的空闲了足够长时间,以及下一个请求恰好是 GET。压测的时候第二条不成立,所以怎么压都压不出来。
想手动复现,把服务端超时改短会快很多。
keepalive_timeout 10s; # 临时改成 10 秒
curl --keepalive-time 0 -v http://host/ ...
# 发完最后一条请求后静置 ≈10-11 s
# 再用 tcpdump 可以看到服务器主动 FIN线上还有另一种触发路径,服务端资源耗尽时也会关连接,表现和 keepalive_timeout 到期一模一样。
修复
如果踩坑的主要是 GET,把 retryOnConnectionFailure 打开就行。这个开关默认就是 true,线上还能裸抛 EOFException,多半是构建 client 的时候被显式关掉了,先去搜一下代码里有没有 retryOnConnectionFailure(false)。
OkHttpClient.Builder builder = new OkHttpClient.Builder();
builder.connectTimeout(TIME_OUT, TimeUnit.SECONDS)
.readTimeout(TIME_OUT, TimeUnit.SECONDS)
.writeTimeout(TIME_OUT, TimeUnit.SECONDS)
.retryOnConnectionFailure(true);原理是 RetryAndFollowUpInterceptor 捕获 IOException 之后会走一次 recover() 判定,这个开关是判定链的第一道。关掉它,recover() 直接返回 false,异常原样抛给业务。打开它,realChain.proceed 会带着新连接再跑一遍。
// okhttp3/internal/http/RetryAndFollowUpInterceptor.java : 152–176
try {
return realChain.proceed(request, transmitter, null);
} catch (IOException e) {
boolean requestSendStarted = !(e instanceof ConnectionShutdownException);
if (!recover(e, requestSendStarted, request)) throw e;
continue; // 回到 while(true),重新 newExchange 并重发
}打开这个开关之前,得先弄清它会不会波及 POST。recover() 一共五道判定,任何一道否决就直接抛异常。
| # | 判定点 | 结果为 false 时 |
|---|---|---|
| 1 | client.retryOnConnectionFailure() | 绝不重试 |
| 2 | requestSendStarted && requestIsOneShot() | 绝不重试 |
| 3 | isRecoverable(e, requestSendStarted) | 绝不重试 |
| 4 | transmitter.canRetry() | 绝不重试 |
| 5 | 全部通过 | 重连并重发 |
第 2 道和第 3 道卡住了 POST 的大部分危险场景。请求体不可重复发送(流式上传、duplex、自定义的 UnrepeatableRequestBody)且已经开始写出,不重试。写完请求体在等响应时超时(常见的 readTimeout,属于 InterruptedIOException),不重试。
但有一道缝没堵上。isRecoverable 的最后一行对普通 IOException 一律放行,EOFException 和 Connection reset 都在其中。一个 body 可重复的 POST,写完请求体之后、读响应之前连接坏掉,四道判定全部通过,OkHttp 会重发。假如服务端已经把第一次请求处理完了,这就是一次重复提交。被 requestSendStarted 挡住的只有 SocketTimeoutException 这一类,读响应阶段的普通 EOF 不在其中,这一点很容易记反。
所以这个开关对非幂等的 POST 接口不是免费的。要么接口本身做幂等,要么在应用层带幂等键兜底。
判定这件事的关键变量是 requestSendStarted,它的取值规则很简单,只有异常正好是 ConnectionShutdownException 时才为 false,其他任何 IOException 都为 true。背后的道理是,只要有字节写到了网络上,对端就可能已经开始处理业务了,这时候重发不安全。
排查清单
碰到 EOFException: limit=0 或者 Connection reset,按这个顺序走。
- 抓包,确认连接是以 RST 收尾还是完整的四次挥手。是 RST 才继续往下
- 看 RST 前面一帧是什么。如果是本端发出的带数据报文,基本就是复用了半关闭连接
- 往前找对端发来的 FIN,量一下 FIN 到那个报文之间隔了多久
- 比对两边的 idle timeout。服务端的小于客户端的,就对上了
- 确认出问题的请求方法。GET 和 HEAD 是重灾区,POST 反而安全
- 检查客户端有没有显式关掉重试开关
边界
这套结论有几个前提。
版本是 OkHttp 3.14.9,ConnectInterceptor 里那行方法名判断在不同大版本里位置和写法有变化,4.x 之后代码结构调整过,结论方向一致但行号对不上。
服务端是 Nginx。close(fd) 而不是 shutdown() 这个行为是绝大多数 Web 服务器的共同选择,但具体到某个服务端要自己确认。
只覆盖 HTTP/1.1。HTTP/2 的连接健康判定走的是 http2Connection.isHealthy(),是另一条路径,不适用这里的分析。
打开 retryOnConnectionFailure 只是止血。真正干净的做法是把客户端的 idle timeout 调到比服务端小,让客户端先关连接,这样就永远不会出现复用半关闭连接的情况。