重试不是闭眼再来一次:429 限流的指数退避与抖动

举报
yd_293143657 发表于 2026/11/07 00:53:08 2026/11/07
【摘要】 线上调第三方接口,对方限流返回 429。最常见的处理是「等一会儿再试」,代码大概长这样:for (let i = 0; i < 5; i++) { const res = await fetch(url); if (res.ok) return res; await sleep(100); // 固定间隔}单看一个客户端没什么问题。但线上从来不是只有一个客户端。 ...

线上调第三方接口,对方限流返回 429。最常见的处理是「等一会儿再试」,代码大概长这样:

for (let i = 0; i < 5; i++) {
  const res = await fetch(url);
  if (res.ok) return res;
  await sleep(100);            // 固定间隔
}

单看一个客户端没什么问题。但线上从来不是只有一个客户端。

一个能复现的实验

写了 20 个并发客户端,每个都要重试 4 次才成功,服务端每次返回 429。对比两种等待策略,间隔基数都取 200ms:

指标 固定间隔 full jitter
第 3 轮重试的时间跨度 5 ms 518 ms
任意 10ms 窗口内的最大并发 20 4
全部完成总耗时 1523 ms 1282 ms

固定间隔那一列,20 个请求全部落在 5 毫秒 内。对服务端来说,这不是「重试」,这是把第一次的尖峰原样复制了四遍。限流本来是为了削峰,结果被客户端自己又堆了回去。

用了抖动之后,同样的 20 个请求在第 3 轮被摊到 518ms 里,任意 10ms 窗口最多只有 4 个。峰值降到原来的 1/5。

顺带一个反直觉的点:抖动版本总耗时反耏更短(1282 vs 1523)。因为随机值可能接近 0,有些客户端提前撞上了成功窗口。抖动不等于变慢,它只是把等待时间从「整齐」变成「分散」。

为什么会整齐

因为所有客户端的代码一样、基数一样、失败时刻也几乎一样。第 n 次重试的时间是确定值:

t_n = t_0 + base × 2^n

只要 t_0 相同,整条时间线就重合。指数退避只解决了「越试越密」的放大问题,没有解决「大家同时试」的同步问题。

抖动怎么写

业界常用的是 full jitter:等待时间在 [0, base × 2^n] 里均匀取一个值,而不是直接取上界。

const sleep = (ms) =>
  new Promise((r) => setTimeout(r, ms));

async function retry(fn, { base = 200, max = 5, deadline = 15000 } = {}) {
  const start = Date.now();
  for (let i = 0; ; i++) {
    try {
      return await fn();
    } catch (e) {
      const retriable = e.status === 429 || e.status === 503 || e.code === 'ECONNRESET';
      if (!retriable || i >= max || Date.now() - start > deadline) throw e;

      const raw = base * 2 ** i;        // 200, 400, 800 ...
      await sleep(Math.random() * raw); // full jitter
    }
  }
}

只加了一行 Math.random(),代价几乎为囶。

抖动的三种写法

同样的上界 raw,取随机值的方弌有三种:

写法 等待时间 特点
no jitter raw 完全同步,就是上面测的那一列
equal jitter raw/2 + random(raw/2) 保留一半下限,摊开程度中等
full jitter random(raw) 摊得最开,峰值最低

equal jitter 的好处是不会出现「几乎立刻重试」的客户端,代价是峰值仍然比 full jitter 高。两种都比不抖动强,按对方接口的脾气选就行。

另外建议把取消信号接进来,别让用户关掉页面了请求还在后台重试:

async function sleep(ms, signal) {
  return new Promise((resolve, reject) => {
    const t = setTimeout(resolve, ms);
    signal?.addEventListener('abort', () => {
      clearTimeout(t);
      reject(signal.reason);
    }, { once: true });
  });
}

三个比公式更容易出事的点

Retry-After 优先。 服务端如果回了 Retry-After: 3,就按它等,别用自己的退避值硬算。对方明确告诉你什么时候再来,照着做比猜更省事:

const ra = Number(res.headers.get('retry-after'));
const wait = Number.isFinite(ra) ? ra * 1000 : Math.random() * raw;

只重试该重试的。 429 和 503 是「现在不行,等会儿可以」;400、401、403、404 是「你错了」,重试只是把错误放大五倍,还浪费配额。网络层的 ECONNRESET、超时属于瞬时故障,可以重试。

给总时长一个上限。 上面代码里的 deadline 不是可选项。没有它,一个持续 429 的接口能让请求在重试链里挂几分钟,用户侧只看到转圈。重试是补救手段,不是续命手段。

小结

指数退避解决的是「重试间隔要不要递增」,抖动解决的是「多个客户端要不要同时重试"」。前者防业己,后者防主群体。

两行代码的事,但线上那 5 毫秒里挤着的 20 个请求,就是没写第二行的时候。

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

评论(0)

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

全部回复

上滑加载中

设置昵称

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

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

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