独立站Webhook可靠性:重试机制、幂等性、签名验证与监控
一、Webhook 为什么天然不可靠
Webhook 是对方系统主动把事件 POST 给你的回调地址。它建立在 HTTP 之上,而 HTTP 本身是无连接、不保证送达的:网络抖一下、你的服务重启一下、处理超时一下,这次投递就可能丢。更麻烦的是”不知道对方收没收到”——你发出去了,但没收到 2xx 响应,到底是网络丢了还是对方处理了但响应丢了?
反直觉的判断在这里:Webhook 系统默认就会重试,所以你的接收端必须按”会被重复投递”来设计,而不是假设它只会来一次。Stripe、Shopify 这类成熟平台的标准做法是:接收端不返回 2xx 就按指数退避重发,最多发 5 至 8 次、跨约 24 小时。
你如果没做幂等,同一笔支付事件就会被入账好几次。线索到系统的打通,见官网线索到 CRM,那里同样要面对重复事件。
四件事缺一件,可靠性就掉一档。下面这张表把每要素”做什么、漏了会怎样”列清。
| 要素 | 作用 | 标准做法 | 漏了的后果 |
|---|---|---|---|
| 签名验证 | 确认请求真来自对方 | HMAC-SHA256 校验头 | 被伪造事件注入假订单 |
| 快速确认 | 避免对方超时重发 | 先回 2xx,再异步处理 | 处理慢导致重复投递 |
| 幂等去重 | 重复事件只处理一次 | 事件 ID 落表去重 | 同一订单入账多次 |
| 重试退避 | 临时失败最终送达 | 指数退避加抖动 | 偶发故障直接丢单 |
| 死信监控 | 失败事件不静默丢 | 超限进死信队列告警 | 长期丢单无人知 |
二、签名验证:确认这个请求真不是别人伪造的
Webhook 回调地址是公网可访问的 URL,任何人都能往这个地址 POST 数据。如果不验签,攻击者直接构造一条”支付成功”的假事件,你后台就可能真的给他开通权限或发订单。这不是理论风险,是真实被打过的攻击面。
标准做法是 HMAC-SHA256:对方用约定的密钥对请求体做签名,放在请求头里(如 X-Signature);你收到后用同一个密钥对原始请求体再算一次,两边比对,一致才处理。
两个细节必须做:第一,用定时安全比较函数比对,不要用普通字符串 ==,防时序攻击;第二,校验请求头里的时间戳,超过 5 分钟容差直接拒绝,防止有人录下旧请求重放。密钥泄露后要能立即轮换。
| 校验项 | 做法 | 不通过的处理 |
|---|---|---|
| 签名是否匹配 | HMAC-SHA256 重算比对 | 直接 401 拒绝 |
| 时间戳是否新鲜 | 与服务器时间差 ≤ 5 分钟 | 拒绝,防重放 |
| 密钥是否过期 | 支持新旧密钥并存轮换 | 用旧密钥再试一次 |
| 请求体是否被改 | 用原始字节算签名 | 不要先 JSON 解码再签 |
三、快速确认与异步处理:别在请求里干重活
这是最容易踩的坑。很多人收到 Webhook 后,在同步请求里做一连串重活:查库、发邮件、调第三方接口、写日志,一套下来花了 5 秒。对方等 3 秒没收到 2xx,判定超时,开始重发——你这边其实还在处理第一条,第二条又进来了。
正确模式是三段式:收到请求后,先验签(毫秒级),验过立刻把事件原样落库并返回 2xx,然后后台异步任务慢慢处理。这样对方 3 秒内一定拿到确认,不会重发;重活慢一点没关系,反正用户已经收到回执了。处理失败的事件留在队列里,由异步任务自己重试。
场景示例:支付成功事件进来。接收端只做三件事:验签、把事件 ID 和原始 payload 写一张 events 表、立刻返回 200。真正的”加库存、发邮件、通知 ERP”由一个后台 worker 从 events 表里取出待处理事件慢慢做。哪怕发邮件要 10 秒,也不会影响对方重发。表单送达的可靠性思路,可对照询盘表单可靠性。
| 阶段 | 动作 | 耗时目标 | 失败处理 |
|---|---|---|---|
| 同步接收 | 验签 + 落库 + 回 2xx | 小于 300 毫秒 | 验签失败直接拒绝 |
| 异步处理 | 查库、发通知、调下游 | 秒级到分钟级均可 | 失败留在队列重试 |
| 重试 | 按退避间隔重跑 | 指数退避 | 超上限进死信队列 |
| 人工补单 | 查死信队列手动触发 | 每日巡检 | 记录补单原因 |
四、幂等性与重试:同一事件来十次也只处理一次
幂等的意思是:同一个操作执行一次和执行十次,结果完全一样。Webhook 必须天然幂等,因为你无法阻止对方重发。实现方式很简单:对方每个事件都带一个全局唯一的事件 ID,你处理前先查这张 events 表,见过这个 ID 就直接返回成功、跳过处理。
重试策略用指数退避加抖动:第一次失败等 1 分钟,第二次 2 分钟,第三次 4 分钟,以此翻倍,最多 5 至 8 次、总跨度约 24 小时。加随机抖动是为了避免所有失败事件在同一秒集中重试,把你的服务再压垮一次(这叫重试风暴)。超过重试上限的事件不丢,进死信队列,等人介入。
| 重试次数 | 退避间隔(约) | 累计时间 | 说明 |
|---|---|---|---|
| 第 1 次 | 1 分钟 | 1 分钟 | 临时网络抖动 |
| 第 2 次 | 2 分钟 | 3 分钟 | 短暂服务重启 |
| 第 3 次 | 4 分钟 | 7 分钟 | 下游临时不可用 |
| 第 4 次 | 8 分钟 | 15 分钟 | 较长故障 |
| 第 5 至 8 次 | 15 至 30 分钟 | 约 24 小时 | 仍失败进死信队列 |
五、监控与死信:别让丢单静默发生
Webhook 最阴险的故障是”静默丢单”:对方觉得发成功了,你这边一条记录都没有,几周后客户投诉”我付了钱为什么没发货”,你才发现事件根本没到。所以监控必须盯三件事:投递成功率、重试队列堆积、死信队列数量。
阈值给具体数字:重试队列堆积超过 100 条,或连续 30 分钟没有任何成功投递,立即告警到你常用的渠道。死信队列每天巡检一次,有积压就人工补单。投递成功率低于 99% 就要查——健康状态下 Webhook 成功率应该接近 99.9%,掉到 99% 以下说明有持续性问题。通用监控告警怎么搭,见站点监控与告警。
| 监控指标 | 健康值 | 告警阈值 | 动作 |
|---|---|---|---|
| 投递成功率 | ≥ 99.9% | 连续 1 小时 < 99% | 查接收端日志 |
| 重试队列堆积 | 接近 0 | 超过 100 条 | 查下游是否卡住 |
| 死信队列 | 0 | 出现任意积压 | 人工补单并定位 |
| 连续无成功投递 | 持续有事件 | 30 分钟为 0 | 查回调地址是否被封 |
| 验签失败次数 | 接近 0 | 突增 10 倍 | 可能有人在扫接口 |
FAQ 常见问题
为什么我的 Webhook 有时候会收到重复的订单事件?
Webhook 收到后应该立刻处理还是先返回?
不做签名验证行不行,反正是走 HTTPS?
重试间隔怎么定,为什么不能固定每分钟重试?
重试很多次还是失败的事件怎么处理?
怎么知道 Webhook 有没有在悄悄丢单?
写在最后
Webhook 可靠性的核心认知是:它不是单向推送,而是一个”不确认就重发”的双向协议。你设计接收端时,必须把”会被重复投递、会被伪造、会丢”当成默认前提,而不是小概率事件。四个要素里,幂等去重和快速确认是最容易被忽略、出事最多的两个,先把这两个做对,事故率就掉一大半。
落地三步:第一,接收端只做验签、落库、3 秒内回 2xx,业务全部异步;第二,按事件 ID 建去重表,重试用指数退避加抖动;第三,死信队列每天巡检,投递成功率和堆积量接告警。做完这三步,你的回调链路就从”偶尔丢单靠运气”变成”丢了当天能发现、能补回来”。
一句可以记住的话:Webhook 的可靠不靠对方发得稳,靠你这边收得笨——验签、回得快、去得重,宁可重复处理,也别漏一单。
