[控场AI]
· 9 分钟阅读· 4,555 字

重试机制实战:指数退避+抖动,一行代码避开重试雪崩

重试机制实战:指数退避+抖动,一行代码避开重试雪崩

固定重试会把瞬时故障放大成风暴,指数退避加 full jitter 并只重试可恢复错误才是正解

文章指出,天真重试是宕机的放大器:服务抖动两秒产生两千个失败请求,若客户端立即或固定延迟重试,恢复瞬间会被数倍流量再次击倒。修复需要两个部分——指数退避拉长单客户端节奏,抖动(jitter)把多客户端的重试时刻打散,而抖动才是真正救命的一半。作者推荐 full jitter,即在 0 到指数上限之间均匀随机取延迟,比 equal jitter 摊得更开;同时给最大延迟(如 30 秒)和尝试次数(四到五次)封顶。错误分类上,只重试 429/502/503/504 及网络层故障,绝不重试 400/401/403/404/422,并优先遵循服务器的 Retry-After 头。非幂等写入尤其是 POST 要么不重试,要么携带幂等键去重,写超时需同等谨慎。文末给出约二十行零依赖的 retry 函数,并建议用 onRetry 回调把重试暴露在日志中。

为什么天真的重试会把小故障变成大宕机

一个每秒处理一千请求的服务,突然抖了两秒——负载均衡器闪断、数据库连接池打满,随便什么原因。这两秒里,两千个请求失败了。如果每个客户端都立刻重试,服务恢复的那一瞬间就会涌进两千个额外请求。原本已经岌岌可危的服务,在最糟糕的时刻被三倍流量砸中。这就是重试风暴:重试本身让宕机持续了下去。

固定延迟重试同样救不了你。「等一秒再试」听起来稳妥,但如果所有客户端在同一刻失败,它们就会在同一刻等待,然后又在同一刻回来——你只是把踩踏推迟了一秒,并没有把它摊开。

for (let i = 0; i < 5; i++) {
  try {
    return await call();
  } catch (e) {
    await sleep(1000);
  }
}

五次尝试、间隔一秒,对单独一个客户端没问题;但当一万个客户端跑着同样的循环同时失败时,它就是灾难。修复需要两个部分:指数退避让你不要持续捶打正在恢复的服务,**抖动(jitter)**让客户端们散开而不是同步。人们记住了前者,忘了后者——而后者才是真正救命的那一半。只做退避仍然会聚集:如果所有人都用 delay = base * 2^attempt,所有人的第三次重试都会落在同一个时间点。你需要随机偏移来打破对齐。

rss source: Retry With Backoff and Jitter: The Version I Copy Everywhere

重试风暴的杀伤力来自同步性:故障发生时,成百上千个客户端几乎在同一瞬间失败,如果它们的重试策略完全相同,就会在同一瞬间集体回来。这本质上是一个惊群效应(thundering herd)问题——服务的恢复窗口被自己的客户端堵死,负载不降反升,形成正反馈。更隐蔽的是级联失败:被重试压垮的下游服务会开始拒绝它自己的下游请求,把局部抖动扩散成跨服务雪崩。理解这一点就能明白,重试策略的核心目标不是「提高单次成功率」,而是「控制失败时刻的总负载曲线」。指数退避负责把单客户端的重试节奏拉长,抖动负责把多客户端的节奏错开——两者缺一不可。

Full Jitter:我为什么不用其他抖动方案

抖动有几种流派,我直接告诉你赢家:full jitter。

full jitter 的做法是先算出指数上限,然后在上限内取一个随机延迟。公式很短:

cap = min(maxDelay, base * 2^attempt)
delay = random(0, cap)

以 200ms 为 base、第三次尝试为例,上限是 1600ms,你就 sleep 在 0 到 1600ms 之间的某个值。每个客户端挑自己的数字,它们散布在整个窗口里,而不是挤在顶端。对比「equal jitter」——sleep 半个上限加随机半个,它给每个客户端保留了一个下限,所以散得不够开。full jitter 用满整个范围,摊得最狠。

反直觉的地方在于:允许延迟低到零,感觉不对——不会有客户端几乎立刻重试吗?会,但只有少数,因为随机取值在整个窗口上均匀分布,其余的都落在各处。任意一个瞬间的总负载始终很低,这才是关键。

我还会给最大延迟封顶。不封顶的话 2^attempt 涨得飞快,到第十次尝试就是 200 秒。30 秒上限通常合理。尝试次数也要封顶,因为无限重试只是把真实问题藏起来。我的默认是四到五次。另外,调这些数字之前先测量——base、cap、尝试次数三者相互影响,别因为「传统就是这样」而选 2^attempt,要因为你观察过时序、它确实按你需要的方式摊开了负载才选。

如果你的客户端全是整点触发的 cron 任务,抖动就更重要了。没有它,每个定时任务会在每一分钟的同一秒、永远地砸向你的 API。

哪些错误值得重试,哪些永远不要

重试错误的错误,比不重试更糟。你浪费尝试次数、增加延迟,有时还会造成真实损害。

应该重试:HTTP 429(请求过多)、503(服务不可用)、502 和 504(网关错误),以及连接重置、超时这类网络层故障。这些都是临时性的,稍后重试有真实的成功机会。

