可靠性2026年4月18日7 分钟阅读

Webhook 重试的指数退避与随机抖动

为什么简单的指数退避会引发惊群效应,随机抖动(jitter)怎么解决它,以及生产级的重试计划到底长什么样。

尝试间隔 · 指数增长现在5分钟30分钟3小时18小时放弃status=failed · 请求体仍在日志中 · 仍可重放
每次尝试后间隔都会拉大的重试计划。最后一次尝试并不是最后的机会:重试耗尽的事件仍然留在日志里,仍然可以重放。

TL;DR

  • 简单的指数退避(delay = base * 2^attempt)会造成惊群效应(thundering herd),因为所有客户端会同时重试
  • 加入随机抖动(jitter),把重试分散开。等值抖动(base/2 + random(base/2))是最佳平衡点
  • 限制最大延迟,限制重试总时长,并区分 4xx(不重试)和 5xx(重试)
  • 连续失败大约 20 次后,在转发目标层面熔断。重试修不好一个坏掉的端点
  • 低成本的重放胜过激进的重试:如果重放只需点一下,重试只需要处理临时性故障就够了

我在实际项目里见到的重试代码,大多长这样:

for (let i = 0; i < maxRetries; i++) {
  try {
    return await deliver(event);
  } catch {
    await sleep(1000 * Math.pow(2, i));  // 1s, 2s, 4s, 8s...
  }
}

这就是指数退避,它比固定间隔重试要好。但它也有一个只会在生产环境暴露的问题:几百个 Webhook 同时失败,又在同一时刻一起重试,把你正在恢复的服务器重新砸趴下。

解决办法是随机抖动。下面讲为什么,以及怎么做才对。

惊群效应

假设你的端点在 T+0 宕机。接下来的 60 秒里,500 个 Stripe Webhook 到达,全部失败。现在每个重试队列里都有 500 个第一次重试排在 T+5min(或者你设置的基础延迟),500 个第二次重试排在 T+10min,以此类推。

你的端点在 T+4min 恢复上线。恭喜:现在有 500 个并发重试在 T+5min 同时打过来,把还在恢复中的服务器重新推过性能拐点。这就是惊群效应。

无随机抖动 · 所有重试挤在同一时刻500 次重试,同一毫秒Equal Jitter · 次数相同,曲线相同分散在整个窗口内T+0T+5minT+10min
同样的 500 次重试,同样的退避曲线。唯一的区别是延迟是一个固定值,还是一个范围。

确定性的退避在设计上就有这个问题。同一时刻失败的每个客户端或事件,都会在同一时刻重试。

随机抖动

随机抖动给重试时间加入随机性,让事件分散开重试,而不是挤成一个尖峰。三种常见策略:

完全抖动(full jitter)

const base = 1000 * Math.pow(2, attempt);  // 1s, 2s, 4s, 8s
const sleep = Math.random() * base;         // 0 to base

每次重试的等待时间在 0 到完整退避间隔之间。分散程度最大,但有些重试几乎会立即触发,可能显得效率不高。

等值抖动(equal jitter)

const base = 1000 * Math.pow(2, attempt);
const sleep = base / 2 + Math.random() * (base / 2);

每次重试的等待时间在退避间隔的一半到全部之间。有下限,分散效果仍然不错。

去相关抖动(decorrelated jitter)

let sleep = baseDelay;
// on each retry:
sleep = Math.min(maxDelay, baseDelay + Math.random() * (sleep * 3 - baseDelay));

每次重试的延迟是上一次延迟的随机倍数。适合你希望退避时间持续增长、又不想用固定公式的场景。

对于 Webhook,等值抖动是最佳平衡点。它让重试大致保持指数增长,同时化解惊群。

生产级的重试计划是什么样的

除了随机抖动,有经验的团队还会这样做。

限制最大延迟。别让第 10 次尝试等上 17 分钟。设一个上限,比如 60 秒,超过上限就一直停在上限,直到放弃。

限制重试的总时长,而不只是尝试次数。“重试 24 小时”是比“重试 8 次”更清晰的 SLA,因为后者取决于一套没人记得住的延迟计算。

区分临时性错误和不可重试错误。转发目标返回的 4xx,尤其是 401、403 或 404,重试也不会成功。服务器是在告诉你请求本身有问题。只对 5xx、429、超时和连接错误重试。

把重试状态展示出来。你的用户需要知道某个事件正处于 5 次中的第 3 次尝试,以及下一次重试的具体时间。如果这些看不到,排查起来会很痛苦。

在转发目标层面熔断。如果一个转发目标已经连续失败 20 次,就暂停一段时间不再重试它。出了重试修不好的问题,继续重试只是在烧计算资源。

AnyHook 怎么处理

AnyHook 的重试策略按套餐调整,底层使用 QStash 托管的重试基础设施。投递次数为 Free 3 次、Pro 5 次、Scale 10 次,按固定的指数阶梯进行:5 分钟、30 分钟、3 小时、18 小时,之后每次间隔 24 小时。这些延迟不加随机抖动。投递超时上限分别为 60 秒、120 秒和 5 分钟。连续失败 20 次后,熔断器触发,暂停向该转发目标投递(首次 10 分钟,再次触发时依次为 30 分钟、2 小时和 24 小时),并发邮件通知你,所以不会出现重试风暴。除 408 和 429 以外的 4xx 完全不重试,失败告警会说明原因,因为在这种情况下重试没用,你需要修正请求的结构。

重试耗尽并不会丢掉事件。它会以 status=failed 保存在你的日志里,在套餐的重放窗口内(Free 3 天,Pro 30 天,Scale 90 天),你随时可以从控制台或通过 API 手动重放。

捷径

一个会改变你对重试看法的认识:如果重放的成本很低,你就不需要激进的重试。

如果重放只需点一下,在一小时内重试 3 次就够了。大多数临时性故障会被自动兜住,遇到少见的长时间宕机,等服务器恢复正常后,由人来重放这批失败的事件。

对比一下只靠重试的系统:为了保险起见,你把重试窗口延长到 72 小时,这意味着向一个你已经知道宕机的端点白白投递 72 小时。

低成本的重放严格优于激进的重试,而这正是 AnyHook 的设计核心。

总结

把重试当作处理临时性故障的快速通道,而不是持久化和重放的替代品。在糟糕的几周里还能稳住的系统,都是失败的投递总能恢复的系统,因为事件在投递之前就已经记录下来了。

各个发送方实际的重试计划都在我们的 Webhook 平台参考里。重试还有一个谁也控制不了的硬上限:一直失败下去,Shopify 可能会删除订阅,Stripe 会禁用端点。而重复和乱序投递之所以必然发生、而不是偶尔发生,正是因为重试:同一个 Webhook 收到了两次。

全部文章2026年4月18日 · 7 分钟

别再丢 Webhook 了。

只改一个 URL。获得重试、事件日志和一键重放。也可以先亲眼看看:注册后跑一次 30 秒的故障演练,看一个事件如何挺过你的服务器宕机。不需要服务器。