Webhook 的山谷:我们用门铃重建了数据库复制

查看原文 HN 讨论

文章摘要

作者说自己在三家不同的公司、面对三家不同的服务商,把同一套系统造了三遍。这套系统从来没有名字,也从来不会出现在路线图上,但套路每次都一样:关于你自己客户的真相存放在别人的数据库里——用户在身份提供商那里,订阅在 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 写的」之争。

技术共鸣与「早该如此」

对方案本身的质疑

「这是不是 AI 写的」支线

这条支线在讨论中占了不小篇幅。stymaar 直接说文章「基本是 slop,只是把破折号删掉了」。作者 weli 回应说 LLM 最多写了 50 个词,只是被指示调整语序、改进语法和内部一致性。qlkzy 重读后给出更细致的判断:文章处于灰色地带,底下确实有相当分量的人类经验,不是 100% LLM,但也离 100% 人类很远;他补充说自己的评论是在读完文章又读完 SCROLL 规范后写的,而那份规范满是 LLM 腔,节奏和修辞风格「非常 Claude」,视觉风格基本就是 Claude artifact 的签名。他反对的理由很实在:这类 LLM 文风特别擅长把「微小但关键的细节」一笔带过,而分布式系统整个领域就是由微小但关键的细节构成的。thingification 则持不同看法:他的「是不是 LLM」警报不是被那些流行句式触发的,而是被那种毫无动机、喜鹊式的堆砌触发的,而读这篇时他没有这种感觉——很多 LLM 爱用的句式在合适语境下本来就是好用法。SpaceNugget 的评论则捕捉到一种普遍情绪:这类 LLM 加持的技术提案文章,给人的感觉像是有人被 Claude 捧得以为自己发现了什么重要的东西,读起来像误闯进别人自我陶醉的日记。