平滑退避是通过随机扰动+时间窗口约束+任务生命周期感知,使重试在时间轴上均匀不可预测,避免集中重试引发雪崩;固定间隔会导致瞬时撞车,无法缓解服务端压力。

什么是平滑退避,为什么不能只用固定间隔重试
平滑退避(Smooth Backoff)不是指指数退避那种“200ms → 400ms → 800ms”的阶梯式增长,而是让重试时机在时间轴上更均匀、更不可预测,避免大量任务在同一毫秒级窗口集中重试,从而缓解服务端瞬时压力。固定间隔(如 setTimeout(fn, 1000))会导致重试请求“撞车”,尤其在服务短暂恢复后,所有客户端几乎同时发起重试,极易引发二次雪崩。
真正有效的平滑,是引入随机扰动 + 时间窗口约束 + 任务生命周期感知——三者缺一不可。
用 Promise 封装带抖动的重试调度器
核心是把每次重试包装成一个可控的 Promise,并在 resolve 前注入带扰动的延迟。不依赖外部库,仅用原生 setTimeout 和 Promise 即可实现:
function smoothRetry(taskFn, options = {}) {
const {
maxRetries = 3,
baseDelay = 500, // 基础延迟(毫秒)
jitter = 0.3, // 抖动系数(0 ~ 1),0.3 表示 ±30%
maxDelay = 5000 // 绝对上限,防失控
} = options;
<p>const attempt = async (retryCount = 0) => {
try {
return await taskFn();
} catch (err) {
if (retryCount >= maxRetries) throw err;</p><pre class="brush:php;toolbar:false;"> // 计算本次延迟:base × 2^retry × (1 ± jitter)
const delay = Math.min(
maxDelay,
baseDelay * Math.pow(2, retryCount) * (1 + (Math.random() - 0.5) * 2 * jitter)
);
// 注意:这里必须返回 new Promise,不能直接 await setTimeout
return new Promise(resolve => setTimeout(resolve, Math.round(delay)))
.then(() => attempt(retryCount + 1));
}};
return attempt(); }
关键点:
-
Math.random() - 0.5生成 [-0.5, 0.5] 区间,乘以2 * jitter得到 [-jitter, +jitter] 扰动范围 - 每次递归调用都生成新延迟,而非复用初始值——否则抖动就失效了
-
Math.round(delay)防止传入小数导致定时器精度异常(部分浏览器会向下取整) - 必须用
new Promise包裹setTimeout,否则无法被await正确等待
如何让重试任务支持取消与降级
真实场景中,用户可能已离开页面、网络已恢复、或更高优任务插入,此时还在排队的重试应能被中断或自动降级。靠 AbortSignal 是最轻量且标准的做法:
function smoothRetry(taskFn, options = {}) {
const { signal, ...rest } = options;
<p>const attempt = async (retryCount = 0) => {
if (signal?.aborted) throw new Error('Retry cancelled');</p><pre class="brush:php;toolbar:false;">try {
return await taskFn();
} catch (err) {
if (signal?.aborted) throw new Error('Retry cancelled');
if (retryCount >= rest.maxRetries) throw err;
const delay = /* 同上计算逻辑 */;
return new Promise((resolve, reject) => {
const timeoutId = setTimeout(resolve, Math.round(delay));
signal?.addEventListener('abort', () => {
clearTimeout(timeoutId);
reject(new Error('Retry cancelled'));
}, { once: true });
}).then(() => attempt(retryCount + 1));
}};
return attempt(); }
使用方式:
const controller = new AbortController();
setTimeout(() => controller.abort(), 8000); // 8秒后强制取消
<p>smoothRetry(fetchData, { signal: controller.signal })
.catch(err => console.warn('最终失败或被取消:', err));</p>
注意:signal 必须在每次递归调用中重新检查,不能只在开头判一次——因为重试过程可能持续数秒,期间状态早已变化。
别忽略任务上下文与错误分类的耦合
平滑退避不是万能解药。对 401(未授权)、404(资源不存在)这类**确定性错误**,重试毫无意义;而对 502/503/网络中断等**临时性错误**才值得退避。硬编码统一退避策略,反而会拖慢错误发现速度。
建议在 taskFn 内部做错误分类,并将结果透出供调度器决策:
- 返回
{ shouldRetry: true, delay: 800 }显式控制下一次延迟 - 抛出带
code: 'NETWORK_TIMEOUT'的自定义错误,由调度器匹配规则 - 对
429 Too Many Requests响应头中的Retry-After字段优先采用,覆盖默认退避逻辑
最易被忽略的一点:平滑退避的“平滑”效果,只有在**多个并发任务共用同一调度器实例**时才能体现。如果每个请求都新建一个 smoothRetry 实例,抖动仍是独立发生的,无法形成宏观上的负载削峰。需要配合全局队列或共享信号源,才能真正落地。










