《用户付一次钱,你的系统为什么处理了两遍: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 做唯一键,重复插入直接撞约束。

sql 复制代码
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 行就直接判重:

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

对应的处理流程:

text 复制代码
收到 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

这六条我已经收录进自己的支付 QA 测试清单里(20 个场景),每次给出海产品做支付体检都会跑一遍。


最后

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

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

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

上一篇同类坑:《支付平台改了字段长度,你的系统可能正在悄悄丢单》------字段漂移、回调格式变化、新事件类型,都是"平台在变、系统没跟上"的坑。

做过多年跨境支付软件开发自动化测试,目前专注出海产品支付验证:支付流程测试、掉单排查、对账兜底。欢迎交流。

相关推荐
杨利杰YJlio1 小时前
KB5121003更新详解:安全启动证书、AI组件、资源管理器与安装验证
前端·javascript·后端
数字化探索者笔记1 小时前
从零搭建:VMware 银河麒麟V10部署 OceanBase 4.2.5 企业版集中式数据库
后端
西门老铁1 小时前
JWT 是什么?三段式结构、签名原理与 6 个安全坑一次讲透
后端·面试
Codelinghu1 小时前
DeepSeek Harness 出来了,干硬件的我琢磨了一下它到底能干啥
后端
eralong1 小时前
Java 面向对象:继承、多态、接口
java·后端
苏三说技术2 小时前
DeepSeek Harness必装的10个插件
后端
阿弱2 小时前
graph-core 策略合并机制的设计与用法
后端·agent
fatcoder2 小时前
玩转Docker 08 — 实战:容器化真实后端并编排
后端·docker·容器
神奇小汤圆2 小时前
从原始的CRUD 到高并发架构:关于秒杀系统的问题拆解与推演
后端