编程 用户付一次钱,系统入账两次:Webhook 重复投递的幂等处理

2026-09-12 21:32:10

用户付一次钱,系统入账两次:Webhook 重复投递的幂等处理

一个真实事故:同一笔款,入账了两次

Paypal 有个 issue(#243):某天系统突然多了一笔“凭空出现”的入账,查来查去发现——同一笔交易,平台连发了 3 条一模一样的 IPN 回调,而系统只把其中 1 条标记为重复,剩下两条都当作新交易处理了。

另一个支付库(stripe_event #104)的问题是并发:两个 webhook 请求同时到达,先后撞上数据库唯一键,一个成功、一个报错——但就是有用户偶发重复写库。

两个场景后果一样:用户只付了一次钱,你的系统入账两次。多发货、多退款、账目对不上。

为什么“重复”是常态,不是事故

支付平台对 webhook 的投递语义是“至少一次”(at-least-once),不是“恰好一次”:

  • 平台侧处理失败会自动重试
  • 网络抖动导致重传
  • 你的服务处理到一半崩溃、重启后又重新消费
  • 手动补投、重放事件

同一事件收到两次不是 bug,一次也没收到才是 bug。既然重复必然发生,系统必须做到“重复到达也不产生副作用”——这就是幂等。

三个去重层级,一层比一层可靠

第 1 层:内存去重(最弱)

进程内放一个 Set,或 Redis 里记 event_id → TTL。问题:进程重启、多实例部署时缓存一丢就失效,只能把重复概率从“必然”降到“偶尔”。当兜底可以,当主方案不行。

第 2 层:数据库唯一约束(可靠,但要防 NULL 陷阱)

用事件 ID / 交易 ID 做唯一键,重复插入直接撞约束。

CREATE TABLE processed_events (
  event_id     TEXT PRIMARY KEY,
  event_type   TEXT NOT NULL,
  processed_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

坑:如果唯一键字段是空值,约束就失效了。Postgres 语义是 NULL ≠ NULL——唯一约束对 NULL 不生效,(tx_hash, operation) 里 tx_hash 为 NULL 的两行永远互不冲突(Postgres 15+ 可显式写 UNIQUE NULLS NOT DISTINCT)。

开源支付仓库 circlefin/arc-ecommerce-payments PR #22 的作者就撞上了:加了 (tx_hash, operation) 唯一约束做幂等,评审时发现 tx_hash 可能为 NULL,唯一约束等于半失效,最后把 transactionHash 改成必填,从源头堵死。

第 3 层:幂等键 + 幂等写入(推荐)

把“处理过的事件”本身作为幂等记录,插入成功才执行业务逻辑,插入返回 0 行就直接判重:

INSERT INTO processed_events (event_id, event_type)
VALUES ($1, $2)
ON CONFLICT (event_id) DO NOTHING;
-- 影响 0 行 → 重复投递,直接返回 200,千万别碰业务逻辑

处理流程:

收到 webhook
  → 验签(先验签,再谈别的)
  → 幂等写入(ON CONFLICT DO NOTHING)
      → 0 行:重复,直接 ack,结束
      → 1 行:首次到达,执行业务逻辑

并发竞态:别用“先查后插”

很多人写“先 SELECT 查一下在不在,不在就 INSERT”。这在并发下必翻车:两个请求同时 SELECT,都查不到,然后都 INSERT——一个成功一个撞约束报错,运气差就重复写库。

正确姿势是让数据库约束当裁判:直接 INSERT,靠 ON CONFLICT DO NOTHING 处理冲突,撞上的那个拿到 0 行就当重复处理,返回 200 优雅结束,而不是抛 500。

circlefin 那个仓库的最后修复就是这套:唯一约束 + INSERT ... ON CONFLICT DO NOTHING + 一个并发重复投递测试(两个并发请求,一个成功、一个优雅判重)。所以这条不只要在代码里做对,还要用并发测试锁住。

别忘了顺序问题

重复和乱序经常一起来。典型场景:

  • 退款事件先到、支付事件后到——如果退款逻辑直接扣减,而支付还没入账,账就乱了
  • 同一个事件被重放两次,但两次 payload 不完全一样——只用事件 ID 判重挡不住“内容变了”的重复

对策:事件 ID 只负责“同一事件的幂等”,业务层还要有状态机——退款先到时记“待对账”,支付到了再核销;关键状态变更用 WHERE status = '旧状态' 的乐观更新,改不到行就说明状态已经变了。

上线前,把这张测试矩阵跑一遍

场景期望结果
同一事件投递两次只处理一次,第二次直接 ack
两个并发请求同时到达一个成功,另一个优雅判重,不 500
进程重启后再收到同一事件仍然判重(去重记录必须持久化)
事件 ID / 幂等键为空唯一约束不失效(字段必填或兜底键)
退款先于支付到达不重复入账,按状态机处理
平台重试携带相同事件 ID跳过业务逻辑,正常返回 200

最后

支付平台不会保证“只送一次”,但你可以保证“只处理一次”:

  1. 去重记录要持久化——别只放内存
  2. 唯一约束要防 NULL——空值会让幂等形同虚设
  3. 并发要靠数据库兜底——先查后插必翻车

把上面六条写进测试矩阵,重复入账基本绝迹。

复制全文 生成海报 支付回调 幂等 Webhook 唯一约束 状态机

推荐文章

程序员茄子在线接单