一个真实事故:同一笔款,入账了两次
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 个场景),每次给出海产品做支付体检都会跑一遍。
最后
支付平台不会保证"只送一次",但你可以保证"只处理一次":
- 去重记录要持久化------别只放内存
- 唯一约束要防 NULL------空值会让幂等形同虚设
- 并发要靠数据库兜底------先查后插必翻车
把上面六条写进测试矩阵,重复入账基本绝迹。
上一篇同类坑:《支付平台改了字段长度,你的系统可能正在悄悄丢单》------字段漂移、回调格式变化、新事件类型,都是"平台在变、系统没跟上"的坑。
做过多年跨境支付软件开发自动化测试,目前专注出海产品支付验证:支付流程测试、掉单排查、对账兜底。欢迎交流。