Webhook 详解:签名载荷与重试机制
你刚刚集成了一个支付网关。文档说:“当支付成功时,我们会向你的端点发送一个 POST 请求。”很简单,对吧?你构建了一个简单的路由来接收 JSON、更新数据库并返回 200。然后事情发生了:客户的支付成功了,但你的服务器短暂宕机。Webhook 从未到达。或者更糟,它到达了两次,而你把账户余额加了两次。Webhook 看起来很简单,直到它出错。
本文将解释 Webhook 的真正工作原理、为什么签名载荷对安全性至关重要,以及如何构建重试逻辑来保持集成的可靠性——即使出了问题也能应对。
什么是 Webhook?
Webhook 是一种 HTTP 回调:当事件发生时,服务方会向你指定的 URL 发送 HTTP POST 请求,而不是让你的应用轮询 API 来获取变更。它是推送而非拉取。
常见的例子包括支付确认(Stripe、PayPal)、CI/CD 构建通知(GitHub、GitLab)以及消息平台(Slack、Discord)。模式很简单:你注册一个 URL,服务方向该 URL 发送 JSON 载荷,你的服务器进行处理。
但这种简单性隐藏了两个关键挑战:真实性(你如何知道请求确实来自服务方?)和可靠性(如果你的端点宕机或响应缓慢怎么办?)。
为什么签名载荷很重要
你的 Webhook 端点是一个公开 URL。任何人都可以向它发送 POST 请求。如果没有验证,攻击者可以伪造支付成功事件、触发发货或破坏你的数据。
签名载荷通过在请求头中包含加密签名来解决这个问题。服务方使用共享密钥和请求体计算 HMAC(基于哈希的消息认证码),然后将其与载荷一起发送。你的服务器重新计算 HMAC 并进行比较。如果匹配,则请求是真实的。
HMAC 签名的工作原理
- 服务方与你共享一个密钥(在设置时提供)。
- 对于每个 Webhook,服务方将时间戳和原始请求体拼接,然后使用密钥计算 HMAC-SHA256 哈希。
- 签名通过请求头发送,通常类似于
X-Signature或Stripe-Signature。 - 你的服务器使用相同的密钥重新计算 HMAC,并与收到的签名进行比较。
- 如果匹配,则处理事件;否则,返回 401 拒绝。
始终使用原始请求体进行 HMAC 计算。如果你的框架在验证之前解析了 JSON,请求体可能会被改变(空白字符、键顺序),导致验证失败。
示例:在 Node.js 中验证 Webhook 签名
const crypto = require('crypto');
const express = require('express');
const app = express();
// Use raw body for signature verification
app.post('/webhook', express.raw({ type: 'application/json' }), (req, res) => {
const signature = req.headers['x-signature'];
const secret = process.env.WEBHOOK_SECRET;
const payload = req.body; // Buffer
const expected = crypto
.createHmac('sha256', secret)
.update(payload)
.digest('hex');
if (!crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected))) {
return res.status(401).send('Invalid signature');
}
// Process the event
const event = JSON.parse(payload.toString());
// ... handle event
res.status(200).send('OK');
});
注意使用了 timingSafeEqual 来防止时序攻击。切勿使用简单的 === 比较签名。
设计重试逻辑
即使有签名,Webhook 也可能失败。你的服务器可能宕机,网络可能丢弃请求,或者你的处理可能超时。服务方通常会重试失败的投递,但你也应该设计系统来优雅地处理重试。
什么算作失败?
大多数服务方将任何非 2xx 的响应视为失败。这包括 3xx 重定向、4xx 客户端错误和 5xx 服务器错误。有些还强制执行超时(例如 5 秒)。如果你没有快速响应,投递会被标记为失败并重试。
带抖动的指数退避
服务方通常使用指数退避进行重试:第一次重试在 1 分钟后,然后 5 分钟、30 分钟等。添加抖动(随机变化)可以防止大量 Webhook 同时失败时出现惊群效应。
如果你正在构建 Webhook 发送方,可以这样实现退避:
- 重试 1:10 秒后
- 重试 2:1 分钟后
- 重试 3:5 分钟后
- 重试 4:30 分钟后
- 重试 5:2 小时后
达到最大尝试次数后,将事件移入死信队列以供人工检查。
幂等性:处理重复投递
重试意味着你的端点可能会多次收到同一个事件。你的处理必须是幂等的:处理同一个事件两次应该与处理一次的效果相同。
常见策略:
- 事件 ID 去重:将已处理的事件 ID 存储在数据库或缓存中。在处理之前,检查该 ID 是否已经出现过。
- 使用 Upsert 而非 Insert:使用天然能处理重复的数据库操作(例如
INSERT ... ON CONFLICT DO NOTHING)。 - 幂等键:对于扣款等操作,传递一个唯一键,以便支付服务方忽略重复请求。
Webhook 消费方最佳实践
| 实践 | 为什么重要 |
|---|---|
| 验证签名 | 防止伪造请求 |
| 快速响应(2 秒内) | 避免超时和不必要的重试 |
| 异步处理 | 将接收与繁重处理解耦 |
| 使用幂等处理器 | 安全应对重复投递 |
| 记录所有事件 | 有助于调试和审计 |
| 监控失败 | 在问题升级之前发现它们 |
还要考虑验证签名中的时间戳以防止重放攻击。许多服务方在签名载荷中包含时间戳;拒绝超过几分钟的旧事件。
Webhook 生产方最佳实践
如果你正在构建发送 Webhook 的服务,请遵循以下准则:
- 使用 HMAC-SHA256 对每个载荷进行签名。
- 包含唯一的事件 ID 和时间戳。
- 使用带抖动的指数退避进行重试。
- 为消费方提供查看投递日志和手动重放事件的方式。
- 清晰地记录你的签名验证流程。
常见问题
什么是签名的 Webhook 载荷?
签名载荷包含一个加密签名(通常是 HMAC),由请求体和共享密钥计算得出。接收方验证签名以确保请求是真实且未被篡改的。
Webhook 应该重试多少次?
没有统一的标准,但大多数服务方会在几个小时内重试 3-5 次。之后,事件通常被标记为失败,并可能可供手动重放。无论重试次数如何,你的系统都应幂等地处理重复事件。
我可以不使用 HTTPS 来使用 Webhook 吗?
技术上可以,但你应该始终使用 HTTPS。Webhook 通常包含敏感数据,如果没有 TLS,攻击者可以拦截或篡改载荷。HTTPS 还能确保你发送到预期的服务器。
使用 Nginx 日志调试 Webhook
当 Webhook 失败时,首先要查看的是 Web 服务器日志。如果你使用 Nginx,访问日志和错误日志会记录每个请求,包括状态码和响应时间。你可以快速发现模式:反复出现的 401(签名验证失败)、500(应用错误)或超时。
为了更快速地分析,可以试试 Nginx 日志分析器 来解析和汇总 Webhook 流量、按状态码过滤并识别有问题的端点。