多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

网络钩子困境待解:SCROLL 协议能否带来新转机?

网络钩子困境待解:SCROLL 协议能否带来新转机? 网络钩子Webhooks的困境2026 年 8 月 5 日机器之心编辑部为您带来网络钩子相关困境的深度解析。如今在三家不同公司为三个不同供应商搭建同一系统时虽该系统无正式名称且未出现在路线图上但每次情况相似即客户的真实数据存于他人数据库如用户信息在身份验证提供商处订阅信息在 Stripe 里退信信息在负责发送邮件的平台而产品需在本地保存这些数据于是订阅网络钩子webhooks并保留副本。第一次搭建时原以为只是构建一个端点编写一条路由解析 JSON 数据并更新数据库中的一行记录一下午就能完成。然而实际工作从一下午变成了一周。首先要进行签名验证因为可修改数据库的开放端点如同安全漏洞接着需设置去重表因消息可能重复推送文档称其为“至少推送一次”处理程序需要缓冲区因为 membership.created 事件有时会在其所依赖的 user.created 事件之前出现之后要有初始数据导入程序由于网络钩子只告知订阅之后发生的事情且与实时事件存在竞争问题所以需要锁机制最后是数据对账定时任务该任务会在凌晨 3 点爬取供应商的列表 API将数据与表进行比对并悄悄修复不一致之处。这个定时任务本质像是一份书面忏悔表明不信任自己构建的数据副本也不知其何时出错所以每晚从头重新推导数据。信任缺失是有原因的数据偏差不会主动暴露我们是通过一张客户支持工单发现问题的如客户几个月前取消订阅但数据库仍显示“活跃”状态某些 customer.subscription.deleted 事件在传输过程中丢失且无地方能察觉该问题他们的仪表盘显示消息已重试但最终被丢弃而我们的日志也无法记录从未到达的请求。这还只是代码层面的问题每个供应商都有自己的仪表盘三个供应商就有三个网络钩子配置页面每个页面对于端点注册方式、事件类型、测试和生产环境的区分方式以及签名密钥的存储位置都有不同规定。出现问题时调试就像一场旅行需在不同标签页查看推送日志、我们的日志以及怀疑有问题的仪表盘。到第三次搭建这个系统时不再抱有幻想提前为整个技术栈签名验证、去重、缓冲、数据导入、定时任务做好预算。当写到第三个去重表时终于问出了第一次搭建时就该问的问题我到底在重建什么通知并非数据实际上是在重建一个有序的日志每次集成都是试图将一系列通知重新转化为它们原本所属的有序、完整、最新的历史记录。荒谬的是这个历史记录存在于供应商的系统里供应商依据它来渲染仪表盘、事件页面和网络钩子重放工具。供应商将有序的日志拆分成一个个 HTTP POST 请求通过既不能保证顺序也不能保证送达的通道发送到端点然后再在自己这边重新组装日志其他每个消费者也都在独立地做这件事且各自都有问题。这就像一个拼图游戏制造商有原图把它剪成碎片一片一片地寄给我们途中还丢了几片有些还寄了两次而且盒子上没有任何图案。当拼好的拼图和原图不一样时他们的客服却问少了哪些碎片而我们根本不知道关键在于没有任何迹象表明有缺失。这并非任何供应商的问题他们的网络钩子完全按照文档工作。问题在于网络钩子的本质它是一种通知“某事发生了这是一个相关的 POST 请求”。通知是触发副作用的好方式但却是传输数据集的糟糕方式。不知从何时起我们开始用它来做传输数据集这件事却没意识到任务已经变了。为何这成了常态这并非有人刻意决定的。“网络钩子”这个术语是 Jeff Lindsay 在 2007 年创造的早期应用很合适如 GitHub 的提交后钩子触发 CI 构建或者支付事件通知服务器发送收据。当时的任务是在某事发生时执行另一件事对于这种情况POST 请求非常完美在可以接受丢失的情况下“即发即忘”的方式没问题。网络钩子之所以流行是因为它对供应商来说是成本最低的交付方式只需一个 HTTP POST 请求对消费者来说也是成本最低的接收方式已有一个 Web 服务器只需添加一条路由。到 2010 年代初“我们支持网络钩子”成了每个 API 主页上的必选功能但这个选项从未区分过两种截然不同的任务一是触发副作用如发送收据、启动构建、通知频道二是确保本地保存的供应商数据准确无误如客户删除了支付方式那么也在数据库中更新该信息。第一项任务是网络钩子诞生的初衷而三次搭建系统做的都是第二项任务恰恰在这项任务中网络钩子所缺乏的所有特性顺序性、完整性、初始数据同步、可验证性都是我们所需要的。我们在 2007 年选择了现成的工具然后花了十五年时间来弥补它的不足。困境进化生物学中有一个概念“适应度景观”山峰代表好的设计山谷代表糟糕的设计生物种群会朝着所处位置的上坡方向进化。陷阱在于“局部最优”一个小山丘比周围的环境好于是进化就停留在了这里即便山谷对面有更高的山峰。要到达更高的山峰意味着要经历暂时更差的设计而进化不会选择暂时变差的路径。用网络钩子进行数据复制就是一个局部最优解证据就是山谷底部堆积如山的解决方案如签名方案、去重存储、幂等处理程序、供应商端带有指数退避策略的重试队列以及其后的死信队列、带有重放工具的网络钩子日志因为消费者不断要求重放还有凌晨 3 点运行的定时任务。在这堆解决方案之上还形成了一个产业。Svix 的出现让供应商无需自行构建网络钩子推送系统Hookdeck 的存在让消费者无需自行构建网络钩子接收系统。AWS 会将这个“山谷”打包成托管服务卖给我们用 EventBridge 接收 SaaS 合作伙伴的事件用 SQS 对事件进行排队用 Lambda 重试处理程序而我们需要自己组装整个流程。整个连接器平台行业Fivetran、Airbyte 以及每个“统一 API”初创公司本质上都是伪 CDCChange Data Capture变更数据捕获通过网络钩子和轮询列表 API 重建数据变更捕获一次构建一个定制连接器然后作为产品出售。在数据库内部捕获变更已经是一个解决了的问题那就是复制而它之所以有效是因为有日志。但在不同公司之间我们却用门铃式的网络钩子来重建这个功能。最喜欢的解决方案是本地隧道很多供应商都提供类似 stripe listen 的 CLI 工具用于在笔记本电脑上打开一个隧道因为网络钩子无法访问本地主机。当多个供应商都需要提供本地隧道以便开发者进行开发时说明这个基础工具的设计方向是错误的。这些工具本身并不是糟糕的工程设计相反它们是优秀的工程成果。这就是局部最优的样子大量优秀的工程投入到山谷底部让山谷变得舒适以至于没有人再抬头看。但有些供应商已经开始抬头了。Stripe 保留了 [30 天的事件记录](https://docs.stripe.com/api/events)并提供了 [/v1/events](https://docs.stripe.com/api/events/list) 接口这是一个有序的、可列出的日志并且 [建议根据它进行数据对账](https://docs.stripe.com/webhooks/process-undelivered-events)。WorkOS 推出了 [Events API](https://workos.com/docs/events/data-syncing/events-api)这是一个有序的、可分页的日志并且 [他们自己的文档建议在数据一致性很重要时使用该 API 而非网络钩子](https://workos.com/docs/events/data-syncing)。日志不断地以各种形式出现每次出现都有自己独特的游标语义、初始数据同步方式没有验证副本的方法也没有统一的规范但方向是明确的这就是趋同进化。日志无处不在但缺乏统一的规范。能否做得更好在考虑新的设计之前值得思考一下任何替代方案实际上需要具备哪些特性。三次集成的经历表明需要具备以下几点顺序性这样变更就可以无需缓冲直接应用能够从零开始同步这样初始数据导入就不会与实时事件产生竞争将删除操作视为数据这样数据缺失就不再是错误模式可恢复性这样系统停机就是自己的问题而不是数据丢失事件以及某种验证结果的方法这样就不用依赖凌晨 3 点的定时任务来维持信任。按照这个标准来衡量现有的明显候选方案都有所欠缺。更频繁地轮询列表 API 其实就是将数据对账定时任务提升为一种整体策略它可以重建当前状态但会消耗大量的速率限制来发现大部分数据并没有变化它无法保证顺序性而且已删除的对象和从未存在过的对象看起来没有区别。托管推送服务无论是供应商端的 Svix还是这边的 EventBridge 和 SQS都能让推送更加可靠但它们仍然是推送仍然没有初始数据同步仍然无法验证仍然是用通知来假装成数据集。这条路只是加固了山谷底部而没有朝着更高的山峰前进。第三个候选方案是供应商一直在部分实现的完全停止推送让消费者自己读取日志。反转数据流那么做个思想实验如果不是让供应商在有新信息时通知我们而是我们主动询问供应商自上次检查后有哪些新信息会怎么样呢假设供应商为每个数据集提供一个 URL该 URL 返回一个有序的、通过游标寻址的完整状态事件变更日志。不携带游标进行请求将从日志开头开始读取这就是初始数据同步无需单独的导入操作也不会有竞争问题。携带游标进行请求将从上次中断的地方继续读取整个同步状态就由这个游标来表示。发送 Prefer: stream 头响应将不会结束每一个变更在提交时就会通过打开的连接发送过来使用的是用于正常 REST 端点的相同 API 密钥。不发送该头将得到一个有边界的页面可以通过定时任务进行轮询。这是同一个端点、相同的事件、相同的游标和相同的消费者代码。这并非什么新奇的东西只是一个分页的 GET 请求。但回顾一下原本一下午就能完成却越做越复杂的工作看看这个方案对整个技术栈有什么影响去重表不再需要每个事件都包含对象的完整当前状态因此应用一个事件就是根据 ID 进行盲目更新操作同一个事件应用两次会产生相同的结果排序缓冲区不再需要因为日志是有序的初始数据导入程序及其锁机制不再需要新的消费者可以不带游标读取同一个日志流重放数据集并在一个请求中直接处理实时变更丢失删除操作不可能发生删除标记是日志中的一个事件它会一直存在直到被读取已取消订阅的客户不会再无声无息地显示为“活跃”状态因为数据缺失不再是错误模式端点、签名验证和本地隧道从一开始就不需要所有连接都是由消费者发起的整个同步过程可以在 NAT 之后、笔记本电脑上或定时任务中运行。日志流还可以携带另外一项信息。当读取到达日志末尾时供应商可以告知当前应该有的数据状态在持有的游标位置处的记录数量和校验和。将两者进行比较就能确定副本是正确的而不是靠猜测。凌晨 3 点运行的定时任务那份书面忏悔将变成一个在原本打算安排定时任务之前就已经完成的比较操作。第四次搭建如果存在这样一个日志流那么第四次搭建这个系统时只需要一个循环发送 GET 请求获取日志流让“更新插入”操作将对象更新或插入到数据库中“删除”操作则删除相应对象并保存最后一个游标。这只需要二十行代码无需路由、无需轮换密钥、无需队列也无需定时任务。副本本身就带有正确性证明当有人询问哪些客户有活跃订阅且邮箱地址有退信时答案就是在本地表上进行 JOIN 查询不会有任何隐藏的问题。目前还没有人提供这样的服务这既是挑战也是机会。SCROLL 协议为了看看这个想法在精确描述后是否仍然可行将其写成了一个协议草案**SCROLL**即 Synchronized Change Replication Over Line Logs基于行日志的同步变更复制。这是一个征求意见稿draft - 00它明确了日志流、游标、流式和轮询模式、检查点、删除标记和数据保留等方面的内容同时也标注了自己信心不足的地方。而且它不需要等待供应商的支持因为可以通过一个中间件从任何供应商现有的网络钩子和列表 API 合成日志流也打算通过这种方式找出设计中的问题。如果您也深陷过这个困境如果编写过去重表、调试过数据对账定时任务或者遇到过删除操作丢失的问题请阅读这个协议草案并反馈它在哪里存在问题。有不同意见是我们期望的反馈沉默才是失败。
返回列表