大规模 Webhooks:设计幂等、防重放且可观测的 Webhook 系统
I’m happy to translate the article for you, but I don’t have the ability to retrieve content from external links. Could you please paste the text you’d like translated (excluding any code blocks or URLs you want to keep unchanged)? Once you provide the content, I’ll translate it into Simplified Chinese while preserving the original formatting and markdown.
介绍
Webhook 看起来很简单,直到你的系统同一笔付款被处理了三次,关键事件被丢失,而你无法证明实际发生了什么。本文是一篇面向生产环境的深度探讨,讲解如何构建一个能够抵御重试、重放、乱序投递、提供商错误以及未来自己可能引入的问题的 webhook 接收系统。
大多数 webhook 提供商承诺:
- 至少一次投递
- 失败时重试
- 已签名的负载
它们不承诺的有:
- 顺序性
- 唯一性
- 一致性
- 合理的重试行为
现实: webhook 是一个你无法控制的、不可靠的分布式队列。应当把它当作如此来对待。
常见的失败模式:
- 重复事件被处理两次
- 提供商在成功后仍持续数小时重试
- 事件乱序到达
- 处理过程中出现部分失败
- 时钟偏差导致签名失效
- 静默丢弃且没有审计记录
一个正确的设计必须假设这些情况每天都会发生。
架构概览
Webhook Provider
│
│ POST /webhook
▼
Ingress Layer (Fast, Stateless)
│
│ enqueue
▼
Persistent Event Store
│
│ dedupe + order
▼
Event Processor
│
│ side effects
▼
Domain Services
关键原则
绝不要在 webhook 处理程序中编写业务逻辑。
Webhook 端点必须:
- 验证签名
- 持久化原始负载
- 返回
2xx响应
其他任何操作都应在下游处理。
最小化 webhook 处理程序(Node/Express)
app.post('/webhook', async (req, res) => {
verifySignature(req);
await storeRawEvent(req);
res.status(200).end();
});
如果你的端点耗时超过 1–2 秒,重试将被保证。
需要存储的内容
- 所有请求头
- 原始请求体
- 接收时间戳
- 提供商事件 ID(如果有)
幂等性
如果你的系统不是幂等的,重试会导致数据损坏。
错误的做法
- “我们会检查状态是否已经改变” ❌
- “我们会信任提供者的事件 ID” ❌
正确的做法
创建你自己的幂等键:
const key = hash(`${provider}:${eventType}:${externalObjectId}`);
使用 唯一约束 持久化该键。如果插入失败,将其视为重复并安全地跳过。
排序
提供者不保证顺序。永远不要假设:
- 事件 A 在事件 B 之前到达
- 时间戳是单调递增的
策略
将事件建模为 状态转换 并拒绝无效的转换:
if (!isValidTransition(currentState, nextEvent)) {
logAndIgnore();
}
这使得顺序变得无关紧要,因为只会应用有效的状态变化。
事务性 Outbox 模式
- 在同一个事务中写入领域变更 以及 Outbox 记录。
- 提交事务。
- 异步工作者读取待处理的 Outbox 记录并执行副作用。
- 将 Outbox 记录标记为已完成。
好处
- 防止重复发送邮件、重复收费以及部分失败。
常见错误
- 在验证之前解析 JSON。
- 忽略 Header 大小写。
- 盲目使用系统时钟。
最佳实践
- 对 原始正文 进行验证。
- 检查时间戳时允许少量时钟偏差。
- 失败即关闭:如果验证失败,不要在内部重试。
可观测性与审计
您需要为每个 webhook 回答三个问题:
- 我们收到了吗?
- 我们处理了吗?
- 它改变了什么?
最低要求
- 事件 ID 可在日志中追踪。
- 处理状态持久化。
- 用于失败的死信队列。
如果您不能在 5 分钟内回答这些问题,您的系统就是盲目的。缺少其中任何一项都可能导致无法撤销的 bug。
结论
Webhooks 是 不是回调;它们是不可信任、可重放的消息。一旦你把它们当作如此处理——存储原始负载、强制幂等性、处理 out‑of‑order delivery,并使用 outbox 处理副作用——它们就会变成乏味、可靠的基础设施。而乏味的基础设施才是目标。