OkHttp 偶发 EOFException:limit=0 的根因是复用了半关闭连接


线上有一个走 Nginx 的 HTTP 接口,客户端用 OkHttp,请求频率不高。日志里偶尔冒出这么一条。

java.io.EOFException: limit=0 content=

三个现象凑在一起,指向的方向很明确。

  • 只有服务端先断开 keep-alive 连接时才出现,客户端主动关连接从来没有过
  • 请求频率越低越容易出现,压测的时候反而一次都碰不到
  • 抓包看,出问题的连接以 RST 收尾,不是完整的四次挥手

先给结论。客户端连接池复用了一条服务端应用层已经关闭、但 TCP 层还处在半关闭状态的连接,在这条连接上发了新的 GET 请求,服务端内核回了 RST。

下面是怎么查到这一步的。

抓包看到了什么

抓包截图,内网 IP 与业务路径已打码

关键在最后六帧,从 128 帧往下看。

帧号时间方向内容
128329.221S → CFIN, ACK Seq=17454 Ack=13672
129329.263C → SACK Seq=13672 Ack=17455
130332.695C → SGET /... HTTP/1.1
131332.696C → SFIN, ACK Seq=14113 Ack=17455
132332.697S → CRST Seq=17455 Win=0 Len=0
133332.697S → CRST(重传)

有三处反常。

第一,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,ACKS→C FIN,ACK服务器主动关闭
②C→S ACKC→S ACK进入 half-closed 状态
③C→S FIN,ACKC→S HTTP GET错误复用连接
④S→C ACKS→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 方法doExtensiveHealthChecksisHealthy 行为失效连接结果
GET / HEADfalse不读 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 分钟。

两端 keepAlive 各自计时的时序

两边都在计时,都在被刷新,但服务端的表短,客户端的表长。空窗期落在 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 时
1client.retryOnConnectionFailure()绝不重试
2requestSendStarted && requestIsOneShot()绝不重试
3isRecoverable(e, requestSendStarted)绝不重试
4transmitter.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,按这个顺序走。

  1. 抓包,确认连接是以 RST 收尾还是完整的四次挥手。是 RST 才继续往下
  2. 看 RST 前面一帧是什么。如果是本端发出的带数据报文,基本就是复用了半关闭连接
  3. 往前找对端发来的 FIN,量一下 FIN 到那个报文之间隔了多久
  4. 比对两边的 idle timeout。服务端的小于客户端的,就对上了
  5. 确认出问题的请求方法。GET 和 HEAD 是重灾区,POST 反而安全
  6. 检查客户端有没有显式关掉重试开关

边界

这套结论有几个前提。

版本是 OkHttp 3.14.9,ConnectInterceptor 里那行方法名判断在不同大版本里位置和写法有变化,4.x 之后代码结构调整过,结论方向一致但行号对不上。

服务端是 Nginx。close(fd) 而不是 shutdown() 这个行为是绝大多数 Web 服务器的共同选择,但具体到某个服务端要自己确认。

只覆盖 HTTP/1.1。HTTP/2 的连接健康判定走的是 http2Connection.isHealthy(),是另一条路径,不适用这里的分析。

打开 retryOnConnectionFailure 只是止血。真正干净的做法是把客户端的 idle timeout 调到比服务端小,让客户端先关连接,这样就永远不会出现复用半关闭连接的情况。

相关文章

快来做第一个评论的人吧~