site logo

Marico's space

**Webhook 幂等性:处理重复事件的实用指南**

Others 2026-07-10 17:34:15 5

最近在处理支付系统 webhook 的时候,被重复投递的问题折腾了一把。说实话,之前一直没把这个当回事,觉得 webhook 发过来处理就是了。结果线上还真复现了一次——业务逻辑跑完了,但返回给支付方的响应因为网络抖动超时了,对方直接重试,导致同一个订单被处理了两遍。好在金额不大,没有造成实际损失,但这个坑让我意识到,webhook 幂等性不是"锦上添花"的优化项,而是必须做的基本功。

这篇文章把 webhook 重复投递的根源、常见的错误做法、以及正确的实现方式说清楚,不废话,直接上干货。

重复投递是必然,不是意外

很多人第一次遇到 webhook 重试时会想:"是不是对方系统有 bug?"——不是的,这是设计层面的必然。

所有主流的 webhook 提供方都采用"至少投递一次"的策略,没有哪家能保证"恰好一次"。以支付宝的支付回调为例,官方文档明确说明可能会重复投递;阿里云的消息队列在弱网环境下也可能重复投递消息。底层原因是分布式系统领域的老问题了——在不可靠的网络上实现精确一次投递已经被证明是不可能的,这可以追溯到"两将军问题"和 FLP 不可能定理。

所以结论很明确:重复投递是一定会发生的,你能控制的是收到重复请求时会不会产生副作用。

最常见的触发场景还不是网络抖动,而是你自己的处理逻辑执行完了,但返回响应晚了那么几毫秒,调用方以为失败了直接重试——结果同一个操作被执行了两遍。

幂等性的基石:稳定的唯一标识符

所有可靠的幂等实现都遵循同一个原则:给每个事件存储一个唯一标识,碰到已见过的标识就跳过处理。