永远不要重试:400(错误请求)、401(未授权)、403(禁止)、404(未找到)、422(无法处理)。这些是你的问题,不是瞬时抖动——payload 格式错、token 错、东西根本不存在。重试只会把同样坏掉的请求再发一次,拿回同样的拒绝。你白白花了五个来回,去确认第一次就已经告诉你的事。

针对 429,检查 Retry-After 头。如果服务器明确告诉了你什么时候回来,就用那个数字替代你算出来的退避值——服务器比你的公式更清楚。

幂等性:最容易被跳过、也最容易后悔的部分

GET 重试是安全的,你只是在读。PUT 或 DELETE 通常也安全,因为重复执行会落在同一个状态。POST 是危险的那个。如果你 POST 「创建订单」,在响应返回前超时,然后重试,你可能创建了两个订单——第一个也许已经成功,只是你没看到回复。

所以对非幂等写入,要么不重试,要么带一个幂等键让服务端去重。多数支付和订单 API 支持专门用于此的 key header:每个逻辑操作设置一次,在多次重试间复用。

超时是微妙的情况。读操作的超时可重试;写操作的超时可能意味着写已经发生。对待写超时要像对待 POST 一样谨慎。

幂等键(idempotency key)是一段由客户端生成的唯一标识符(通常是 UUID),随请求一起发送,服务端用它来判断「这个逻辑操作是否已经处理过」。首次收到时正常执行并缓存结果,若同一 key 再次到达,直接返回缓存的结果而不重复执行。关键在于这个 key 必须在整个重试链路中保持不变——同一次逻辑操作的所有尝试复用同一个值,新的逻辑操作才生成新 key。不同厂商叫法不同:Stripe 用 Idempotency-Key 头,部分服务用请求体中的 request_id。没有幂等键时,还有一种兜底思路是「先查询再写入」,但它在并发下不可靠,且增加一次往返。最稳的做法仍然是服务端去重。

我真正会粘贴的那段函数

整个东西就是这样,短是故意的。我宁愿读二十行,也不愿为这件事信任一个依赖。

const RETRYABLE = new Set([429, 502, 503, 504]);

function sleep(ms) {
  return new Promise((r) => setTimeout(r, ms));
}

async function retry(fn, opts = {}) {
  const base = opts.base ?? 200;
  const cap = opts.cap ?? 30000;
  const max = opts.max ?? 5;

  let attempt = 0;
  while (true) {
    try {
      return await fn();
    } catch (err) {
      const status = err.status ?? err.response?.status;
      const retryable =
        RETRYABLE.has(status) || err.code === "ECONNRESET";

      if (!retryable || attempt >= max) throw err;

      const ceiling = Math.min(cap, base * 2 ** attempt);
      const delay = Math.floor(Math.random() * ceiling);
      await sleep(delay);
      attempt++;
    }
  }
}

调用方式就是 await retry(() => fetch(url)),其余它自己处理。

几个使用要点。需要时传入你自己的可重试判断:默认集合覆盖大多数 HTTP 场景,但数据库驱动抛出的错误形状不同。我通常会加一个 onRetry 回调,把每次尝试和它选中的延迟记进日志——重试风暴真发生时,日志里一目了然。

遇到 Retry-After 头时,我在计算抖动之前先读取它,把延迟钳制到不低于该值,服务器的指示优先。

不要把这段函数裹在非幂等写入外面而没有幂等键。函数不知道你的 POST 是危险的,那个判断留在调用点的你身上。

我故意把可重试集合保持得小而明确。对所有 5xx 都重试很诱人,但 500 往往是服务端真实的 bug,不会在 400 毫秒内自己修好。我宁愿快速失败看见它,也不愿把它藏在四次安静的重试后面。

这段实现还有几个容易被忽视的细节。第一,attempt >= max 的判断放在抛出之前,意味着实际尝试次数是 max 次重试加最初的第一次调用,总计 max + 1 次,调参时容易差一。第二,Math.random() * ceiling 生成的是 [0, ceiling) 的左闭右开区间,天然不会取到上限本身,这在统计上无害,但如果你需要严格覆盖整个窗口可以改成 Math.random() * (ceiling + 1)。第三,err.status ?? err.response?.status 同时兼容了原生 fetch 的 Response.status 和 axios 风格的 err.response.status,但如果你用的是其他 HTTP 客户端,错误结构可能挂在 err.cause 或自定义字段上,需要自行适配。第四,递归改成 while (true) 循环可以避免深度重试时的调用栈堆积,也更利于在循环外统一记录尝试次数。

底线

把上面的函数复制进你的下一个项目,然后调三个数字:base 延迟、上限、最大尝试次数。从 200ms、30 秒、五次开始,然后观察日志,在看到真实失败后再调整。

最要紧的那一处改动是抖动那一行:用 Math.random() * ceiling 代替固定等待,这就是阻止你的客户端踩踏一个已经倒下的服务器的关键。其余都是细节。

在把任何写入包进重试之前,先问一句:跑两次安全吗?如果不安全,就加幂等键,或者干脆关掉重试。一个你能看见的缺失错误,好过一个你发现不了的重复订单。

#重试机制#指数退避#full jitter#指数退避抖动#API重试#重试风暴#幂等性#Retry-After
分享:

相关推荐