OpenFeign 和 OkHttp 首次调用失败、重试成功的排查与优化

业务服务通过 OpenFeign 调用用户与权限服务时,偶尔会出现一种比较特殊的现象:

第一次请求发生 IOException
OkHttp 随后自动重试
第二次请求成功
接口最终仍然返回 HTTP 200

应用错误日志里通常看不到这次失败,只有在链路追踪的子 Span 中才能发现异常:

java.io.IOException: unexpected end of stream on http://downstream-service/...
    at okhttp3.internal.http1.Http1ExchangeCodec.readResponseHeaders(...)

这类问题表面上没有造成接口失败,但它说明 HTTP 连接复用链路并不稳定。失败比例较低时,OkHttp 的自动重试能够兜底;调用量上升或下游连接集中失效时,重试会增加尾延迟、连接创建和下游压力,最终可能演变成真正的请求失败。

本文结合业务服务调用用户与权限服务的场景,记录这类问题的排查过程、配置边界和优化方法。

1. 问题现象

一次请求中,业务服务连续调用了用户与权限服务的两个接口。两个调用都出现了相同的过程:

从 OkHttp 连接池获取连接
  -> 发送 HTTP 请求
  -> 读取响应头时收到 EOF
  -> 当前请求子 Span 标记为 ERROR
  -> OkHttp 重新建立或选择连接
  -> 自动重发请求
  -> 下游正常返回

Trace 中的典型证据包括:

异常类型:java.io.IOException
异常信息:unexpected end of stream
异常位置:Http1ExchangeCodec.readResponseHeaders
重发次数:http.request.resend_count = 1
最终状态:HTTP 200

这里有三个重要特征:

  1. 失败发生在读取 HTTP/1.1 响应头阶段,而不是业务响应码校验阶段。
  2. 同一个业务请求内的重试很快成功,下游接口本身能够正常处理请求。
  3. 根 Span 最终成功,但中间某次网络尝试对应的子 Span 失败。

因此,排查重点不应该先放在下游业务代码,而应该放在 HTTP 连接复用、服务端连接关闭和链路空闲超时上。

2. 为什么应用日志里看不到失败

Feign 方法对业务代码来说只执行了一次:

UserInfo userInfo = userFeignService.getUserInfo(userCode);

但 Feign 底层使用 OkHttp 后,一次方法调用可能包含多次网络尝试:

Feign 方法调用
  -> OkHttp 第一次网络交换失败
  -> OkHttp 判断异常可以恢复
  -> OkHttp 内部重试
  -> 第二次网络交换成功
  -> Feign 返回成功结果

如果业务代码只在 Feign 抛出最终异常时记录日志,那么第一次连接级失败已经被 OkHttp 恢复,不会进入业务异常处理逻辑。

链路追踪的粒度通常更细。它可能记录每次 HTTP 网络交换,因此能够看到:

一个失败的客户端子 Span
一个成功的重试子 Span
一个最终成功的业务 Span

这也是为什么只看 ERROR 日志会认为“没有问题”,而查看 Trace 详情却能发现大量首次尝试失败。

监控这类问题时,需要区分两个指标:

最终请求失败率
首次网络尝试失败率

最终失败率低,不代表连接层稳定。

3. unexpected end of stream 表示什么

异常发生在:

Http1ExchangeCodec.readResponseHeaders

它表示客户端已经发出请求,但在读取完整响应头之前,底层连接结束了。

常见原因包括:

  • 客户端复用了一个服务端已经关闭的 keep-alive 连接。
  • 网关、负载均衡或代理先于客户端关闭了空闲连接。
  • 下游实例重启、摘流或发生连接级异常。
  • 服务端处理请求前后主动关闭了 Socket。
  • 网络设备重置了长时间空闲的 TCP 连接。

结合“第一次失败、立即重试成功”的表现,最符合的是失效连接复用:

连接曾经可用并进入客户端连接池
        ↓
连接空闲期间被服务端或中间网络设备关闭
        ↓
客户端连接池暂时仍保留这个连接
        ↓
下一次请求复用该连接
        ↓
读取响应头时发现连接已经结束
        ↓
OkHttp 丢弃连接并使用新连接重试
        ↓
请求成功

这通常被称为 stale connection,也就是连接池里仍存在、实际已经不可用的连接。

不过,仅凭一次 unexpected end of stream 不能百分之百证明根因就是连接池。正确的结论应该是:

Trace 证据高度符合空闲连接被对端关闭后再次复用,需要继续核对客户端、服务端和中间网络层的空闲超时配置。

4. 为什么调用更频繁的服务失败率反而更低

对比另一个同样调用用户与权限服务的应用,几千次调用中只有极少数失败,而当前业务服务更容易出现首次尝试失败。

调用次数本身不能直接解释 stale connection 的概率。真正关键的是连接的空闲时间分布。

高频调用可能让连接持续活跃:

请求 -> 短暂空闲 -> 请求 -> 短暂空闲 -> 请求

连接很少超过服务端的空闲超时,因此不容易在连接池中变成失效连接。

低频或间歇性调用更容易出现:

请求 -> 长时间空闲 -> 服务端关闭连接 -> 客户端再次复用

所以即使调用总量更少,首次失败比例也可能更高。

此外还需要对比:

  • 是否使用相同的 OpenFeign 和 OkHttp 版本。
  • 是否共享相同的连接池配置。
  • 是否经过相同的网关、负载均衡和服务发现链路。
  • 请求是否落到相同的下游实例。
  • 服务调用是否持续均匀,还是存在明显的空闲窗口。

不能只根据“另一个服务失败次数少”就排除下游或连接池问题。

5. 三类配置必须分开理解

排查时最容易混淆的是下面三类配置:

Feign 请求超时
OkHttp 客户端连接池空闲时间
下游服务端 keep-alive 空闲时间

