可靠性2026年8月3日9 分钟阅读

同一个 Webhook 收到了两次。这是约定,不是 bug。

多数主流平台采用至少一次(at-least-once)投递:重复一定会发生,不是例外。四种幂等模式,按保护力度从低到高排列。

在发送方看来,这两种情况完全一样平台POST处理程序已执行订单已履约应答在传输中丢失平台POST先崩溃了什么都没发生同样没有应答重试 → 产生重复,但可以通过工程手段处理不重试 → 悄无声息、无法察觉的收入损失。所有平台都选择重试。
发送方的两难,以及为什么多数平台都用同一种方式解决它。

有人被扣款一次,却收到了两封邮件。或者一个订单发了两次货,或者同一个 10:00 的时段来了两个人。你翻日志,发现同一个 event.id 在 14:02:07 处理过一次,14:02:41 又处理了一次,第一反应是平台出了问题。

平台没有出问题。它做的正是它承诺的事。Stripe、Shopify、Twilio 以及其他大多数有分量的 Webhook 发送方,保证的都是至少一次(at-least-once)投递,而“至少一次”是一个含义精确的术语:重复本身就是约定的一部分。把投递当成精确一次(exactly-once)来设计,才是真正的 bug,而且这个 bug 只会在生产环境暴露,通常是在你最忙的那个小时。

TL;DR

  • 至少一次意味着重复迟早一定会发生,而不是很少发生
  • 精确一次投递花多少钱都买不到;精确一次处理可以做到,而且要在你这边实现
  • 在平台的事件 ID 上建一个唯一索引,一行 SQL 就能解决 90% 的问题
  • 无法纳入事务的副作用(邮件、第三方 API)需要“先认领、再提交”(claim-then-commit)模式
  • 乱序到达是没人提前考虑的姊妹问题,它要靠版本号解决,而不是去重

为什么没人卖精确一次

设想一下发送方的处境。它把你的事件 POST 出去,响应还没回来,TCP 连接就断了。你的服务器可能已经完整处理了这个事件,只是应答丢了;也可能在碰到它之前就挂了。从外面看,这两种状态完全相同。

发送方只有两个选择。重试:如果你已经处理过,现在就多了一个重复。或者不重试:如果你从没处理过,这个事件就永远丢了。

两难就是这样,没有第三条路。多数平台选择重试,因为重复的付款通知只是个麻烦,你可以在工程上绕过去;而丢失的付款通知是你察觉不到的收入损失。它们的选择是对的。只是这意味着去重必须放在某个地方,而唯一有足够上下文的地方,就是你的数据库。

现实中重复的来源,大致按我们见到的频率排列:

  • 你的响应太慢,发送方超时了,但你的处理程序还是执行完了
  • 你的应用返回了 200,但前面的负载均衡器或代理返回了 502
  • 你的处理程序成功了,然后在清理时抛出异常,框架把它变成了 500
  • 一次部署在写入之后、响应之前杀掉了进程
  • 你在控制台里点了“重新发送”,然后忘了

注意,这五种里有四种是活已经干完了、应答却丢了。这是常态,不是罕见情况。

模式 1:在事件 ID 上建唯一索引

如果只做一件事,就做这个。

