重试机制不能当作通用的解决办法:一次重试风暴的复盘

举报
yd_278798845 发表于 2026/11/03 09:23:14 2026/11/03
【摘要】 事故:一次看似贴心的改动去年冬天,下游推荐服务因为一次发布出现抖动,P99 从 80ms 涨到 800ms。上游调用方本身有 2 秒超时,绝大多数请求最终能正常返回,只是慢了一点。但很少有人注意到,三个月前有人在客户端加了一段“失败后立即重试两次”的代码。故障窗口里,超时的请求变成三倍流量打到已经过载的下游;下游更慢,更多请求超时,更多重试……二十分钟后,整个推荐服务不可用,而它自身的抖动其...

事故:一次看似贴心的改动

去年冬天,下游推荐服务因为一次发布出现抖动,P99 从 80ms 涨到 800ms。上游调用方本身有 2 秒超时,绝大多数请求最终能正常返回,只是慢了一点。

但很少有人注意到,三个月前有人在客户端加了一段“失败后立即重试两次”的代码。故障窗口里,超时的请求变成三倍流量打到已经过载的下游;下游更慢,更多请求超时,更多重试……二十分钟后,整个推荐服务不可用,而它自身的抖动其实只持续了三分钟。

这次事故复盘之后,我养成了一个习惯:看到 for _ in range(3) 包着网络调用,先问几个问题。


一、重试会放大流量,而且不存在免费的重试

重试的本质是:在一次失败的基础上,再制造 N 次新请求。 系统健康时,它提高成功率;系统故障时,它放大负载,而故障时恰恰是下游最承受不住负载的时候。

更隐蔽的是多层重试叠加带来的放大效应:

  • 客户端重试 2 次(共 3 次尝试)
  • API 网关再重试 2 次
  • 服务内部又重试 2 次

3 × 3 × 3 = 27。一次用户点击,到了下游变成 27 次调用。

所以第一条底线是:重试只应在一层实现。 如果调用链上每一层都觉得“重试一下更稳妥”,叠加出来的放大倍数就会超出任何容量规划。


二、不是所有失败都值得重试

对错误进行分类,比写重试逻辑本身更重要:

  • 网络超时、连接重置:可以重试
  • 5xx:需要看具体情况。502/503/504 通常可以重试,500 往往意味着代码 bug,重试没有意义
  • 429:可以重试,但要读取 Retry-After,而不是立刻再次请求
  • 4xx(408 除外):绝不重试。参数错误、鉴权失败,重试一万次结果也一样
  • 非幂等操作:默认不重试,除非有幂等键

最容易出事的,是最后一条。


三、幂等性:重试的前提

重试一个“创建订单”的 POST 请求时,如果第一次请求实际上已经成功、只是响应丢了,第二次就会创建第二笔订单。

标准解法是在协议层引入幂等键:

code.txt

POST /ordersIdempotency-Key: 550e8400-e29b-41d4-a716-446655440000

服务端用这个 key 去重:相同 key 的重复请求直接返回第一次的结果,不再执行一遍。Stripe 的 API 用的就是这个方案。

所以第二条底线是:先确认幂等,再谈重试。


四、退避与抖动:避免重试请求同时涌向服务端

固定间隔的重试同样会带来严重问题。一千个客户端同时失败,都在 1 秒后重试,第二波、第三波请求会整齐地同一时刻涌向服务端,形成重试同步。

解法是指数退避 + 抖动:

code.python

import random def backoff_delay(attempt: int, base: float = 0.2, cap: float = 5.0) -> float:    ceiling = min(cap, base * (2 ** attempt))    return random.uniform(0, ceiling)

attempt 从 0 开始,等待上限依次是 0.2s、0.4s、0.8s……最多 5 秒,实际等待时长在上限内随机取值。这就是 AWS 那篇经典文章里的 full jitter:把等待时间随机打散到 [0, ceiling],让重试请求自然错开。

两个常见误区:只加随机抖动、不加指数退避,等于没有真正错开;抖动幅度远小于退避幅度,也错不开,因为抖动必须和退避处于同一量级。

第三条底线:重试必须带抖动,间隔必须递增。


五、超时要按整体预算来算

还有一类很常见的放大:每一层都设置了自己觉得合理的超时。

客户端 10 秒、网关 5 秒、服务 3 秒、下游 2 秒。看起来每层都很保守,实际上客户端会在第 10 秒才放弃,而这 10 秒里,连接和线程一直在等待一个早已注定失败的结果。

正确做法是传递 deadline:调用方给出一个绝对截止时间,每一跳只使用“剩余时间”,并保证所有重试的总耗时不超过这个 deadline。

code.python

import time def call_with_retry(fn, *, max_attempts: int = 4, deadline: float, base: float = 0.2):    for attempt in range(max_attempts):        try:            return fn()        except RetryableError:            if attempt == max_attempts - 1:                raise            delay = backoff_delay(attempt, base)            if time.monotonic() + delay >= deadline:                raise            time.sleep(delay)

调用方传入 deadline=time.monotonic() + 3,整条链路共享 3 秒预算。这样“重试”就不会退化成“无限期等待”。客户端侧同理:用户取消、页面离开时,应该同时取消所有待执行的重试,而不是让它们继续在后台运行。


六、熔断:当重试已经无法补救时

重试只能处理偶发失败。如果下游持续不可用,再聪明的退避也只是把请求排着队送死。

熔断器把这种场景显式处理:

  • 关闭:正常放行
  • 打开:连续失败超过阈值,直接快速失败,不再调用下游
  • 半开:冷却一段时间后,放少量探测请求,成功则关闭熔断,失败则重新打开

它和重试是互补关系:重试应对“瞬时的抖动”,熔断应对“持续的故障”。没有熔断,重试会把故障期间仅存的恢复机会也消耗掉。


七、三条底线与两个要求

回头看那次事故,问题并不出在“重试”本身,而在于我们把它当成了没有代价的通用手段。现在团队里有一条不成文的规矩:任何重试代码合入前,必须回答:

  1. 这一层是唯一的重试层吗? 上层是否已经重试过?
  2. 这个操作幂等吗? 不幂等的话,幂等键在哪?
  3. 放大倍数算过吗? 最坏情况下,下游会收到多少倍流量?

此外再加两条实现要求:错误分类必须显式写出来;总耗时必须有 deadline。


结语

重试会放大结果。系统健康时,它放大的是成功率;系统故障时,它放大的是故障本身。

可靠的做法,是把重试当作一个需要设计、需要预算、需要监控的机制,而不是随便写成一行 for 循环。下次想加 retry=3 之前,不妨先问自己:这几秒的等待,是在给下游恢复的机会,还是在它最脆弱的时候又增加负担?

【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0)

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。