它们属于不同层级,不能放在同一个配置节点下理解。

5.1 Feign 请求超时

常见配置如下:

spring:
  cloud:
    openfeign:
      client:
        config:
          default:
            connectTimeout: 60000
            readTimeout: 60000
            followRedirects: true

这部分控制的是一次请求:

  • connectTimeout:建立 TCP 连接最多等待多久。
  • readTimeout:连接建立后读取响应最多等待多久。
  • followRedirects:是否跟随重定向。

它不控制 OkHttp 连接池中的空闲连接保存多久。

把 connectTimeout 或 readTimeout 从 60 秒改成 30 秒,也不能直接解决失效连接复用问题。因为当前异常不是“等待超时”,而是读取响应头时连接已经结束。

5.2 OkHttp 客户端连接池

Spring Cloud OpenFeign 4.1.x 中,HTTP 客户端公共属性前缀是:

spring.cloud.openfeign.httpclient

连接池空闲连接保留时间可以配置为:

spring:
  cloud:
    openfeign:
      okhttp:
        enabled: true
      httpclient:
        time-to-live: 30
        time-to-live-unit: seconds

虽然属性名叫 time-to-live,但在 OpenFeign 的 OkHttp 自动配置中,它会用于创建:

new ConnectionPool(maxConnections, timeToLive, timeToLiveUnit);

对应 OkHttp ConnectionPool 的 keepAliveDuration,实际语义是连接在池中的最大空闲保留时间。

该版本默认值是:

900 秒

如果服务端或中间负载均衡只保留 60 秒空闲连接,而客户端仍可能保留 900 秒,那么两边配置明显不对齐。

将客户端改为 30 秒后,长时间空闲的连接会更早从连接池淘汰,下一次请求更倾向于建立新连接,而不是尝试复用可能已经被对端关闭的连接。

5.3 下游服务端 keep-alive

下游服务的空闲连接超时属于服务端容器配置。例如使用 Tomcat 时,需要在下游服务自己的配置中核对:

server:
  tomcat:
    keep-alive-timeout: 60s

具体属性取决于实际使用的 Spring Boot 和 Web 容器版本。Undertow、Netty、Tomcat 的配置位置并不相同。

客户端的:

spring.cloud.openfeign.httpclient.time-to-live

不能配置下游服务端的空闲超时。它只影响当前应用创建的 Feign OkHttp 连接池。

6. 超时应该如何对齐

一次服务调用可能经过:

调用方 OkHttp
  -> 服务发现或客户端负载均衡
  -> 网关或四层负载均衡
  -> 下游 Web 容器

链路中任何一层都可能提前关闭空闲连接。

客户端连接池的空闲保留时间应该短于链路中最短的空闲超时:

客户端空闲保留时间
    < min(网关空闲超时, 负载均衡空闲超时, 服务端 keep-alive 超时)

并且要留出一定安全余量。

例如已确认链路最短空闲超时是 60 秒,可以先配置:

spring:
  cloud:
    openfeign:
      httpclient:
        time-to-live: 30
        time-to-live-unit: seconds

这里的 30 秒不是所有系统的固定答案,而是当前链路的保守起点。最终值应根据真实服务端和基础设施超时确定。

设置得过短也有代价:

  • TCP 连接创建次数增加。
  • HTTPS 场景增加 TLS 握手成本。
  • 下游连接建立压力上升。
  • 连接复用率下降。

目标不是完全禁止连接复用,而是在复用收益和失效连接风险之间找到合理区间。

7. OkHttp 重试和 Ribbon 重试不是一回事

配置中还可能存在:

ribbon:
  OkToRetryOnAllOperations: false

这个配置不能说明 OkHttp 不会发生连接级重试。

需要区分:

Ribbon/负载均衡重试
  -> 由负载均衡客户端决定是否重新选择实例、重新调用

OkHttp 连接失败恢复
  -> 由 OkHttp 判断当前 IOException 是否可恢复
  -> 可能丢弃失效连接并重新建立连接

OkHttp 默认启用 retryOnConnectionFailure。因此即使关闭了 Ribbon 对所有操作的重试,Trace 中仍可能看到 OkHttp 的连接级重发。

也不要为了消除 Trace 中的错误 Span,直接关闭 OkHttp 自动恢复。关闭以后,原本能在毫秒级恢复的连接异常会直接变成业务请求失败。

合理的顺序是:

  1. 先降低失效连接进入复用路径的概率。
  2. 保留合理的连接级恢复能力。
  3. 对重试次数和首次尝试失败率建立监控。

8. 本次优化方案

结合当前调用链,优先在调用方业务服务配置:

spring:
  cloud:
    openfeign:
      okhttp:
        enabled: true
      httpclient:
        # 连接池中空闲连接最多保留 30 秒
        time-to-live: 30
        time-to-live-unit: seconds
      client:
        config:
          default:
            connectTimeout: 60000
            readTimeout: 60000
            followRedirects: true

这个调整只改变调用方 Feign 使用的 OkHttp 连接池,不会修改下游服务端配置。

同时需要继续确认:

  1. 下游服务实际使用的 Web 容器及 keep-alive 空闲超时。
  2. 服务之间是否经过网关、负载均衡或 Service Mesh,以及它们的连接空闲超时。
  3. 运行时真正生效的 Feign Client 是否是 OkHttp,而不是 JDK Client 或 Apache HttpClient。
  4. 是否存在业务代码自行创建的 OkHttpClient,从而绕过 OpenFeign 自动配置。

如果项目自行声明了 OkHttpClient Bean 或自定义 Feign 配置,需要确认自动配置中的 ConnectionPool 是否仍被使用。仅仅在 YAML 中写了 time-to-live,不代表自定义客户端一定会读取它。