怎么用幂等设计保证支付回调不重复扣款
结论先行:支付回调防重复扣款,靠的不是「加个锁」或「先查再插」,而是「唯一幂等键 + 数据库唯一索引 + 状态机条件更新」这三件套,再加「永远给支付平台返回成功响应」。只要这四点做齐,同一笔回调被推 100 次,账也只变动 1 次。
支付回调为什么一定会重复?
结论:重复回调是支付平台的正常行为,不是异常,你的接口必须默认它会重复。微信支付 V3 在 24 小时内按 15s/15s/30s/3m/10m/20m/30m/30m/30m/60m/3h/3h/3h/6h/6h 的间隔重试,共 15 次;支付宝异步通知按 4m、10m、10m、1h、2h、6h、15h 的节奏持续重试,最长覆盖 24 小时。触发重试的条件只有一个:你没返回它认可的成功报文,或者它没在规定时间内读到(比如你处理耗时 30 秒,socket 读超时)。
所以「回调重复」这件事你控制不了,你只能控制「重复到达后不产生副作用」。这也意味着:任何依赖「回调只来一次」的代码都是定时炸弹。
幂等键应该选什么?
结论:幂等键要选「同一笔交易在重试前后完全一致」的字段,通常就是商户订单号 + 支付渠道 + 交易类型。
不要用平台推送流水号做幂等键。微信的 id、支付宝的 notify_id 在每次重试时都可能不同,用它做幂等,第二次回调会当成新请求放过去,直接重复入账。
推荐的键结构:
idem_key = channel + ":" + out_trade_no + ":" + trade_type
例:WXPAY:2025010812345678:PAY
退款回调、分账回调要用不同的 trade_type,否则一笔订单的支付和退款会撞进同一个幂等键。
唯一索引和条件更新具体怎么写?
结论:幂等靠数据库的原子性保证,不靠应用层判断。落地就两步:幂等表唯一索引拦住重复插入,订单表条件更新拦住重复改状态。
第一步,建一张幂等表,把 idem_key 建唯一索引:
CREATE TABLE t_idempotent (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
idem_key VARCHAR(128) NOT NULL,
status TINYINT NOT NULL DEFAULT 0, -- 0处理中 1成功
create_time DATETIME NOT NULL,
UNIQUE KEY uk_idem_key (idem_key)
);
回调进来先插这条记录,INSERT ... ON DUPLICATE KEY UPDATE 或直接捕获唯一键冲突(MySQL 错误码 1062)。插入成功的那一次才继续走业务;插入失败说明是重复回调,直接返回成功报文。
第二步,订单状态用带条件的 UPDATE,而不是「查出来判断再改」:
UPDATE t_order
SET status = 'PAID', pay_time = NOW(), channel_trade_no = ?
WHERE order_no = ? AND status = 'PENDING';
判断 affected_rows:等于 1 说明这次真的完成了扣款状态流转;等于 0 说明已经被处理过,跳过后续发货、加积分、发消息等所有副作用。
这两步必须在同一个本地事务里,或者至少保证「幂等表插入」与「状态流转」的先后顺序固定。常见错误是先查订单状态、发现是 PENDING 再去改,中间隔着几十毫秒,两个并发回调会同时通过检查。
为什么不能只用分布式锁?
结论:分布式锁可以降低并发压力,但不能替代唯一索引和条件更新。
Redis 锁有三个现实问题:锁会超时释放(比如设了 10 秒 TTL,业务跑了 12 秒,第二个请求就进来了);主从切换或网络分区时锁可能丢失;拿到锁的进程崩溃后业务状态未知。
把 Redis 锁当作「减少数据库竞争的优化手段」是合理的,把它当作「唯一的正确性保证」一定会出事。反过来,只有唯一索引没有锁,虽然会有少量请求打到数据库被拒,但结果是正确的。
处理失败时该返回什么?
结论:只要回调数据校验通过、幂等记录已落库,无论业务后续是否成功,都要返回平台规定的成功响应体,把发货、加积分这类操作放到异步队列里重试。
如果你在处理失败时返回失败报文,平台会继续重试,而这笔交易的幂等记录已经存在,后续重试全被拒绝——业务就永远补不上了。正确做法是:幂等表落库成功即返回成功,业务失败走自己的重试队列(比如延迟 1 分钟、5 分钟、30 分钟重试 3 次),而不是靠支付平台帮你重试。
另外别忘了验签。回调报文必须用平台公钥验证签名,并校验 total_amount、seller_id 与本地订单一致,否则伪造回调能直接改你的订单状态。
怎么验证真的幂等?
结论:上线前做「重放压测」,用同一份回调报文并发打 100 次,检查订单只有 1 条 PAID 记录、只有 1 条流水、库存只扣 1 次。
具体做法:把线上真实回调报文存下来,用 JMeter 或 ab -n 100 -c 20 对回调接口重放,然后查 SELECT COUNT(*) FROM t_idempotent WHERE idem_key = ? 应为 1,SELECT COUNT(*) FROM t_order_flow WHERE order_no = ? 也应为 1。这一步能暴露出 90% 的「先查后插」写法。
最后提醒一个高频坑:主动查询订单接口和异步回调接口会同时到达。两个入口必须走同一套幂等键和同一个条件更新语句,否则用户点一次「查询支付结果」,就可能触发第二次发货。
收束一下:幂等键选对(渠道+订单号+交易类型)、唯一索引兜底、条件更新判 affected_rows、回调永远返回成功、业务失败走内部队列——这五条做到了,支付回调重复扣款这个问题在工程上就闭环了。
原文链接:https://www.gj0.com/thread-181.html
转载请注明出处并保留本声明;内容仅代表作者观点,与本站立场无关。若本文涉嫌侵权,请联系本站处理。