Webhook:接收、重试与补偿

Webhook:接收、重试与补偿

支付状态变了、仓库里有人提交了代码、第三方平台完成了一项任务,自己的服务怎么知道?定时查询对方 API 当然可以,但大多数时候只会得到“还没有变化”。

Webhook 换了一个方向:接入方先登记回调地址,事件发生后,由对方主动发来 HTTP 请求。这个思路并不复杂,麻烦的是 HTTP 请求到达之后发生的事。

回调成功,不等于业务完成

收到事件后,如果先更新数据库、调用其他服务,再把最终结果返回给对方,任何一步变慢都可能让回调超时。业务其实已经处理成功,对方却因为没有及时收到响应而再次投递,同一个动作就可能执行两遍。

因此,Webhook 入口通常只做验签、基本校验和可靠存储,然后尽快返回 2xx。后续处理交给后台任务或队列。这里的 2xx 表示“事件已经收下”,不表示“整条业务链路已经走完”。

把接收和处理分开之后,外部平台的重试节奏不会直接拖住内部业务,消费失败也可以按自己的方式恢复。

验签要使用原始请求体

Webhook 地址通常暴露在公网。知道 URL 不等于有权发送事件,所以不能仅凭请求成功到达就相信其中的内容。

常见做法是双方共享密钥,由发送方根据请求体计算签名,接收方再用同一个密钥验证。签名计算应使用原始请求体,而不是解析后重新生成的 JSON;字段顺序或空白一旦变化,算出的结果就可能不同。

时间戳和投递 ID 也值得检查。前者可以拒绝过旧的请求,后者可以识别同一次投递。HTTPS、密钥轮换和缩小事件订阅范围属于同一层防护。IP 白名单可以补充限制,但不能替代验签。

重试意味着事件可能重复

各个平台怎样重试并不统一,但有一点可以提前确定:Webhook 不能按“恰好一次”来设计。接收端已经处理成功,响应仍可能丢在路上;不同事件并行投递时,到达顺序也可能与发生顺序不同;服务中断得足够久,自动重试还会耗尽。

事件 ID 或投递 ID 应当落到持久化记录中,并由唯一约束拦住重复消费。退款、发货、发放权益这类有副作用的操作,本身也要支持幂等,不能只靠进程内的一份“已处理列表”。

如果状态有先后关系,不要简单地让后到的请求覆盖当前数据。可以比较资源版本,也可以收到通知后再查询一次权威状态。Webhook 告诉我们“发生过一件事”,它携带的内容未必就是资源此刻的最终状态。

GitHub 的 Webhooks 最佳实践 涵盖了验签、快速响应和重新投递;Stripe 的 Webhook 文档 也提醒接收端处理重复事件,并建议异步消费。

漏掉的事件要有补偿入口

重试只能解决暂时失败,不能保证事件永远不会丢。服务长时间不可用、配置错误或消费逻辑有缺陷,都可能让事件错过正常处理窗口。

补偿方式取决于对方提供的能力:定期对账、按时间范围查询事件、从管理后台重新投递,或者重放自己保存的原始事件。至少要能通过事件 ID 串起接收、验签、入队和业务结果,否则出问题时只能确认“接口好像收到过请求”。

Webhook 也有自己的契约

Webhook 仍然是跨系统 API。事件类型、事件 ID、发生时间、资源标识和结构版本,都是接收方理解事件的依据。

生产方新增可选字段通常问题不大,删除字段、改变字段类型或修改枚举含义则容易破坏已有接入。接收端要能忽略未知字段,也不要订阅自己用不到的事件。载荷里只放处理所需的信息;需要更多详情时,再使用服务凭证查询提供方 API,避免把敏感数据铺进回调日志。

它不会直接更新页面

Webhook 解决的是服务之间的事件通知,不会直接把变化送进浏览器。第三方事件到达业务服务后,服务端先更新内部状态;如果页面需要及时看到结果,还要通过轮询、SSE 或 WebSocket完成后一段通信。

例如支付平台用 Webhook 通知支付完成,业务服务完成验签和幂等处理,再把新状态推给页面。这是两条链路,各自都要考虑失败和恢复。

什么时候不必使用 Webhook

调用方必须当场拿到处理结果时,同步 API 更直接。双方都在同一个系统内,而且需要高吞吐、严格顺序、复杂路由或长期积压能力时,消息队列也更合适。

Webhook 更适合跨服务、跨组织的异步通知。接入时更该确认的不是回调能不能收到,而是它重复、乱序或暂时收不到时,系统还能不能把事情做对。