有人被扣款一次,却收到了两封邮件。或者一个订单发了两次货,或者同一个 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,客户已经取消的订阅却还能继续用。没有任何事件被处理两次。结果仍然是错的。
幂等性在这里帮不上忙。你需要的是在 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 上加唯一索引。下次改这段代码时,把标记挪进事务里。如果你处理的东西有生命周期(订阅、订单、部署),赶在乱序投递写入一个过时状态、而你一个月都没发现之前,把版本守卫加上。