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这里有三个重要特征:
- 失败发生在读取 HTTP/1.1 响应头阶段,而不是业务响应码校验阶段。
- 同一个业务请求内的重试很快成功,下游接口本身能够正常处理请求。
- 根 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 自动恢复。关闭以后,原本能在毫秒级恢复的连接异常会直接变成业务请求失败。
合理的顺序是:
- 先降低失效连接进入复用路径的概率。
- 保留合理的连接级恢复能力。
- 对重试次数和首次尝试失败率建立监控。
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 连接池,不会修改下游服务端配置。
同时需要继续确认:
- 下游服务实际使用的 Web 容器及 keep-alive 空闲超时。
- 服务之间是否经过网关、负载均衡或 Service Mesh,以及它们的连接空闲超时。
- 运行时真正生效的 Feign Client 是否是 OkHttp,而不是 JDK Client 或 Apache HttpClient。
- 是否存在业务代码自行创建的
OkHttpClient,从而绕过 OpenFeign 自动配置。
如果项目自行声明了 OkHttpClient Bean 或自定义 Feign 配置,需要确认自动配置中的 ConnectionPool 是否仍被使用。仅仅在 YAML 中写了 time-to-live,不代表自定义客户端一定会读取它。