重试不是闭眼再来一次:429 限流的指数退避与抖动
线上调第三方接口,对方限流返回 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 个请求,就是没写第二行的时候。
- 点赞
- 收藏
- 关注作者
评论(0)