所有文章
webhook designidempotencyretriesdistributed systems

Webhook 重试与幂等性,不再让人头疼

每个主流 Webhook 服务商——Stripe、GitHub、Shopify、Twilio、Slack——都会故意把同一事件投递不止一次。他们称之为至少一次投递。如果你的处理器没有为此设计,你会得到被重复扣费的客户、重复的 GitHub PR 标签,以及被触发两次的 Slack 通知。修复很小却被低估:把每个处理器都设计成在重复投递下安全。

为什么会出现重复

当服务商没能及时收到一个干净的 2xx 时就会重试。重试窗口各不相同——Stripe:指数退避最长 3 天;GitHub:约 8 小时内 8 次尝试;Shopify:48 小时。多数情况下原始投递其实成功了,只是网络在回程丢了响应。服务商不知道这一点,所以重试。所以你的处理器跑了两次。

这不是要修的 bug,而是系统的一个属性。要为它而构建。

真正有效的幂等模式

每个来自主流服务商的事件都包含一个唯一的事件 ID。Stripe:evt_xxx。GitHub:X-GitHub-Delivery 头(一个 UUID)。Shopify:X-Shopify-Webhook-Id。第一次处理事件时存下这个 ID。重试时去查它——如果你见过它,就返回 200,而不重新运行处理器。

async function handleWebhook(event) {
  const eventId = event.id;

  // Atomic insert-if-not-exists
  const inserted = await db.processedEvents.insertIfNew({
    eventId,
    receivedAt: new Date(),
  });

  if (!inserted) {
    // We've handled this one. Be polite about it.
    return { status: 200, body: 'already processed' };
  }

  // First time we've seen it. Do the work.
  await actuallyHandleEvent(event);

  return { status: 200 };
}

关键的形态:插入和处理器逻辑必须原子地在一起,或者插入必须在前。如果你先调用 handleEvent 再插入 ID,处理器中途崩溃就不会留下记录,重试会重新处理。

去重键存在哪里

  • 与业务写入相同的数据库事务。最好。去重键和副作用要么都提交,要么都不提交。在事件 ID 列上使用唯一约束。
  • 带长 TTL 的 Redis。对低风险事件没问题。把 TTL 匹配到你对接的任一服务商最长的重试窗口——从 7 天起步。
  • 内存缓存。别。你的进程一重启缓存就没了,下一次重试就成功地重新运行了一个已经跑过的处理器。

“非幂等”在实践中是什么样

我们遇到的一个真实例子:一个 Stripe invoice.payment_succeeded 处理器直接调用 account.credit(amount)。每次成功支付都增加余额。大多数时候恰好一次。偶尔两次,当一次重试碰上同一事件。我们发现它,是因为一位客户礼貌地发邮件问,为什么他 50 美元的发票给他账户入了 100 美元。

修复不是去改 Stripe 的重试策略,也不是去和服务商较劲。而是在 events_processed.stripe_event_id 上加一个唯一索引,并在处理器开头加一道防护。整个补丁十一行。

处理器的时间也很重要

服务商有硬性超时。GitHub:10 秒。Discord 交互端点:3 秒。Stripe 和 Shopify 更宽容(约 30 秒),但你慢了照样会被重试。模式都一样:快速响应,异步干活

async function handleWebhook(event) {
  if (await alreadyProcessed(event.id)) {
    return { status: 200 };
  }

  // Don't await the heavy work — queue it.
  await queue.enqueue('process-stripe-event', event);

  // 200 immediately. Worker picks up the event.
  return { status: 200 };
}

队列 worker 处理慢活。Webhook 处理器基本上就是一个去重器 + 一个队列生产者。这个模式在重试下依然正确,因为队列生产者本身是幂等的(按事件 ID 的 insert-if-new),任何重复入队都被 worker 里同一道防护拦下。

拜托,在本地测一测

测试幂等性正是隧道重放被造出来要干的事。在 你隧道的请求历史 中捕获一次事件投递,然后对着你的本地处理器重放十次。第一次调用应当写入你的数据库。接下来九次应当返回 200 而不碰它。看你的数据库日志来确认。

这是大多数团队会跳过、而大多数团队不该跳过的测试。

各服务商的重试策略

  • Stripe:重试 3 天,指数退避。16 次尝试后放弃。
  • GitHub:8 次尝试,约 8 小时内完成。之后把投递标记为失败。
  • Shopify:48 小时,指数退避。在 48 小时内连续失败 19 次后禁用 Webhook 订阅。
  • Twilio:默认一次重试,可配置至 3 次。
  • Slack:带退避的 3 次重试(1 秒、30 秒、5 分钟)。

把你的去重 TTL 设为你在意的最长窗口,再加一点缓冲。

归根结底

重复投递是一份契约,而非缺陷。如果你上线了一个调用 incrementsend_emailcharge_card 而没有去重键的 Webhook 处理器,你就上线了一个 bug。打补丁很小。在客户投诉之后才发现则代价高昂。

关于端到端测试这个,重放调试指南 讲了流程。签名校验在这之前——签名指南 讲了它。加入 PortPreview 等候名单,获得正是围绕这一工作流构建的隧道 + 重放。

常见问题

为什么 Webhook 服务商会把同一事件发两次?
服务商采用至少一次投递。如果没能及时收到 2xx 响应,它们就会重试——即使原始处理器其实成功了、只是响应丢了。把重复投递当作系统保证的特性,而非边缘情况。
怎样让 Webhook 处理器幂等?
用服务商发送的事件 ID(Stripe 的 evt_xxx、GitHub 的 X-GitHub-Delivery 头、Shopify 的 X-Shopify-Webhook-Id)作为去重键。在处理器开头用唯一约束插入它;如果因为已存在导致插入失败,就返回 200 而不重新运行业务逻辑。
去重键应该保留多久?
至少和服务商最长的重试窗口一样长。Stripe 重试 3 天,Shopify 48 小时。7 天的 TTL 能富余地覆盖所有主流服务商。如果有存储空间,就永久保留——它对审计也有用。