跳至主要內容

十万个why:Feign 超时时间明明设了 3 秒,为什么实际等了 10 秒才返回超时?

程序员小富大约 4 分钟

大家好,我是小富~

《十万个why》系列持续更新中

这个问题我相信不少人遇到过。配置文件里写得清清楚楚:

feign:
  client:
    config:
      default:
        connectTimeout: 3000
        readTimeout: 3000

下游服务一旦变慢或者挂掉,按预期最多 3 秒就应该超时返回。但实际一看监控,超时时间稳定在 10 秒左右。3 秒变 10 秒,多出来的 7 秒是哪来的?

先搞清楚一件事:你配的 Feign 超时,可能根本没生效!!!

很多人不知道,在 Spring Cloud 2020.0 之前的版本(对应 Spring Boot 2.4 之前),Feign 底层的 HTTP 调用不是自己发出去的,而是委托给 Ribbon 的 LoadBalancerFeignClient 来执行的。

你可以在项目里查一下:

mvn dependency:tree | grep ribbon

如果看到spring-cloud-starter-netflix-ribbon,那就对了,Ribbon 是实际干活的那个。

而 Ribbon 有自己的超时配置,默认值写在 DefaultClientConfigImpl 里:

public static final int DEFAULT_CONNECT_TIMEOUT = 2000;
public static final int DEFAULT_READ_TIMEOUT = 5000;

ReadTimeout 默认 5000 毫秒。你在 Feign 层配的 3000ms,到 Ribbon 这层,被它自己的默认值 5000ms 盖掉了。

但 5 秒也不是 10 秒,还差一半,问题出在哪?另一半时间:Ribbon 的默认重试

Ribbon 有两个和重试相关的参数:

// 同一台实例重试次数(不含首次)
ribbon.MaxAutoRetries = 0

// 切换下一台实例的重试次数
ribbon.MaxAutoRetriesNextServer = 1

MaxAutoRetriesNextServer = 1 意味着:第一台实例超时之后,Ribbon 会自动切换到另一台实例再试一次。

实际最大等待时间的计算公式:总耗时 = ReadTimeout × (MaxAutoRetries + 1) × (MaxAutoRetriesNextServer + 1)

代入默认值:5000 × (0 + 1) × (1 + 1) = 5000 × 1 × 2 = 10000ms

10 秒,对上了。

第一台实例等 5 秒超时,切到第二台再等 5 秒超时,一共 10 秒。整个过程中你配的那个 3 秒没有参与任何环节。

为什么 Ribbon 能覆盖 Feign 的配置?

不是猜的,看源码。

FeignLoadBalancer 在执行请求时构造超时参数的逻辑:

@Override
public RibbonResponse execute(RibbonRequest request, IClientConfig configOverride)
    throws IOException {
    Request.Options options;
    if (configOverride != null) {
        RibbonProperties override = RibbonProperties.from(configOverride);
        options = new Request.Options(
            override.connectTimeout(this.connectTimeout),
            override.readTimeout(this.readTimeout)
        );
    } else {
        options = new Request.Options(this.connectTimeout, this.readTimeout);
    }
    // ...
}

this.connectTimeoutthis.readTimeout 来自 Ribbon 的 IClientConfig,不是来自 Feign 的配置。Feign 层面设的超时值在这里被直接无视了。

配置优先级实际上是这样的:Ribbon 服务级别配置 > Ribbon 全局配置 > Ribbon 默认值 >> Feign 配置(不生效)

怎么改

知道原因了,改法很直接。

第一种:配 Ribbon 的参数

既然实际生效的是 Ribbon,就直接配 Ribbon:

ribbon:
  ConnectTimeout: 3000
  ReadTimeout: 3000
  MaxAutoRetries: 0
  MaxAutoRetriesNextServer: 0

MaxAutoRetriesNextServer 建议改成 0。默认的重试行为对 GET 请求问题不大,但如果是 POST 请求,没有幂等保证的情况下重试一次,可能直接造成业务重复——比如多扣一笔款、多发一条短信。

需要对特定服务单独设置的话:

order-service:
  ribbon:
    ReadTimeout: 5000
    MaxAutoRetries: 0
    MaxAutoRetriesNextServer: 0

第二种:升级 Spring Cloud,去掉 Ribbon

Spring Cloud 2020.0 开始,Ribbon 被移除了,替代方案是 Spring Cloud LoadBalancer。升级之后 Feign 的超时配置才是真正生效的那一份:

feign:
  client:
    config:
      default:
        connectTimeout: 3000
        readTimeout: 3000

如果项目短期内升不上去,就老实配 Ribbon,不要在 Feign 层面配了之后以为万事大吉。

第三种:在上层用熔断器兜底

如果你对底层超时配置的优先级已经搞不清了,可以在 Feign 外面再套一层 Sentinel 或者 Resilience4j 做超时控制。

不管底层实际等多久,上层到了时间直接走 fallback。多一层封装,但确定性强。

怎么验证配置到底生效没有

写一个测试接口,故意 sleep 超过超时时间:

@GetMapping("/slow")
public String slow() throws InterruptedException {
    Thread.sleep(15000);
    return "done";
}

用 Feign 调这个接口,看实际多久返回异常:

约 3 秒返回,你的超时配置生效了

约 5 秒返回,Ribbon 默认超时在生效,重试关了

约 10 秒返回,Ribbon 默认超时 + 默认重试,你配的 Feign 超时完全没用

不用猜,跑一次就知道了。

说在最后

这个问题的根源就一句话:在有 Ribbon 的 Spring Cloud 项目里,Feign 的超时配置是不生效的,实际控制超时的是 Ribbon。

配超时的时候,先搞清楚最终是谁在发 HTTP 请求,然后去配那一层的参数。中间封装了几层,就有几层你以为生效了其实没生效的可能。

上次编辑于: