ElIauk

OpenFeign 超时、重试与熔断

一个接口超时,为什么拖垮整条链路 Spring Cloud 项目里最常见的线上故障不是某个服务挂了,而是某个下游变慢:网关线程被占满,上游服务的 Feign 连接池耗尽,最后整个调用链一起雪崩。OpenFeign 本身只是个声明式 HTTP 客户端,真正决定稳定性的是三组参数:超时、重试、熔断降级。

评论
OpenFeign 超时、重试与熔断

一个接口超时,为什么拖垮整条链路

Spring Cloud 项目里最常见的线上故障不是某个服务挂了,而是某个下游变慢:网关线程被占满,上游服务的 Feign 连接池耗尽,最后整个调用链一起雪崩。OpenFeign 本身只是个声明式 HTTP 客户端,真正决定稳定性的是三组参数:超时、重试、熔断降级。这三者配得不匹配,比不配更危险。

下面以 Spring Boot 3.3 + Spring Cloud 2023.0.x + OpenFeign 4.x 为例,把参数关系和踩坑点讲清楚。

超时:三层时间要逐层放大

Feign 的超时只有两个:建连超时和读取超时。默认值分别是 10 秒和 60 秒,60 秒的读超时对多数接口都太长了,等于把线程白送出去。配置按客户端名分组,default 是兜底:

spring:
  cloud:
    openfeign:
      client:
        config:
          default:
            connect-timeout: 1000
            read-timeout: 3000
            logger-level: basic
          order-service:
            read-timeout: 2000
      circuitbreaker:
        enabled: true

一次请求实际会经过多层超时:浏览器或网关的最外层超时、调用方 Feign 的读超时、被调服务的处理耗时、它内部再调下游的 Feign 超时、数据库查询超时。合理的关系是外层略大于内层,并留出重试和网络抖动的余量。常见错误是网关 3 秒、Feign 5 秒,结果网关先把连接断掉,调用方还在等,日志里只有一堆 502,看不出根因。

另外,Feign 的读超时是单次请求的,不包含负载均衡选实例和排队等连接的时间。高并发下如果连接池不够,请求会卡在 pending acquire,这部分时间不受 read-timeout 约束,表现是接口耗时远超配置值。

重试:默认不重试反而是好事

Spring Cloud OpenFeign 默认使用 Retryer.NEVER_RETRY,也就是不自动重试。很多团队为了“提高成功率”随手加一个 Retryer Bean,却没意识到三个问题:重试会放大下游流量;重试会叠加超时,3 次 × 3 秒就变成 9 秒;对 POST 这类非幂等接口重试,可能产生重复下单。

比较稳妥的策略是:只对幂等查询开启重试,次数控制在 1 到 2 次,并且设置在网关或调用方的最外层,而不是每一层都重试。如果基于 Spring Cloud LoadBalancer 做实例级重试,还需要引入 spring-retry 并显式开启。重试和熔断要一起看:下游已经熔断时,重试没有意义,只会增加无谓等待。

熔断降级:fallback 别把异常吞了

开启 spring.cloud.openfeign.circuitbreaker.enabled 后,Feign 调用会经过熔断器。用 Sentinel 时对应的是 feign.sentinel.enabled=true;用 Resilience4j 则要自己配超时和熔断规则。降级类必须是 Spring Bean,推荐用 FallbackFactory,因为它能拿到异常:

@FeignClient(name = "order-service", path = "/api/orders",
        fallbackFactory = OrderClientFallbackFactory.class)
public interface OrderClient {

    @GetMapping("/{id}")
    OrderVO getById(@PathVariable("id") Long id);
}
@Slf4j
@Component
public class OrderClientFallbackFactory implements FallbackFactory<OrderClient> {

    @Override
    public OrderClient create(Throwable cause) {
        return id -> {
            log.error("查询订单失败, orderId={}", id, cause);
            throw new BizException("ORDER_UNAVAILABLE", "订单服务暂不可用,请稍后重试");
        };
    }
}

最差的写法是 fallback 里返回 null 或空对象,调用方以为查到了,继续往下走,最后在别处报 NPE。降级要么给出明确的业务错误码,要么返回带标记的兜底数据,并且一定把原始异常记下来。

常见坑

坑一:@FeignClient 同时写了 name 和 url。 一旦写了 url,请求就直连该地址,服务发现和负载均衡全部失效。测试环境临时指向某台机器时要注意,别把 url 带进生产配置。

坑二:接口路径重复。 类上写 @RequestMapping("/api/orders"),方法上再写 /api/orders/{id},最终路径变成 /api/orders/api/orders/{id}。类上只放前缀,方法上放相对路径。

坑三:请求头丢失。 Feign 不会自动透传 Authorization、租户 ID、traceId。需要实现 RequestInterceptor 统一补,或者用 @RequestHeader 显式声明。透传前想清楚哪些头可以信任,别把外部传入的租户头原样带到内部服务。

坑四:扫描不到客户端。 启动类没加 @EnableFeignClients,或者 basePackages 没覆盖到接口所在包,启动时报 No qualifying bean of type OrderClient。

排查步骤

  1. 把客户端包的日志级别开到 DEBUG,确认请求 URL、耗时、异常类型,分清是建连失败、读超时还是熔断拒绝。
  2. 绕过 Feign,用 curl 直接打下游实例,排除网络和服务发现问题。
  3. 看下游的线程池、连接池和 GC,很多“Feign 超时”其实是下游被慢 SQL 拖住了。
  4. 检查熔断器状态和 fallback 日志,确认是真实失败还是降级逻辑误伤。
  5. 最后对齐网关、Feign、下游三层的超时值,确保外层大于内层。

参考

评论

推荐阅读

订阅邮件

订阅更新,及时收到最新文章。

作者