大家好,我是小富。
《十万个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 的 upstream | proxy_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 秒。不要让"重试"变成压垮系统的最后一根稻草。
我是小富,下期见。
