Webhook 之谷
摘要
一位工程师反思了自己反复构建 webhook 集成系统的经历,详细阐述了签名验证、去重、缓冲和对账定时任务等隐藏复杂性,并指出 webhook 通知并不能可靠替代完整有序的数据日志。
暂无内容
查看缓存全文
缓存时间: 2026/08/05 16:55
# Webhook 之谷
Source: https://weli.dev/blog/the-valley-of-webhooks/
## 第三次
我把同一个系统建了三遍了——在三家不同的公司,对接三个不同的服务提供商。它从来没有名字,也从来不会出现在路线图上,但过程总是如出一辙:关于你自己客户的真相,存在于别人的数据库里。用户存在身份提供商那边,订阅在 Stripe,退信在发邮件的服务商那里,而你的产品需要这份真相落在本地。于是你订阅 webhook,保留一份副本。
第一次,我以为我在写一个端点:一条路由,解析 JSON,更新一行数据,一个下午的活儿。
那个下午膨胀成了一周。先是签名验证,因为一个开放端点却能改写你的数据库,那是个漏洞。然后是去重表,因为投递会到达两次,而文档还欢快地称此为“至少一次”。接着 handler 加了个缓冲,因为 `membership.created` 有时会先于它指向的 `user.created` 到达。然后是初始化导入器,因为 webhook 只告诉你*订阅之后*发生的事,而它和实时事件赛跑,于是又长出了一套加锁机制。最后是对账定时任务:一个凌晨 3 点爬取服务商列表 API 的作业,和我们的表做 diff,悄悄修正不一致的地方。
我想诚实地说明那个 cron 到底是什么。那是一份书面认罪书。它写着:*我不信任自己建的副本,而且我没办法知道它什么时候错了,所以我只能每晚从头重新推导一遍,永永远远。*
信任的丧失是有原因的。数据漂移从来不会自己宣告;我们的是通过一张工单发现的。一个客户几个月前就取消了,而我们的数据库仍然显示 `active`;某个 `customer.subscription.deleted` 在 Stripe 和我们之间蒸发了,而没有任何东西能够察觉:他们的面板不能——上面显示投递已重试并最终丢弃;我们的日志也不能——日志无法记录一个从未到达的请求。
而这还只是代码层面。每个服务商还带来自己的面板。三家服务商,三个 webhook 配置页,每个对端点如何注册、有哪些事件、测试环境和生产环境如何隔离、签名密钥存放在哪里,都有自己的想法。出问题的时候,调试就是一次巡游:他们的投递日志在一个标签页,我们的日志在另一个,第三个标签页留给当前我怀疑的那个面板。它们长得都不一样,而每一个都得检查。
到我第三次构建这个系统时,我已经不再自欺欺人了。我一开始就把整套东西都算进了预算里(签名、去重、缓冲、引导、定时任务),而在我写第三张去重表的某个时刻,我终于问出了第一次就该问的那个问题。
我到底在这里重建什么?
## 通知不是数据
我在重建一个有序日志。每一次集成,都是试图把一堆通知流还原成它来源的那段有序、完整、当前的历史。
而最荒诞的地方在于:那段历史*是存在的*。它必须存在,因为它就在服务商内部;他们正是靠它渲染自己的面板、事件页面和 webhook 回放工具。服务商把自己有序的日志切碎成一条条 HTTP POST,经由一个既不保证顺序也不保证投递的通道打向我的端点,然后我在自己这边重新拼装日志。每一个其他消费者也都是这么干的,各自独立,各自带着自己的 bug。
这是一幅拼图,制造商手里有原图,把它剪碎,把碎片一封封寄给我,路上丢了几片,有几片寄了两遍,盒子上什么图都没印。而当我拼出来的图和原图对不上时,他们的支持团队还问*我*少了哪些碎片。我不知道,而这正是问题所在:没有任何东西会宣告一个缺口的存在。
这不是任何服务商的 bug。他们的 webhook 工作得和文档分毫不差。问题在于 webhook 的*本质*:一个通知,“有事情发生了,这里是一封关于它的 POST。”通知是触发副作用的好方式,却是传输数据集的很糟方式,而不知从什么时候起,我们开始拿它干第二件事,却没有意识到自己已经换了工种。
## 这怎么就成了常态?
没有人做过这个决定。“webhook”这个术语是 Jeff Lindsay 在 2007 年创造的,早期的用途确实是完美契合:GitHub 的 post-receive hook 触发 CI 构建,或者支付事件 ping 一下你的服务器,好让它发一封收据邮件。它的职责是在事情发生时做一件事,而对这个而言 POST 完美:当忘记也无妨时,发完即忘也无妨。
Webhook 之所以蔓延,是因为它是服务商能提供的最便宜的东西(一次 HTTP POST),也是消费者能接收的最便宜的东西(你已经有了 web 服务器,加一条路由就行)。到 2010 年代初,“我们有 webhook”成了每个 API 落地页上的一个勾选项,而这个勾选项从不区分两种截然不同的工作:
1. **触发一个副作用**:发送收据,启动构建,ping 一下频道。
2. **保持一份服务商数据的正确副本**:这个客户删掉了支付方式,所以你的数据库里也要更新。
第一件事是 webhook 天生的使命。第二件事是我三次都在做的事,而第二件事恰恰是 webhook 所缺失的每一项属性(顺序、完整性、引导、可验证性)都正是你所需要的那个场景。
我们捡起了 2007 年摆在桌上的那个工具,然后花了十五年去弥补。
## 那个山谷
进化生物学里有一个概念让我念念不忘:适应度地形。山峰是好的设计,山谷是坏的设计,而种群攀爬的是它们恰好站着的那个斜坡。陷阱在于*局部最优*:一座小山丘,比它周围的环境好一点,于是进化就停在了那里,即使山谷对面有一座高得多的山峰。要到达更高的山峰,必须穿过那些暂时更差的设计,而进化不做“暂时更差”。
一幅手绘的适应度地形图:webhook 坐在一个慢慢被缓解工具填满的山谷里,而服务商提供的日志则蹲在山谷对面的更高山峰上
用于复制的 webhook 是一个局部最优,而证据就是山谷地面上的那一堆变通方案:签名机制、去重存储、幂等 handler、服务商侧带指数退避的重试队列以及它们背后的死信队列、带回放工具的 webhook 日志(因为消费者老是要求回放)、还有我的凌晨 3 点 cron。
这堆东西上面还长出了一层经济。Svix 的存在是为了让服务商不必自己构建 webhook 投递;Hookdeck 的存在是为了让消费者不必自己构建 webhook 摄取。AWS 把这个山谷作为托管服务卖给你,用 EventBridge 摄取你 SaaS 合作伙伴的事件,用 SQS 排队,用 Lambda 重试你的 handler,而你自己组装这条管道。还有一整个连接器平台行业(Fivetran、Airbyte、每个“统一 API”初创公司),本质上都是伪 CDC:从 webhook 和轮询列表 API 重建出来的变更数据捕获,一个一个定制连接器,当作产品来卖。在数据库内部,捕获变化是个已解决的问题:它叫复制,它能工作是因为有一份日志。在公司之间,我们用门铃把它重建出来。
所有这些变通方案里我最喜欢的是本地隧道。很多服务商提供一个类似 `stripe listen` 的 CLI,它会开一条通到你笔记本电脑的隧道,因为 webhook 无法到达 localhost。想想这是什么:一个由服务商构建并维护的产品,被反复重新发明,它的全部目的就是绕开自己那个原语在投递方向上的限制。当多家服务商都需要提供本地隧道好让开发者能*开发*时,说明这个原语答错了问题。
这些工具没有一个是烂工程;它们都是极好的工程。这正是局部最优的样子:这么多极好的工程被倾注进山谷地面,山谷变得舒适,于是没有人抬头看。
但有些服务商抬过头。Stripe 保留三十天的事件(https://docs.stripe.com/api/events),提供 `/v1/events`(https://docs.stripe.com/api/events/list)——一段有序、可列举的日志,并且建议用它来对账(https://docs.stripe.com/webhooks/process-undelivered-events)。WorkOS 提供一个事件 API(https://workos.com/docs/events/data-syncing/events-api)——一段有序、游标分页的日志,而且当数据一致性重要时,他们自己的文档建议用它而不是 webhook(https://workos.com/docs/events/data-syncing)。日志不断逃逸出来,而每一次逃逸都铸就了自己特制的游标语义、自己的引导方案,没有验证副本的方式,也没有共享契约,但方向是明白无误的。这是趋同演化:不相关的生物,同样的环境压力,同样的翅膀。
日志无处不在,但契约不存在。
## 能不能做得更好?
在伸手去拿新设计之前,值得问问:任何替代方案实际上需要提供什么?我三次集成的经验给出了这份清单:顺序,这样变更无需缓冲就能应用;从零开始的方式,这样初始化就不是一个和实时事件赛跑的独立导入;删除以数据的形式存在,这样“缺席”就不再是失败模式;可恢复性,这样我的停机是我自己的问题,而不是一次数据丢失事件;以及某种验证结果的方法,这样信任就不会退化成凌晨 3 点的 cron。
拿这份清单来衡量,那些显而易见的候选方案都不够。更大力地轮询列表 API,就是把对账 cron 升级成整个策略:它能重建当前状态,但会烧掉大量速率限额去发现大多数东西没变;它对顺序只字不提;而且一个被删除的对象和一个从未存在过的对象看起来一模一样。托管投递——无论是服务商侧的 Svix 还是我这边的 EventBridge 和 SQS——让推送更可靠,但它们仍然是推送:仍然没有引导,仍然没有验证,仍然是假装成数据集的通知。那条路只是夯实了山谷的地面,并没有爬向任何高处。
第三个候选方案,正是服务商们一直在自己半搭的那个:干脆停止推送,让消费者自己读日志。
## 翻转箭头
那么,思想实验来了。如果反过来——不是服务商告诉我们何时有新信息——而是我们问服务商:从上一次检查以来,它有什么新信息要给我们?
一幅手绘草图:左边,webhook 把一团乱箭推向你的端点;右边,你用一把游标拉取一条有序日志
假设服务商为每个集合提供一个 URL,这个 URL 返回一个有序、游标寻址的变更日志,事件里携带完整状态。不带游标请求,你就从头读起,这就是你的引导——没有单独的导入,没有竞争。带着游标请求,你就从上次停下的地方续上。你全部的同步状态就是那个游标。
```
GET /feed/customers?cursor=01J9XQ4R
Prefer: stream
200 OK
Content-Type: application/x-ndjson
{"cursor":"01J9XR2M","operation":"upsert","object":{"id":"cus_123","plan":"pro"}}
{"cursor":"01J9XR2N","operation":"delete","object_id":"cus_099"}
```
发送 `Prefer: stream`,响应就不会结束:每条变更在提交时即时到达,经由一条*你*发起的连接,使用你平时调 REST 端点的那同一个 API key。不发送它就得到一个有界的页面,可以用 cron 去轮询。同一个端点,同一批事件,同一个游标,同一份消费者代码。
这些都不稀奇;这就是一个分页 GET。但请回看我那个膨胀起来的下午,看看它把那一摞东西变成了什么:
- **去重表**没了。每个事件都携带对象的完整当前状态,所以应用一个事件就是一次以 id 为键的盲 upsert,同一个事件应用两次产生相同的结果。
- **排序缓冲**没了,因为日志是有序的。
- **初始化导入器和它的加锁机制**没了。一个新消费者不带游标地读同一个数据流,回放整个集合,然后在一个请求里顺势进入实时变更。
- **丢失的删除**不可能了。墓碑是日志里的事件,它一直待在那里直到我读到它。我那个取消的客户不可能悄无声息地继续是 `active`,因为“缺席”不再是失败模式。
- **端点、签名和隧道**从一开始就不存在。每个连接都是消费者发起的,循环可以在 NAT 后面、在笔记本上、或者在一个定时任务里跑。
这个数据流还可以再带一样东西。当一次读取到达日志末尾时,服务商可以告诉你那里应该有什么:一个计数,以及你当前游标处的当前状态校验和。你对比两者,你就*知道*你的副本是对的,而不是假设它是对的。我的凌晨 3 点 cron——那份书面认罪书——变成了在我本来会想到去调度它之前就已经做完的一次比较。
## 第四次
如果这样一个数据流存在,我第四次构建这个系统就会是一个循环:`GET` 数据流,让 “upsert” 把对象 upsert 进我的数据库、“delete” 把它删掉,然后保存最后的游标。那就是二十行代码,没有路由,没有需要轮换的密钥,没有队列,没有 cron。副本自带正确性证明,而当有人问哪些客户拥有活跃订阅但邮箱在退信时,答案是跨本地表的一次 `JOIN`,不带任何无声的星号。
今天没有人提供这种东西。这就是那个坎,也正是重点所在。
我想看看这个想法能不能经得起被精确写下来,于是我把草拟成了一个协议:**SCROLL**,即 Synchronized Change Replication Over Line Logs,发布在 welidev.github.io/scroll(https://welidev.github.io/scroll/)。它是草稿第 00 版,用的是“征求意见稿”那个意义上的 draft-00。它规定了数据流、游标、流式和轮询模式、检查点、墓碑和保留策略,并标出了我自己信心最低的地方。它也不要求等待服务商来支持,因为一个 shim 层可以从任何服务商现有的 webhook 和列表 API 合成出一个数据流来——这正是我打算用来找出设计哪里错了的方式。
如果你在山谷里住过,如果你写过去重表,调试过对账 cron,或者眼睁睁看着一条删除记录蒸发掉——请读一读它,然后告诉我它在哪里会坏掉。不同意见是我想要的回应;沉默才是失败模式。
相似文章
@corbin_braun: 你随心编程。但你仍然不知道什么是Webhook。5分钟学会。
一条推文,推广一个关于理解Webhook的5分钟快速教程,针对那些善于随性编程但缺乏这一基础知识的开发者。
我们不断重造的车轮
一篇论述软件工程师反复重造已被充分解决的基础设施(如身份验证、后台任务、速率限制和功能开关)的文章,用自定义代码换取经过验证的解决方案,却不得不自行维护和调试这些代码。
@posthog: https://x.com/posthog/status/2069472232712389112
PostHog 解释了为什么“循环”(即自我提示的代理工作流)正在受到青睐,这得益于模型能力的提升以及 Stripe 和 Lovable 等公司的实际成果。该帖子详细说明了构建循环所需的要素,并展示了诸如 PR 监控和 Bug 修复等示例。
@kentcdodds:这绝对是我最喜欢的循环
Kent C. Dodds 分享了一个自动化设置,其中 Kody Koala 暴露了一个 Sentry webhook,触发 Cursor AI 云代理来调查并修复错误,然后合并、部署并在生产环境中验证。
@kentcdodds: 今天我使用 @kodykoala 搭建了一个自动化,它暴露一个 webhook 给 @sentry 用于发送错误,然后 Kody 会启动…
一位开发者构建了一个自动化流程,利用 Sentry 的 webhook 触发 Cursor AI 代理来调查并修复错误,如果风险低则自动合并和部署。