在微服务调用链路中,一个看似合理的“超时重试”配置,往往会导致非幂等接口被重复执行,引发数据错乱、重复扣款或状态机异常。
核心矛盾:超时 ≠ 失败
理解这个问题的前提,是必须区分“网络超时”与“业务失败”。
当 Feign 抛出 SocketTimeoutException 或 ReadTimeoutException 时,仅代表客户端在规定时间内没有收到响应。此时服务端的状态是完全未知的:
- 请求可能根本没到达服务端;
- 请求已到达并处理成功,但响应在网络传输中丢失;
- 请求正在服务端排队或执行中,尚未返回。
如果此时触发自动重试,且目标接口不具备严格的幂等性,就会导致同一笔业务被多次执行。所谓的“幂等接口被调了两次”,本质上是因为重试策略将“超时”错误地等同于“可安全重试的失败”。
陷阱一:GET 请求的隐式重试
OpenFeign 默认集成的 Retryer.Default 对 GET 请求有特殊处理。即使你没有显式配置任何重试策略,GET 请求在遇到连接异常或超时时,仍会自动重试最多 5 次(初始间隔 100ms,最大间隔 1s)。
这个设计基于 HTTP 语义规范(GET 是安全且幂等的),但在实际微服务开发中,大量开发者使用 GET 传递复杂查询参数,甚至用 GET 触发某些副作用操作(如导出、同步、状态刷新)。一旦这些非纯查询的 GET 接口发生超时,默认的隐式重试就会直接导致重复执行。
排查与验证:开启 Feign 的 FULL 级别日志,观察超时后是否有连续的相同请求发出。若确认存在非预期重试,需自定义 Retryer 覆盖默认行为:
@Bean
public Retryer feignRetryer() {
// 禁止所有自动重试,包括GET
return Retryer.NEVER_RETRY;
}
陷阱二:POST/PUT 的重试条件误判
对于非 GET 请求,OpenFeign 默认不重试。但很多团队会通过自定义 Retryer 或集成 Spring Retry / Resilience4j 来为 POST/PUT 添加重试能力。问题出在重试条件的粒度过粗。
常见错误配置是将 IOException、FeignException 等大类异常全部纳入重试范围。这会导致以下场景被错误重试:
- 服务端返回 500 内部错误(可能是业务校验失败,而非临时故障);
- 服务端返回 429 Too Many Requests(限流信号,重试只会加剧问题);
- 响应体解析失败(服务端已成功处理,仅响应格式异常)。
正确做法:仅对明确的瞬时性网络异常启用重试,并对 HTTP 状态码做白名单过滤:
@Bean
public Retryer feignRetryer() {
return new Retryer.Default(100, 1000, 3) {
@Override
public void continueOrPropagate(RetryableException e) {
// 仅允许连接超时和读取超时重试
if (!(e.getCause() instanceof SocketTimeoutException
|| e.getCause() instanceof ConnectException)) {
throw e;
}
// 若携带了HTTP状态码,仅对503/504重试
Integer status = e.status();
if (status != null && status != 503 && status != 504) {
throw e;
}
super.continueOrPropagate(e);
}
};
}
陷阱三:Feign Retryer 与 LoadBalancer Retry 的叠加放大
这是最容易被忽视的坑。在 Spring Cloud OpenFeign 体系中,Feign 自身的 Retryer 与 Spring Cloud LoadBalancer (SCLB) 的重试机制 是两层完全独立、且在调用栈上呈嵌套关系的重试逻辑。如果不理解它们的执行顺序和叠加原理,极易在生产环境引发指数级的“重试风暴”。
假设 Feign Retryer 配置 maxAttempts=3,LoadBalancer 配置 maxRetriesOnSameServiceInstance=1 + maxRetriesOnNextServiceInstance=1,则单次调用的最大实际请求次数为:
3 × (1 + 1 + 1) = 9 次
这意味着一次超时可能触发多达 9 次服务端调用。如果接口不是严格幂等的,这种放大效应会直接将偶发超时演变为批量数据事故。
3.1 调用栈与执行顺序:谁在外层,谁在内层?
Feign Retryer 在外层,SCLB Retry 在内层。
当通过 Feign 接口发起一次调用时,底层的同步调用栈如下所示:
[外层] Feign.SynchronousMethodHandler.invoke()
└── [外层循环] Feign.Retryer (控制整体重试次数)
└── feign.Client.execute(request, options)
└── [Spring Cloud 代理] FeignBlockingLoadBalancerClient.execute()
└── [内层循环] Spring RetryTemplate (SCLB 重试机制)
└── 1. ReactiveLoadBalancer.choose() (选择实例)
└── 2. [底层 HTTP] delegate.execute() (如 HttpURLConnection/ApacheHttpClient)
3.2 外层:Feign Retryer
- 位置:
feign.SynchronousMethodHandler 的核心 while(true) 循环。
- 职责:包裹整个
Client.execute() 过程。它不关心底层是直连 IP 还是走了负载均衡,只关心 Client.execute() 最终是否抛出了 RetryableException。
- 触发条件:底层抛出
IOException(如 SocketTimeoutException)或 ErrorDecoder 将其判定为可重试的异常(如 HTTP 503)。
3.3 内层:Spring Cloud LoadBalancer (SCLB) Retry
- 位置:
FeignBlockingLoadBalancerClient.execute() 内部。
- 前提条件:必须引入
spring-retry 依赖,且配置 spring.cloud.loadbalancer.retry.enabled=true。
- 职责:包裹“选择实例并发送 HTTP 请求”的动作。它具备实例感知能力,可以在同一个实例上重试,或切换到下一个实例重试。
3.4 内层逻辑:SCLB 的实例级重试流转
SCLB 的重试由 Spring 的 RetryTemplate 驱动,其核心配置参数有两个:
maxRetriesOnSameServiceInstance (默认 0):在同一实例上的最大重试次数。
maxRetriesOnNextServiceInstance (默认 1):切换到下一个实例的最大重试次数。
执行流转时序(假设 Same=1, Next=1):
- 初始尝试:LoadBalancer 选出实例 A,发起 HTTP 请求。失败(如
SocketTimeoutException)。
- 同实例重试:检查
maxRetriesOnSameServiceInstance (1)。在实例 A 上再次发起请求。失败。
- 切换实例:同实例重试耗尽。检查
maxRetriesOnNextServiceInstance (1)。LoadBalancer 选出实例 B。
- 新实例尝试:在实例 B 上发起 HTTP 请求。失败。
- 新实例同实例重试:检查
maxRetriesOnSameServiceInstance (1)。在实例 B 上再次发起请求。失败。
- 内层耗尽:SCLB 重试策略耗尽,将最后一次的异常(如
IOException)向上抛出给 Feign 的 Client.execute()。
内层最大 HTTP 请求数公式:
(注:1 代表初始尝试)
3.5 外层逻辑:Feign Retryer 的盲重试与异常转换
当内层 SCLB 耗尽并抛出异常后,控制权交还给外层的 Feign Retryer。
- 异常转换:Feign 的
ErrorDecoder 拦截内层抛出的异常。如果是 IOException(包含超时),默认会被包装为 RetryableException。
- 重试决策:Feign 调用
Retryer.continueOrPropagate(RetryableException)。
- 如果
Retryer 决定不重试,直接抛出异常,调用链路终止。
- 如果
Retryer 决定重试,线程 sleep 指定时间(退避策略),然后重新执行整个 Client.execute()。
- 状态丢失(盲重试):Feign 是“无状态”的,它不知道内层 SCLB 刚才尝试了哪些实例。当 Feign 触发重试时,内层 SCLB 会从零开始重新走一遍 LoadBalancer 选实例和重试的流程。这意味着,底层 HTTP 客户端极有可能再次选中刚才已经失败的实例 A 或 B。
外层最大 HTTP 请求数公式:
(注:Feign 默认 Retryer 的 maxAttempts 为 5)
3.6 核心冲突点与生产治理策略
这两层重试机制在设计哲学上存在根本冲突:
- SCLB 重试:侧重于网络层/实例级容错(Instance Failover),解决的是“这个节点挂了,我换一个节点”的问题。
- Feign 重试:侧重于业务层/应用级容错(Application Retry),解决的是“这次调用失败了,我整体再试一次”的问题。
生产环境治理:永远不要让两层重试在同一故障域内同时生效。
治理方案:明确职责边界,避免双层重试同时生效。推荐两种策略:
| 策略 |
Feign Retryer |
LoadBalancer Retry |
适用场景 |
| 网络层容错优先 |
NEVER_RETRY |
启用,仅重试连接异常 |
服务实例易宕机、滚动发布频繁 |
| 业务层补偿优先 |
精细控制,仅重试超时 |
禁用 |
接口幂等性难保证,依赖业务补偿 |
策略 A:禁用内层,保留外层(推荐用于强幂等接口)
关闭 SCLB 的重试,让 Feign Retryer 接管所有重试逻辑。配合自定义 Retryer 实现指数退避。
# application.yml
spring:
cloud:
loadbalancer:
retry:
enabled: false # 彻底关闭内层 SCLB 重试
// 外层 Feign 自定义重试器(带抖动退避)
@Bean
public Retryer feignRetryer() {
// 初始 100ms,最大 2s,最多重试 3 次
return new Retryer.Default(100, 2000, 3);
}
策略 B:禁用外层,保留内层(推荐用于非幂等/写接口)
关闭 Feign 的重试,仅依赖 SCLB 进行实例切换。这能确保即使发生超时,请求也只在不同的实例间流转,而不会被 Feign 整体重新触发。
// 彻底关闭外层 Feign 重试
@Bean
public Retryer feignRetryer() {
return Retryer.NEVER_RETRY;
}
# application.yml
spring:
cloud:
loadbalancer:
retry:
enabled: true
max-retries-on-same-service-instance: 0 # 不在同一实例重试(避免重复执行)
max-retries-on-next-service-instance: 1 # 仅切换一次实例
retry-on-all-operations: true # 允许对 POST/PUT 等非 GET 请求进行实例切换
策略 C:精细化异常隔离
通过自定义 Feign 的 ErrorDecoder,将“网络超时”与“业务异常”严格剥离。仅对明确的网络断开(如 ConnectException)交由外层重试,对读取超时(SocketTimeoutException,此时服务端可能已处理)坚决不重试。
@Bean
public ErrorDecoder customErrorDecoder() {
return (methodKey, response) -> {
Exception decoded = new ErrorDecoder.Default().decode(methodKey, response);
if (decoded.getCause() instanceof SocketTimeoutException) {
// 读取超时:服务端状态未知,禁止外层重试,直接抛出非 Retryable 异常
return new FeignException.FeignServerException(response.status(), "Read Timeout", response.request());
}
// 连接超时或其他网络异常:允许外层重试
return decoded;
};
}
Feign Retryer 是宏观的战术重试,SCLB Retry 是微观的实例重试。
四、根本解法:重试不能替代幂等设计
无论重试策略多么精细,都无法 100% 避免“超时后服务端已处理”的情况。重试只是提升可用性的手段,幂等才是保障正确性的底线。
对于所有可能被重试的接口,必须在服务端实现幂等控制:
- 写操作:基于业务唯一键(订单号、流水号)做数据库唯一约束或 Redis 原子校验,确保重复请求产生相同结果;
- 读后写操作:采用版本号/OCC 乐观锁,防止并发重试导致的数据覆盖;
- 状态变更操作:引入状态机校验,拒绝非法状态流转(如“已支付”状态不再接受支付回调)。
五、生产检查清单
- [ ] 是否已关闭 GET 请求的隐式重试,或确认所有 GET 接口均为纯查询;
- [ ] POST/PUT 的重试条件是否仅包含瞬时网络异常,排除了业务异常与限流响应;
- [ ] Feign Retryer 与 LoadBalancer Retry 是否避免了叠加放大,总重试次数是否在可控范围内;
- [ ] 所有可能被重试的接口,服务端是否具备严格的幂等保障;
- [ ] 重试间隔是否采用指数退避+随机抖动,避免重试风暴;
- [ ] 是否开启了 Feign FULL 日志用于线上问题排查,而非仅在测试环境开启。
超时重试本身不是问题,将“超时”无差别地当作“可重试失败”才是问题。在分布式系统中,永远假设网络是不可靠的,也永远假设重试可能发生。