资讯详情

资讯详情

Spring Cloud OpenFeign超时重试的坑:为什么你的幂等接口被调了两次

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

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →