3. 重试策略:指数退避、抖动、最大重试次数、幂等性设计

流水线里出故障,最直接的反应就是「再试一次」。但怎么试,这里面门道很深。我见过不少团队,一遇到失败就立刻重试,结果系统雪崩,比不重试还惨。

重试不是简单的循环调用。它是一套组合拳:什么时候重试、等多久再试、试几次停手、以及重试会不会造成数据混乱。这四个问题,对应着四个核心概念:指数退避、抖动、最大重试次数、幂等性。

核心原则:重试是容错的最后一道防线,但不是万能药。用得好,系统韧性翻倍;用不好,就是二次灾难的导火索。

3.1 指数退避:别在伤口上撒盐

假设你的服务调一个下游接口,超时了。你立刻重试,又超时。再重试,还是超时。你想想看,如果下游本来就负载高,你这种「闪电连击」只会把它彻底打垮。

指数退避的思路很简单:每次重试的等待时间,按指数增长。第一次等 1 秒,第二次等 2 秒,第三次等 4 秒,第四次等 8 秒……这样给下游留出恢复时间。

我个人习惯用这个公式:

wait_time = base_delay * (2 ^ attempt_number)

其中 base_delay 是基础等待时间,attempt_number 是当前重试次数(从 0 开始)。

举个例子:

重试次数 等待时间(base_delay=1s) 累计等待
0(首次) 0s 0s
1 1s 1s
2 2s 3s
3 4s 7s
4 8s 15s
5 16s 31s

你看,到第 5 次重试时,已经等了半分钟。这段时间足够下游从短暂的故障中恢复过来。

经验之谈:我在项目中遇到过,base_delay 设得太小(比如 100ms),指数增长后还是很快。建议根据你的业务容忍度来定,一般 500ms 到 2s 比较合理。

3.2 抖动:别让所有请求同时爆炸

指数退避解决了「重试太频繁」的问题,但引入了一个新问题:惊群效应。

想象一下,你的服务有 1000 个请求同时失败。它们都按指数退避,第一次重试都在 1 秒后。1 秒一到,1000 个请求同时打向下游。下游刚缓过来,又被一波流冲垮了。

为什么会这样?因为所有请求的退避时间一模一样。

解决方案就是加抖动。在退避时间上引入随机偏移,让每个请求的等待时间都不一样。

我常用的抖动公式:

wait_time = base_delay * (2 ^ attempt_number) + random(0, base_delay * (2 ^ attempt_number))

或者更简单的版本:

wait_time = base_delay * (2 ^ attempt_number) * (0.5 + random(0, 0.5))

这样每个请求的等待时间会在一个范围内随机分布,不会出现「齐射」的情况。

注意:抖动不是越大越好。抖动范围太大,会导致某些请求等待时间过长,影响整体延迟。我个人习惯把抖动控制在退避时间的 50% 以内。

3.3 最大重试次数:及时止损

重试不是无限的。你想想看,如果下游已经挂了,你重试 100 次也没用,只会浪费资源。

所以必须设置最大重试次数。超过这个次数,就放弃重试,走降级或报错流程。

最大重试次数怎么定?我一般看两个指标:

  • 业务容忍度:用户能等多久?比如支付接口,用户等 10 秒就急了,那重试总时间不能超过 10 秒。
  • 故障类型:临时性故障(如网络抖动)可以多试几次;永久性故障(如 404、400)试一次就够了。

我建议把重试次数控制在 3-5 次。太少不够用,太多容易雪崩。

这里有个常见的坑:重试次数和超时时间要联动。比如你设了 5 次重试,每次超时 2 秒,那最坏情况下一个请求要等 10 秒以上。如果上游也在等你,整个调用链就堵死了。

避坑指南:我曾经在一个项目中,重试次数设了 10 次,超时时间没改。结果一次下游故障,所有请求都在重试,线程池被打满,整个服务挂了。从那以后,我每次设重试次数,都会算一下最坏情况下的总耗时。

3.4 幂等性设计:重试的命根子

前面说的都是「怎么重试」,但还有一个更根本的问题:重试会不会造成数据错误?

比如一个下单接口,第一次调用成功了,但响应超时。客户端重试,又下了一单。用户被扣了两次钱。这就是典型的非幂等问题。

幂等性,说白了就是:同一个操作,执行一次和执行多次,结果一样。

实现幂等性,我常用的方法有几种:

方法 原理 适用场景 注意点
唯一请求 ID 客户端生成唯一 ID,服务端去重 写操作(下单、转账) ID 必须全局唯一,服务端要持久化去重记录
乐观锁 用版本号或时间戳判断是否已处理 更新操作 版本号要递增,不能回退
状态机 操作只能在特定状态下执行 流程类操作(订单状态流转) 状态转换要严格定义
数据库唯一约束 利用数据库的唯一索引防重复 插入操作 要处理唯一冲突的异常

我个人最推荐唯一请求 ID 的方式。客户端每次请求带一个 UUID,服务端处理前先查一下这个 ID 是否处理过。处理过就直接返回上次的结果,不再重复执行。

// 伪代码示例
function handleRequest(requestId, data) {
  // 1. 检查 requestId 是否已处理
  if (cache.exists(requestId)) {
    return cache.get(requestId)  // 直接返回上次结果
  }
  
  // 2. 执行业务逻辑
  result = processData(data)
  
  // 3. 缓存结果
  cache.set(requestId, result)
  
  return result
}

重要提醒:幂等性不是「加个唯一 ID」就完事了。你还要考虑:去重记录的过期时间、并发情况下的锁竞争、以及去重记录本身的持久化。我曾经见过一个系统,去重用 Redis,结果 Redis 挂了,所有请求都变成非幂等了。

3.5 完整重试策略框架

把上面四个概念串起来,就是一个完整的重试策略。我画了一张图,帮你理清思路:

重试策略框架图 重试策略核心框架 请求失败触发重试 是否达到最大重试次数? 放弃重试,走降级 计算指数退避 + 抖动 等待计算出的时间 执行重试(确保幂等) 核心逻辑:判断是否重试 → 计算退避时间 → 等待 → 幂等执行 → 循环直到成功或达到上限

这个框架的核心逻辑是:

  1. 请求失败后,先判断是否达到最大重试次数。到了就放弃,没到就继续。
  2. 计算退避时间,加上抖动,然后等待。
  3. 执行重试,但必须确保操作是幂等的。
  4. 如果重试成功,结束;如果失败,回到第一步。

3.6 实战中的几个坑

最后,分享几个我在实战中踩过的坑:

  • 坑一:重试穿透缓存。 有一次我重试时没注意,每次重试都重新查数据库,结果数据库被打爆了。后来在重试逻辑里加了本地缓存,第一次查到的结果后面直接复用。
  • 坑二:重试和超时混用。 重试的超时时间和正常请求的超时时间要分开。重试时网络可能更差,超时时间应该更长一些。
  • 坑三:忽略业务语义。 不是所有失败都适合重试。比如「余额不足」这种错误,重试 100 次也没用。要根据错误码区分哪些值得重试。

我的习惯:每次上线重试策略前,我都会做一次「最坏情况模拟」:假设所有请求都失败,重试策略会不会把系统搞垮。如果会,就调参数,直到安全为止。

重试策略说难不难,说简单也不简单。指数退避、抖动、最大重试次数、幂等性,这四个要素缺一不可。把它们组合好,你的流水线就能在故障面前多扛一会儿。