CREATE TABLE processed_events (
  event_id    TEXT PRIMARY KEY,
  provider    TEXT NOT NULL,
  processed_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
const { rowCount } = await db.query(
  `INSERT INTO processed_events (event_id, provider)
   VALUES ($1, $2) ON CONFLICT (event_id) DO NOTHING`,
  [event.id, "stripe"],
);
if (rowCount === 0) return new Response(null, { status: 200 }); // already handled
await doTheWork(event);

有两个细节经常有人搞错。

重复事件返回 200,不要返回 409。在发送方看来,一个被你正确忽略的重复事件是成功的。返回错误会让发送方去重试一个你刚告诉它你已经收到的东西,你就造出了一个循环。

ID 必须用平台的,而不是你自己的:Stripe 的 event.id,GitHub 的 X-GitHub-Delivery,Shopify 的 X-Shopify-Webhook-Id。对请求体做哈希是个诱人的替代方案,也是个陷阱:当发送方没有在请求体里放 ID 时,两个确实不同的事件可能有逐字节相同的请求体。同一个客户、同样金额、相隔一秒的两笔订单,是两笔真实的订单。按请求体哈希去重,你就拒绝履行了一笔别人已经付过钱的订单。

如果平台根本不给 ID,你就只能用真正唯一的业务字段拼出一个合成键。这个处境更糟,值得在代码里写条注释,说明为什么这样选。

模式 2:把标记和业务操作放进同一个事务

模式 1 有个漏洞。如果插入已经提交,随后 doTheWork 抛出异常,你就把一个没处理的事件标记成了已处理。重试会被忽略,事件悄悄丢失,这种失败比你想防止的重复更糟。

如果业务操作是数据库操作,就用事务堵上这个漏洞:

await db.transaction(async (tx) => {
  const { rowCount } = await tx.query(
    `INSERT INTO processed_events (event_id, provider)
     VALUES ($1, $2) ON CONFLICT DO NOTHING`,
    [event.id, "stripe"],
  );
  if (rowCount === 0) return;          // duplicate, commit an empty tx
  await tx.query(`UPDATE orders SET status = 'paid' WHERE id = $1`, [orderId]);
});

两者要么都生效,要么都不生效。这才是正确的写法,你应该默认就用它。

模式 3:无法回滚的副作用,用先认领、再提交

事务管不到 Resend、Salesforce 或物流 API。邮件发出去就是发出去了。这时老实的做法是两阶段标记:

CREATE TABLE processed_events (
  event_id     TEXT PRIMARY KEY,
  status       TEXT NOT NULL,          -- 'processing' | 'done'
  claimed_at   TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  completed_at TIMESTAMPTZ
);

做任何事之前先认领事件。然后干活。再标记为完成。处理中途并发到达的重复事件会看到 processing,于是退让,而不是和你抢。

需要你做决定的部分是:进程在认领和提交之间挂掉,行卡在 processing 状态,该怎么办?你无法知道邮件有没有发出去。两种选择都说得过去,你应该有意识地做出选择:

// Rows stuck in 'processing' >15min are re-claimed. Biases toward
// a possible duplicate email over a definitely-missing one. Flip this for
// side effects where duplication costs money (charges, shipments).
const STALE_CLAIM_MS = 15 * 60 * 1000;

对于收据和通知,重新认领是对的,因为多发一封邮件代价很小。凡是涉及资金或货物流转的,就让它卡着,然后告警通知人来处理。在常量旁边用注释写清楚你选了哪一种、为什么,能让下一个接手的人少困惑一个小时。

模式 4:用版本守卫应对乱序投递

去重在这里帮不了你,而真正会损坏数据的,就是这种失败。

有了重试,事件 #2 就可能跑到事件 #1 前面,而拉开这个差距的正是退避计划:一次等了五分钟的投递,会晚于这五分钟里发出的所有事件到达。一个订阅在 14:00:01 被更新为 active,14:00:03 又被更新为 cancelled。第一次投递失败,五分钟后重试。现在你的数据库里是 active,客户已经取消的订阅却还能继续用。没有任何事件被处理两次。结果仍然是错的。

按顺序发出active 14:00:01cancelled 14:00:03经过一次重试后到达cancelledactive(晚到 5 分钟)没有事件被处理两次。这一行仍然显示 active。解决办法是单调递增的守卫条件,而不是去重表:WHERE updated_at < $new
这里用去重是用错了工具。没有任何事件被投递两次,只是顺序错了。

幂等性在这里帮不上忙。你需要的是在 WHERE 子句里加一个单调递增的守卫条件:

UPDATE subscriptions
SET status = $1, updated_at = $2
WHERE id = $3
  AND updated_at < $2;    -- refuse to apply stale state

平台给你什么单调递增的字段就用什么:version 字段,或者对象本身的 updated_at。不要用你自己的 NOW(),它反映的是你收到事件的时间,而出错的恰恰就是这个顺序。

有些平台既不给唯一 ID,也不保证顺序。HubSpot 在文档里写明它的 eventId 不保证唯一,而且会批量发送事件,不承诺顺序;那该按什么去重是另一个问题。

对于有真正不变量的状态机,再进一步:直接拒绝非法的状态转换,而不是依赖时间戳。从已过期变回生效的转换应该抛出异常,而不是被悄悄应用。

关于测试

只发一个 Webhook 再检查结果,是发现不了这些 bug 的。值得写的测试有:

  • 连续发送同一个事件两次。断言只产生一次副作用
  • 并发发送同一个事件两次。缺失的唯一索引靠的就是这个测试发现,顺序执行的测试永远不会失败
  • 乱序发送两个事件。断言较新的状态生效
  • 在写入标记和执行副作用之间杀掉进程。断言结果符合你在模式 3 中的决定

真正有价值的是并发测试。大多数幂等代码都假设投递是串行的,两次重试在同一毫秒落到两个实例上时,它第一次就会出错。关于“测试能通过”和“代码真能用”之间的差距,我们在你的 Webhook 签名测试什么也证明不了里写得更多。

AnyHook 能做什么

AnyHook 并不能让你省掉自己这边的幂等处理,任何声称能做到的服务都是在过度宣传。重复源自发送方,也可能产生在我们和你之间的网络上,这一层谁都消除不了。

它能改变的,是你能不能看清问题:

  • 每个事件都连同完整请求、尝试次数和最近一次响应一起记录,所以“这是发了两次,还是我的处理程序在一次投递上跑了两次”这个问题,在控制台里就能回答,不用凌晨 2 点去对照三个日志来源
  • 每次投递都明确标出是第几次尝试,重试和新事件可以区分开
  • 同一个事件的每次重试,X-AnyHook-Delivery 请求头都带着相同的事件 ID,即使上游平台不怎么给 ID,你也有一个去重键,用来识别我们和你之间产生的重复
  • 重放是主动操作,并且有标记。重放的事件带有 is_replay 标记,所以一周后回头看,一次回填不会像一波来历不明的重复高峰
  • 日志可检测篡改。当问题是“到底收到了什么,这一行有没有被改过”时,一个可修改的数据库只是一种说法,不是证据:可检测篡改的 Webhook 日志

总结

重复不是平台的缺陷,而是为了一个事件都不丢而付出的代价,这笔交换是对的。今天就在平台的事件 ID 上加唯一索引。下次改这段代码时,把标记挪进事务里。如果你处理的东西有生命周期(订阅、订单、部署),赶在乱序投递写入一个过时状态、而你一个月都没发现之前,把版本守卫加上。

全部文章2026年8月3日 · 9 分钟

别再丢 Webhook 了。

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