CREATE TABLE processed_webhooks ( idempotency_key TEXT PRIMARY KEY, provider TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'processing', received_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

收到 webhook 时,把这个唯一标识插入表中。如果因为唯一约束冲突插入失败,说明这个事件之前已经处理过了。

关键在于这个标识符从哪来,不同平台的规定不一样,搞错了整个幂等逻辑就废了:

>GitHub
平台 稳定 ID 来源 重试机制
支付宝 notify_id,每次重试相同 最多重试 5 次,间隔递增
微信支付 transaction_id / out_trade_no 未收到响应时自动重试
X-GitHub-Delivery 请求头 不自动重试,手动或 API 重新投递时复用同一 ID
遵循 Standard Webhooks 规范的平台 webhook-id 请求头,重试时保持不变 各平台实现不同,规范建议接收方据此去重

Standard Webhooks 规范值得关注。这是一个开放规范,由 webhook基础设施公司 Svix 联合 Zapier、Twilio、Supabase 等合作伙伴制定,标准化了三个请求头:webhook-id、webhook-timestamp 和 webhook-signature(默认 HMAC-SHA256)。规范明确建议使用 webhook-id 作为幂等键。据报道,OpenAI、Anthropic、Google Gemini、Twilio、阿里云等多平台都已采用此规范,这意味着实现一套验证和去重逻辑就能跨平台复用。

常见错误做法:先查后写

第一次实现的时候,很容易写成这样:

SELECT * FROM processed_webhooks WHERE idempotency_key = 'evt_123';
-- 如果存在,返回 200 OK
-- 如果不存在,执行业务逻辑,然后:
INSERT INTO processed_webhooks VALUES ('evt_123');

这叫"先查后写",是一个典型的竞态条件。如果网络抖动导致同一个 webhook 在几毫秒内被投递两次,两个请求同时执行 SELECT,都看到"未处理",然后都去执行业务逻辑——重复扣款、重复发通知就发生了。

解决思路:把"检查"和"声明"合并成一个原子操作,绝不能先查后写。

正确的并发安全实现

下面是一个用 Node.js/Express + PostgreSQL 实现的方案,关键改动是用 INSERT ... ON CONFLICT DO NOTHING RETURNING 原子性地判断当前请求是否抢到了这个 key:

app.post('/webhook/alipay', async (req, res) => { const event = req.body; const idempotencyKey = event.notify_id; const client = await pool.connect(); try { await client.query('BEGIN'); // 原子性地声明这个 key。如果有行返回,说明当前事务抢到了; // 如果没有行返回,说明已经被其他请求声明了。 const insertResult = await client.query(` INSERT INTO processed_webhooks (idempotency_key, provider, status) VALUES ($1, 'alipay', 'processing') ON CONFLICT (idempotency_key) DO NOTHING RETURNING idempotency_key; `, [idempotencyKey]); const claimedByThisRequest = insertResult.rowCount > 0; if (!claimedByThisRequest) { // 已经被其他请求声明了。锁定这一行,获取真实状态—— // 这会阻塞,直到持有锁的事务提交或回滚。 const { rows } = await client.query(` SELECT status FROM processed_webhooks WHERE idempotency_key = $1 FOR UPDATE; `, [idempotencyKey]); if (rows[0].status === 'completed') { await client.query('COMMIT'); return res.status(200).send('Already processed'); } // 仍在处理中(或之前的请求在完成前崩溃了)。 // 告诉调用方稍后重试,而不是和当前持有锁的请求竞争。 await client.query('ROLLBACK'); return res.status(409).send('Concurrent processing in progress'); } // ========================================== // 🚀 在这里执行业务逻辑 🚀 // ========================================== await processAlipayNotification(event); await client.query(` UPDATE processed_webhooks SET status = 'completed' WHERE idempotency_key = $1; `, [idempotencyKey]); await client.query('COMMIT'); return res.status(200).send('Success'); } catch (error) { await client.query('ROLLBACK'); console.error('Webhook processing failed', error); return res.status(500).send('Internal Server Error'); } finally { client.release(); }
});

生产环境中的两个补充建议:

  • 业务逻辑的副作用也要幂等——用 upsert 而不是普通 insert(ON CONFLICT ... DO UPDATE),这样即使有漏网之鱼也不会造成问题。
  • 给存储的 key 设置 TTL,保留时间要覆盖平台的最大重试窗口加安全边际。支付宝大概几天,Stripe 是 72 小时,保留一周是个合理的折中。

先响应,后处理

即使数据库锁做得再好,也解决不了一个问题:如果业务逻辑本身很慢——生成 PDF、调用第三方 API、发邮件——超过平台设定的超时时间,平台就会认为失败并重试。

支付宝要求回调响应时间通常在几秒内;GitHub 的超时更短。超时了平台就重试,哪怕你的处理器还在勤勤恳恳工作。

解法是把"接收"和"处理"解耦:

  • 响应:验证签名、保存原始 payload 和幂等键、立即返回 200 OK。
  • 处理:用异步 worker 从消息队列(SQS、RabbitMQ、Kafka,或者简单的数据库 job 表)消费保存好的事件,按自己的节奏处理。

因为平台在毫秒级就收到了 200,不会触发超时,也就不会产生因超时导致的大量重复投递。

幂等性也是安全防线

幂等性还有个容易被忽视的作用:安全防护。

一个被正确签名的 webhook,如果被人从泄露的日志文件、截获的测试环境流量、或者贴到 Slack 的截图中抓取出来,它可以长期保持技术上的有效性。签名验证能证明事件是真实的,但无法防止重放攻击。幂等性保证了即使有人重放一个技术上完全合法的请求,它也不会重复执行敏感操作。签名验证证明事件来源可靠,幂等性限制了一个合法但被重放的事件能做什么。

运维可见性:手动重试的困境

代码写得再好,也解决不了运维场景:开发或运维人员在平台的控制台看到一条失败记录,犹豫要不要点"重发"。

平台控制台只能看到它收到的 HTTP 状态码,看不到你的幂等表实际怎么处理了这个请求。这是一个真实的盲区,所以围绕 webhook 中继和管理工具的生态才发展起来。

两个值得关注的方案:

  • 幂等感知的中继平台。像 InstaWebhook 这类工具位于提供方和应用之间,跟踪每个事件的明确状态——已接收、已入队、已尝试、已重试、已送达、死信——在重放事件前展示投递历史和幂等上下文,而不是让你瞎猜。
  • 全生命周期 webhook 平台。更大的玩家——Svix(主要是给自己的客户发送 webhook,也是 Standard Webhooks 规范的发起方)、Hookdeck 和 Convoy(主要是接收)、Hooklistener(webhook 检查和重放测试)——覆盖了发送/接收生命周期的不同部分,市场边界越来越模糊。

这些工具不能替代上面数据库层面的幂等实现——它们是在此基础上增加运维可见性和更安全的重放语义,对于有多人处理生产环境 webhook 故障的团队来说,这是刚需。

总结

webhook 已经成为系统间事件通知的默认方式,假设网络完美可靠是设计缺陷,不是边缘情况。提供方会发送重复请求,网络会抖动,部署会中断正在进行的请求——这些都是正常的、预期的行为,不是罕见的故障模式。

应对这个问题的最佳实践有四层:

  1. 使用稳定的、平台正确的唯一标识符
  2. 用原子数据库声明替代先查后写的竞态
  3. 解耦"先响应后处理",让慢业务逻辑不触发超时重试
  4. 在团队规模需要时,使用让手动重放变成安全、知情的决策而非瞎猜的运维工具

把这四层做好,重复的 webhook 投递就不再是线上故障,而只是背景噪音。