重试与退避:失败之后怎么办

45 阅读 3049 字 · 约 11 分钟

网络请求失败了,重试一次。这大概是分布式系统里最自然的反应——网络本来就是不可靠的,偶尔超时再发一次就好了。但就是这个"再发一次",如果没有策略地乱来,能把一个小故障放大成一场灾难。重试不是"失败了再试一次"这么简单,它本身就是一门需要设计的工程。

为什么要重试

分布式系统的故障大部分是瞬时的。网络抖了一下、下游 GC 停了 200 毫秒、某个节点刚重启还没就绪——这些故障不需要修复,等一下自己就好了。不重试,用户就白白承受了一次失败;重试一次,大概率就成功了。

统计上,大部分超时重试一两次就能过。这说明重试的"性价比"极高——成本几乎为零,收益是直接把失败率降一个数量级。所以重试在分布式系统里不是可选的,是必须的。

朴素重试的危险

直觉的重试方式是:失败了立刻再试。但这种做法在故障规模稍大时会出事。

重试风暴。 下游服务挂了,1000 个请求超时,1000 个上游同时重试——下游还没恢复,又来 1000 个请求,雪上加霜。如果每个上游都重试 3 次,就是 4000 个请求打在一个正在挣扎的服务上。本来等 2 秒自己能恢复的故障,被重试打得更难恢复。这就是重试放大效应——重试的流量可以轻易翻几倍,把下游彻底压垮。

级联重试。 A 调 B,B 调 C。C 挂了,B 重试 3 次,A 也重试 3 次。A 的一次请求在 C 那里变成了 3 × 3 = 9 次请求。如果链路再长一点,指数级放大。上游以为只是多试了几次,到了最底层已经翻了几十倍。

重试雪崩。 更极端的版本。某核心服务出问题,所有上游同时开始重试,流量瞬间翻倍。服务恢复的瞬间,积压的重试请求一股脑涌进来——又把它打挂了。循环往复,系统在"挂 → 恢复 → 被重试打挂"之间震荡。这就是 2015 年 AWS DynamoDB 那次事故的机制——一个小故障通过重试放大,影响了大量依赖服务。

指数退避:别一失败就猛冲

解决"立刻重试"的第一步是退避——失败后等一下再试,而不是立刻发。

固定退避:每次等 1 秒。比立刻重试好,但如果 1000 个请求同时失败,1 秒后它们又同时来了——同步重试,还是挤在一起。

指数退避:每次等待时间翻倍。第一次等 1 秒,第二次 2 秒,第三次 4 秒,第四次 8 秒。等待时间指数增长,给下游越来越多的恢复时间。这是最常用的退避策略,几乎所有 SDK 和中间件都内置了。

指数退避的好处不只是"等更久"——它还隐含了一个"逐渐放弃"的信号。重试间隔越来越长,说明重试方在说"我不着急了,你慢慢恢复"。下游的压力是逐步释放的,不是一下子涌过来再一下子消失。

抖动:打破同步

指数退避解决了"等更久",但没解决"同时来"。1000 个请求同时失败,第一次重试都等 1 秒——1 秒后 1000 个请求又同时到达。这叫"重试同步"——所有客户端的重试节奏完全一致,像一群人同时冲向出口。

抖动(Jitter) 是给退避时间加一个随机偏移。本该等 1 秒的,变成等 0.7~1.3 秒之间的随机值。1000 个请求的重试时间被打散了——有的 0.7 秒后到,有的 1.3 秒后到,不再挤在同一瞬间。这个随机偏移看起来不起眼,但在大规模系统里,它是防止重试风暴最有效的手段。

AWS 的一篇经典论文里论证过:抖动比退避更重要。 没有抖动的指数退避,在大量客户端同时失败的场景下,依然会形成周期性的流量尖峰。加了抖动,流量就平滑了。实践中的做法通常是:指数退避 + 抖动一起用,退避定基准、抖动打散集中。

重试预算:别无限重试

重试不能无限制地做下去——等了 16 秒、32 秒,下游还是没恢复,继续等下去意义不大。需要设一个上限。

最大重试次数 是最简单的限制:重试 3 次就放弃,返回失败。但这个数字怎么定?重试 3 次、每次指数退避加抖动,总共可能等十几秒——对用户来说已经太久了。重试次数要和调用链的超时预算匹配:如果整条链路的总超时是 30 秒,分配给这一跳的重试预算不能超过几秒。

