重试机制不能当作通用的解决办法:一次重试风暴的复盘
事故:一次看似贴心的改动
去年冬天,下游推荐服务因为一次发布出现抖动,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 请求时,如果第一次请求实际上已经成功、只是响应丢了,第二次就会创建第二笔订单。
标准解法是在协议层引入幂等键:
POST /ordersIdempotency-Key: 550e8400-e29b-41d4-a716-446655440000
服务端用这个 key 去重:相同 key 的重复请求直接返回第一次的结果,不再执行一遍。Stripe 的 API 用的就是这个方案。
所以第二条底线是:先确认幂等,再谈重试。
四、退避与抖动:避免重试请求同时涌向服务端
固定间隔的重试同样会带来严重问题。一千个客户端同时失败,都在 1 秒后重试,第二波、第三波请求会整齐地同一时刻涌向服务端,形成重试同步。
解法是指数退避 + 抖动:
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。
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 秒预算。这样“重试”就不会退化成“无限期等待”。客户端侧同理:用户取消、页面离开时,应该同时取消所有待执行的重试,而不是让它们继续在后台运行。
六、熔断:当重试已经无法补救时
重试只能处理偶发失败。如果下游持续不可用,再聪明的退避也只是把请求排着队送死。
熔断器把这种场景显式处理:
- 关闭:正常放行
- 打开:连续失败超过阈值,直接快速失败,不再调用下游
- 半开:冷却一段时间后,放少量探测请求,成功则关闭熔断,失败则重新打开
它和重试是互补关系:重试应对“瞬时的抖动”,熔断应对“持续的故障”。没有熔断,重试会把故障期间仅存的恢复机会也消耗掉。
七、三条底线与两个要求
回头看那次事故,问题并不出在“重试”本身,而在于我们把它当成了没有代价的通用手段。现在团队里有一条不成文的规矩:任何重试代码合入前,必须回答:
- 这一层是唯一的重试层吗? 上层是否已经重试过?
- 这个操作幂等吗? 不幂等的话,幂等键在哪?
- 放大倍数算过吗? 最坏情况下,下游会收到多少倍流量?
此外再加两条实现要求:错误分类必须显式写出来;总耗时必须有 deadline。
结语
重试会放大结果。系统健康时,它放大的是成功率;系统故障时,它放大的是故障本身。
可靠的做法,是把重试当作一个需要设计、需要预算、需要监控的机制,而不是随便写成一行 for 循环。下次想加 retry=3 之前,不妨先问自己:这几秒的等待,是在给下游恢复的机会,还是在它最脆弱的时候又增加负担?
- 点赞
- 收藏
- 关注作者
评论(0)