《用户付一次钱,你的系统为什么处理了两遍: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. 并发要靠数据库兜底------先查后插必翻车

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

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

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

相关推荐
子兮曰5 天前
jev-ultrafast 深度解析:7 秒订机票的浏览器 Agent 是如何炼成的
前端·后端·agent
子兮曰5 天前
Jev 爆发一周:7 秒 Agent 背后的 System One 生态与三场争议
前端·后端·ai编程
爱勇宝5 天前
ZCode 开源 24 小时:一份没有历史的账本,回答不了"有没有偷代码"
前端·后端·chatglm (智谱)
胡写代码6 天前
别再前后端各写一套表单校验了
java·后端
大勇前进6 天前
原生 PHP 还是 Laravel?小项目到底要不要上框架
后端
yuzhi_liu6 天前
我用 LangGraph4j 实现 Multi-Agent Supervisor
后端
alsmile6 天前
Node-RED 之外,国产规则引擎的新方案:基于标准语法,Go 先行实现
后端·开源·go
大白806 天前
PHP 内存溢出排查思路:看懂报错日志,精准定位问题
后端
二月龙6 天前
PHP 接口返回统一响应封装,让前后端对接更省心
后端
盖伦发发6 天前
软件工程SOLID 五大设计原则
后端·软件工程