重试预算(Retry Budget) 是更系统化的思路。给每个调用方分配一个"重试额度"——比如正常请求量的 10%。超过这个额度就不再重试,直接失败。好处是:重试流量被限制在一个比例之内,不会无限放大。下游就算全挂了,重试流量也只多 10%,不会被重试打得更惨。

超时 + 重试的关系。 重试不能超过调用的总超时。如果总超时是 5 秒,第一次花了 3 秒超时,剩余 2 秒——最多再重试一次。重试策略要和超时策略联动:先设超时,再在超时预算内安排重试。

什么时候不该重试

不是所有失败都该重试。失败分两种:

瞬时故障:网络抖动、临时超时、服务重启中。重试有意义——过一会儿大概率会好。

永久故障:参数错误、权限不足、资源不存在。重试没意义——再试多少次结果都一样。重试参数错误的请求,除了浪费资源没有任何收益。

区分方式看错误类型:4xx 错误(客户端错误)不重试,5xx 错误和服务端错误可以重试。网络超时可以重试,但连接被拒绝(connection refused)要看情况——服务可能正在重启,可以重试一次看看。

幂等是重试的前提。 重试意味着同一个请求发多次。如果被调方不幂等,重试就可能产生副作用——重复扣款、重复创建。调用方重试前,必须确认被调方的操作是幂等的,或者请求带唯一 ID 让被调方去重。不幂等的操作重试,比不重试还危险。

和熔断器配合

重试解决的是"瞬时故障恢复",但如果是持续故障呢?下游挂了 5 分钟,重试 3 次还是失败——每次请求都等完退避时间才返回,用户体验极差。

这时候需要 熔断器(Circuit Breaker)。熔断器监控失败率:失败率超过阈值,就"熔断"——在一段时间内直接拒绝请求,不再调用下游。等冷却时间过了,放一个请求试探——成功了就恢复,失败了继续熔断。

重试和熔断是两个层次的保护:重试是"微观"策略,解决单次请求的瞬时故障;熔断是"宏观"策略,解决持续故障下的系统保护。重试是在"试一试",熔断是在"别试了,先歇着"。好的系统两个都有——先重试,重试失败到一定程度触发熔断,熔断期间快速失败不重试,冷却后再试探。

怎么用

  • 退避用指数退避,不是固定间隔:等 1s、2s、4s,不是等 1s、1s、1s
  • 退避一定要加抖动:不加抖动的指数退避在大规模下还是会同步,抖动是打破同步的关键
  • 重试次数设上限:3 次是常见的起点,和调用链的超时预算联动
  • 用重试预算限制总重试流量:重试不超过正常流量的 10%~20%,避免重试放大压垮下游
  • 只重试瞬时故障:4xx 不重试,5xx 和超时可以重试
  • 重试前确认幂等:不幂等的操作重试要带唯一 ID,否则别重试
  • 配合熔断器:重试连续失败到阈值就熔断,别一直重试一个挂掉的服务
  • 链路层级要协调重试:不是每一层都该重试——底层重试了,上层就不重试,避免级联放大

常见误区

  • 重试越多越可靠:重试越多,下游压力越大,自己延迟也越长。3 次够的,试 10 次只会更慢更危险
  • 固定间隔重试就够了:大规模下固定间隔的同步重试会形成流量尖峰,远不如指数退避加抖动
  • 所有失败都该重试:永久故障重试是浪费——参数错误、权限不足,试一万次还是失败
  • 重试不需要考虑幂等:不幂等就重试,等于重复执行——可能比不重试更糟
  • 每一层都重试:A→B→C 三层都重试 3 次,一次请求变成 27 次。不是每层都该重试,靠近底层重试、上层靠超时兜底
  • 重试和超时无关:重试次数 × 每次退避时间 > 总超时预算,重试就是在浪费时间——用户早超时走了

小结

重试是分布式系统应对瞬时故障的基本手段,但朴素重试会引发风暴、级联放大、雪崩。正确策略四件套:指数退避(给下游恢复时间)、抖动(打破同步)、重试预算(限流量)、只重试瞬时故障且幂等的操作。再配熔断器兜底——失败到阈值就停。重试不是"再试一次",是需要设计的工程。