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 同时打过来,把还在恢复中的服务器重新推过性能拐点。这就是惊群效应。
确定性的退避在设计上就有这个问题。同一时刻失败的每个客户端或事件,都会在同一时刻重试。
随机抖动
随机抖动给重试时间加入随机性,让事件分散开重试,而不是挤成一个尖峰。三种常见策略:
完全抖动(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 收到了两次。