跳至主要內容

程序员小富大约 5 分钟

大家好,我是小富。

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

线上告警:某个接口 P99 响应时间飙到了 28 秒。排查调用链发现,这个请求经过了 3 层微服务调用,每层的超时时间都是 3 秒。

3 × 3 = 9 秒,最多也就 9 秒啊,怎么会到 28 秒?

因为你忘算了重试

超时 × 重试次数 × 调用层数 = 灾难

大部分微服务框架(Feign、Ribbon、OpenFeign + Resilience4j)默认都带有重试机制。以 Ribbon 为例,默认配置是:

ribbon:
  ConnectTimeout: 1000        # 连接超时 1 秒
  ReadTimeout: 3000           # 读超时 3 秒
  MaxAutoRetries: 0           # 对当前实例重试 0 次
  MaxAutoRetriesNextServer: 1 # 切换到下一个实例重试 1 次
  OkToRetryOnAllOperations: false

看起来 MaxAutoRetries=0 好像没开重试。但 MaxAutoRetriesNextServer=1 意味着会在另一个实例上重试 1 次。

所以一次调用的最大耗时 = (1 + MaxAutoRetries) × (1 + MaxAutoRetriesNextServer) × (ConnectTimeout + ReadTimeout)

= (1 + 0) × (1 + 1) × (1 + 3) = 1 × 2 × 4 = 8 秒

多层调用叠加

假设调用链是 A → B → C,每层都用了上面的默认配置:

最坏情况:

A 调 B(实例1)→ B 调 C(实例1)→ C 真的慢了,3秒超时
                  B 调 C(实例2)→ C 也慢了,3秒超时(切实例重试)
                  B 超时,返回给 A

A 调 B(实例2)→ B 调 C(实例1)→ 3秒超时(切实例重试)
                  B 调 C(实例2)→ 3秒超时
                  B 超时,返回给 A

A 终于拿到超时异常

时间线:

C 层:每次超时 3 秒
B 层:调 C 两次(切实例),共 6 秒超时
A 层:调 B 两次(切实例),每次 B 内部耗 6 秒
总计:6 × 2 = 12 秒
加上连接超时的部分,轻松逼近 16 秒

如果有人把 MaxAutoRetries 也设成了 1(对当前实例也重试),那就是:

C 层每次调用:(1+1) × 3 = 6 秒
B 层调 C:6 × (1+1) = 12 秒(切实例)
A 层调 B:12 × (1+1) × (1+1) = 48 秒

一个 3 秒超时的配置,三层调用就可能卡 48 秒。

更隐蔽的重试来源

除了 Ribbon/LoadBalancer 的重试,还有这些地方可能偷偷加了重试:

重试来源默认行为你可能不知道
Ribbon/Spring Cloud LoadBalancer切换实例重试 1 次很多人不知道这也算重试
Feign 自身的 Retryer默认重试 5 次!Retryer.Default 会重试 5 次
Nginx 的 upstreamproxy_next_upstream 默认在 error/timeout 时切换上游又加了一层
HttpClient 连接池部分实现在连接被服务端关闭时会自动重试对 GET 请求默认重试
Spring Retry如果引入了 @Retryable又叠一层

这些重试机制各自独立、互不感知。Feign 重试了 5 次,每次 Ribbon 又切实例重试 1 次,每次底层 Nginx 还可能切了一次上游。重试次数是乘法关系,不是加法。

Feign 的默认 Retryer 是个大坑

// Feign 默认的 Retryer 实现
public Default() {
    this(100, SECONDS.toMillis(1), 5); // 最多重试 5 次!
}

很多人引入了 OpenFeign 却不知道它默认会重试 5 次。在 Spring Cloud 里,Feign 的 Retryer 默认被设成了 NEVER_RETRY,但如果你手动配置了 Feign 或者直接用了原生 Feign,这个默认的 5 次重试就生效了。

重试还有一个更严重的问题:重试风暴

当 C 服务已经过载(所以响应慢),A 和 B 的重试会让 C 收到的请求量翻倍甚至翻几倍:

正常流量:100 QPS
C 变慢后,每个请求 B 层重试 2 次:100 × 2 = 200 QPS
A 层也重试 2 次:200 × 2 = 400 QPS

C 本来就扛不住 100 QPS,现在要扛 400 QPS
→ C 更慢了 → 更多超时 → 更多重试 → 恶性循环 → 雪崩

重试机制设计的初衷是应对偶发性的网络抖动,当故障是持续性的时候,重试反而成了压死骆驼的最后一根稻草。

正确的配置姿势

原则一:调用链的总超时应该由最上层控制

A 对外承诺 5 秒超时
  → A 调 B 设置 4 秒超时(留 1 秒给自身逻辑)
    → B 调 C 设置 2 秒超时(留 2 秒给 B 的逻辑和重试)

从外到内,超时时间递减,而不是每层都一样。

原则二:禁用非必要的重试

# 关闭 Feign 重试
feign:
  client:
    config:
      default:
        retryer: feign.Retryer.NEVER_RETRY

# 关闭 Ribbon 重试
ribbon:
  MaxAutoRetries: 0
  MaxAutoRetriesNextServer: 0

只在幂等请求(GET、查询)上开重试,非幂等请求(POST、扣款)绝对不要重试。

原则三:加熔断器

@FeignClient(name = "order-service", fallback = OrderFallback.class)
public interface OrderClient {
    @GetMapping("/orders/{id}")
    Order getOrder(@PathVariable Long id);
}

当下游持续超时,熔断器会快速失败(几毫秒返回),而不是等到超时,彻底斩断重试风暴。

总结

误区真相
设了 3 秒超时就最多等 3 秒重试会成倍放大超时时间
MaxAutoRetries=0 就没有重试MaxAutoRetriesNextServer 切换实例也是一种重试
每层都设 3 秒很合理各层超时应递减,且总和要在上层超时之内
重试能提高成功率下游持续故障时,重试会触发雪崩

一句话:微服务调用链中,重试次数是乘法关系,超时时间会指数级膨胀。 每层 3 秒超时 + 每层 1 次重试,三层下来就是 3 × 2³ = 24 秒。不要让"重试"变成压垮系统的最后一根稻草。


我是小富,下期见。

上次编辑于: