Webhook 的山谷:我们用门铃重建了数据库复制
文章摘要
作者说自己在三家不同的公司、面对三家不同的服务商,把同一套系统造了三遍。这套系统从来没有名字,也从来不会出现在路线图上,但套路每次都一样:关于你自己客户的真相存放在别人的数据库里——用户在身份提供商那里,订阅在 Stripe 那里,邮件退信在邮件服务商那里——而你的产品需要在本地拿到这份真相。于是你订阅 webhook,自己留一份副本。
第一次他以为自己只是在写一个 endpoint:一条路由,解析 JSON,更新一行记录,一个下午就能搞定。结果一个下午长成了一周。先是签名校验,因为一个能改数据库的开放端点就是个洞;然后是去重表,因为投递会重复到达,文档还乐呵呵地把这叫「至少一次」;然后 handler 长出了缓冲区,因为 membership.created 有时会比它指向的 user.created 先到;然后是引导导入器,因为 webhook 只告诉你「订阅之后」发生的事,而这个导入过程会和实时事件赛跑,于是又长出了加锁方案;最后是对账定时任务——凌晨三点爬服务商的 list API,和自己的表做 diff,悄悄修掉不一致的地方。
作者对这个 cron 的定性很尖锐:它是一份书面供词。它等于在说:我不信任自己造的这份副本,而且我没有办法知道它什么时候是错的,所以我每晚都要从头重新推导一遍,永远如此。而信任的丧失是有原因的:漂移从不主动宣告自己。他们那次是靠一张客服工单才发现的——某个客户几个月前就取消了,数据库里还写着 active,某条 customer.subscription.deleted 在 Stripe 和他们之间蒸发了,而任何地方都没有能力察觉:服务商的面板显示这条投递重试过、最终被丢弃;自家的日志则根本无法记录一个从未到达的请求。
作者的核心论断是:通知不是数据。他一直在重建的其实是一份有序日志,而这份日志本来就存在——它就在服务商内部,正是他们用来渲染面板、事件页和 webhook 重放工具的东西。服务商把有序日志切碎成一个个 HTTP POST,通过一条既不保证顺序也不保证送达的通道打到你的端点,然后你在自己这边把日志重新拼起来,其他每个消费者也各自独立地拼一遍,各自带着自己的 bug。他把这比作一幅拼图:厂家手里有原图,把它剪碎了一片一片寄给你,路上丢了几片,有几片寄了两遍,盒子上什么图也没印。
至于这怎么成了行业惯例:webhook 一词由 Jeff Lindsay 在 2007 年提出,早期用法(GitHub post-receive 触发 CI、支付事件触发发收据邮件)都是极好的匹配——「发生某事时做某事」,fire-and-forget 完全够用。webhook 流行是因为它对服务商最便宜(一个 POST),对消费者也最便宜(你本来就有 web 服务器)。但这个勾选框从未区分两种截然不同的工作:(1)触发副作用;(2)保持服务商数据的本地副本正确。第二种恰恰需要 webhook 缺失的每一个属性——顺序、完整性、引导、可验证性。
文章用演化生物学的适应度地形作比喻:webhook 做复制是一个局部最优,证据就是山谷底部堆积的一大堆变通方案——签名方案、去重存储、幂等 handler、指数退避重试队列与死信队列、带重放工具的 webhook 日志,以及他自己的凌晨三点 cron。这堆东西上面还长出了一个经济体:Svix 让服务商不必自己做投递,Hookdeck 让消费者不必自己做接收,AWS 把整个山谷打包成 EventBridge + SQS + Lambda 卖给你,而 Fivetran、Airbyte 以及所有「统一 API」创业公司本质上都是伪 CDC——用 webhook 和轮询 list API 一个个手工重建变更数据捕获,当作产品来卖。数据库内部捕获变更是已解决问题,叫复制,之所以能工作是因为有日志;到了公司之间,我们却用门铃重造它。作者最喜欢的一个变通是本地隧道:stripe listen 这类 CLI 存在的唯一目的,就是绕开服务商自己那个原语的投递方向——当多家服务商都得给开发者发一个隧道工具才能做开发,说明这个原语在回答错误的问题。
不过也有服务商抬头看了:Stripe 保留 30 天事件并提供有序可列举的 /v1/events,官方推荐用它对账;WorkOS 提供 Events API,游标分页的有序日志,官方文档在一致性重要时推荐它优先于 webhook。日志一直在往外逃,但每次逃逸都自造一套游标语义、自造引导故事,没有验证副本的办法,也没有共享契约。
作者据此提出替代方案:把箭头反过来。服务商为每个集合提供一个 URL,返回有序的、游标寻址的全量状态变更日志。不带游标请求就是从头读——这就是你的引导,没有单独的导入、没有竞态;带游标请求就从上次的位置继续,你的全部同步状态就是那个游标。带 Prefer: stream 头则响应永不结束,每条变更在提交时流式到达;不带则得到一个有界分页,可以用 cron 轮询——同一个端点、同样的事件、同样的游标、同样的消费者代码。这样一来:去重表没了(每个事件携带对象完整当前状态,按 id 盲写 upsert 天然幂等);排序缓冲没了(日志本就有序);引导导入器和加锁方案没了;丢失的删除变成不可能(墓碑就是日志里的一条事件,会一直躺在那里等你读到);端点、签名、隧道从一开始就不存在,因为所有连接都是消费者发起的,可以跑在 NAT 后面、笔记本上或定时任务里。此外,读到日志末尾时服务商还可以给出当前状态的计数与校验和,让你知道副本是对的,而不是假设它是对的。
作者把这个想法写成了协议草案 SCROLL(Synchronized Change Replication Over Line Logs),托管在 welidev.github.io/scroll,明确标注为 draft-00,并说它不需要等服务商——可以先写一个 shim,从任何服务商现有的 webhook 和 list API 合成出这样一个 feed。他在文末直言:希望得到的回应是反对意见,沉默才是失败。
HN 评论精华
这条讨论有 245 分、100 多条评论,走向大致分三股:技术上的赞同与补充、对 SCROLL 是否真的「新」的质疑,以及一场规模不小的「这篇文章是不是 LLM 写的」之争。
技术共鸣与「早该如此」
- qlkzy 写了全场最系统的一条反驳性长评:他说这个话题每次都让他吃惊——他不理解人们是怎么想到只基于 webhook 来构建同步机制的。webhook 既不是至少一次也不是至多一次,更不保证顺序;如果你真在乎数据,就得把 webhook 投递当成尽力而为的东西,有点像 UDP。他的做法是反过来:先把「万一全坏了怎么恢复同步」的流程做扎实,这几乎必然涉及轮询或查询上游;把这个「灾难恢复」同步流程做好之后,它往往就能当成很多系统唯一的主同步流程。再往上一层,才把 webhook 当作纯粹的「数据变脏了」提示信号。他对作者方案的保留意见是:方案非常事件导向,但真正想要的结果并不是事件,而是「这边的状态和那边的状态一样」;过度依赖事件,最后灾难恢复脚本还是想去比对状态。
- jallmann 补了一句很实用的观点:把灾难恢复机制当作系统正常运转的一部分来跑,好处是它会被经常演练,而不是变成一条极少被走到、需要它时往往已经坏掉的特殊路径。
- alt227 讲了 QuickBooks API 的亲身经历,比原文更惨:创建用户或发票时有时返回错误,但实体其实已经创建了,所以每次都得手动回查;QuickBooks 有时更新很慢还会锁住公司文件,导致存在性检查也做不了、超时,你只能一直查下去;在每分钟成百上千笔交易的量级下这个状态永远追不上。他找官方开发支持,得到的回复是「确保东西在我们系统里正确创建是你的工作」。他最后问:我们是怎么走到「忍受根本无法被信任的系统」这一步的?
- rawgabbit 从会计学角度回应了「这问题 1954 年是不是就被解决了」的调侃:老派系统的做法就是会计 101——变更发生时不立即更新余额,而是写进流水账(日志),日志才是真相来源,状态由日志推导;日志批量异步发送、异步应用,并记录批次成功与否;多个系统往中央服务器发日志,中央服务器排序后再应用;每隔一段时间「结账」,中央服务器不再接受早于某日期的分录。
- zrail 指出 Stripe 的 events API 确实带游标,大客户长期以来就偏好轮询它。作者 weli 回应说 Stripe events API 正是「正确做法」的范例之一,SCROLL 想做的只是把它变成一个通用规范,让所有人都提供类似的事件轮询 API。seandoe 则指出原文里那句「今天几乎没人提供这个」和 Stripe 的存在有点自相矛盾。
对方案本身的质疑
- zffr 提出最直接的技术反对:webhook 下没有变化就不发消息;SCROLL 下消费者必须自己决定何时去问,没有「数据变了」的通知机制,消费者只能悲观地按某个频率轮询。他认为这会带来两个问题——双方都增加无谓网络流量,以及消费者本地模型与服务商之间的延迟比 webhook 更大。lxgr 顺势提议:那就为「有东西变了」这一件事本身发一个空 webhook 通知,语义是「大概有变化,赶紧去拉 SCROLL」。Multicomp 吐槽这不就是二十年前 RESTful 与 XML Web Services 时代的 ETag 长轮询重新发明了一遍吗。
- cobbzilla 认为提出的 feed 方案在功能上和「增量对账」无法区分,文章框架好、文笔好,但并没有真正提出新东西。cadamsdotcom 反驳说这恰恰是好消息:如果你需要的就是日志复制,有一个标准意味着服务商更可能提供一个符合规范的端点,并覆盖那些常见坑(墓碑、游标无法保证单调时怎么办等),因为在规模上任何遗漏的要求都可能是致命的。
- sandeepkd 系统地站在服务商一侧算账:PUSH 和 PULL 各有代价,作者推了三遍 PUSH 吃尽苦头,就寄望 PULL 能解决,但对面的草也一样。PULL 模式下服务器可用性同样存疑,而且要给大量客户支持这种批量数据本质上就是数据库全表扫描,NoSQL 下代价更高;客户即使没有新数据也会打很多次调用,或者产生额外延迟。他还举了 CRL(证书吊销列表)的例子:它就是可以 PULL 的、对所有客户端都一样的、大多数实现里就是 web 服务器上的一个文件,可几乎没人做对,甚至压根不做——而这还是安全领域。
- ninju 追问游标的实现:服务商怎么知道你给的游标指向哪个事件?听上去像是要管理外部状态。作者 weli 回应说规范里游标只需要可按字典序比较,服务商想用时间戳也完全可以。
- Terr_ 引用 2013 年的经典文章《The Log: Real-time data’s unifying abstraction》,并提出一个复杂情况:访问窗口——如果我的系统只被允许看到一年中两段分离的时间里发生的事(因为那是订阅或授权期),数据宿主就得维护「连接历史」的概念,还要在黑暗期插入人造的「初始状态」汇总。zbentley 认为这个需求相当少见,如果真有,更简单的做法是给客户一个从最近一次授权开始的实时流 API,历史数据走另一套「你申请报告,我们一两天后给你一个 S3 预签名 URL」的经典接口。
- __MatrixMan__ 提议用许可链(permissioned blockchain)来解决多方状态一致问题,引发了全场最长的一串争论。Xirdus 强烈反驳:99% 的应用里,任何一份数据都只有一个权威来源,压根没有共识可言;「盲目复制另一个数据库并用对方版本覆盖差异」不是共识协议,它就是从权威源复制数据。他还追问密钥泄露后要怎么轮换、怎么恢复。Terr_ 补刀说在那些不算完全疯狂的场景里,正确答案通常还是早就存在的分布式数据库,「私有区块链」是自相矛盾的营销词,就像把博客卖成「单用户 Twitter」。
- tasn(Svix 创始人,公司在原文中被点名)现身说法:webhook 简单且无处不在,这既是弱点也是优点,也是它被用在不擅长的事(状态同步)上的原因。Svix 为此加了 FIFO 端点、Polling 端点和「Svix Stream」几种有序状态同步方式,并推荐了他们发起、已被 OpenAI、Anthropic、Google 等采用的签名校验规范 Standard Webhooks。
「这是不是 AI 写的」支线
这条支线在讨论中占了不小篇幅。stymaar 直接说文章「基本是 slop,只是把破折号删掉了」。作者 weli 回应说 LLM 最多写了 50 个词,只是被指示调整语序、改进语法和内部一致性。qlkzy 重读后给出更细致的判断:文章处于灰色地带,底下确实有相当分量的人类经验,不是 100% LLM,但也离 100% 人类很远;他补充说自己的评论是在读完文章又读完 SCROLL 规范后写的,而那份规范满是 LLM 腔,节奏和修辞风格「非常 Claude」,视觉风格基本就是 Claude artifact 的签名。他反对的理由很实在:这类 LLM 文风特别擅长把「微小但关键的细节」一笔带过,而分布式系统整个领域就是由微小但关键的细节构成的。thingification 则持不同看法:他的「是不是 LLM」警报不是被那些流行句式触发的,而是被那种毫无动机、喜鹊式的堆砌触发的,而读这篇时他没有这种感觉——很多 LLM 爱用的句式在合适语境下本来就是好用法。SpaceNugget 的评论则捕捉到一种普遍情绪:这类 LLM 加持的技术提案文章,给人的感觉像是有人被 Claude 捧得以为自己发现了什么重要的东西,读起来像误闯进别人自我陶醉的日记。