支付回调的幂等设计
为什么回调必须幂等
支付平台为了让你一定收到通知,回调是会重试的。同一笔订单支付成功,你这边可能收到一次、两次,甚至更多次 /notify。
如果每次回调都老老实实走一遍”更新订单 + 发货 + 开通权益”,轻则重复发货,重则重复扣库存、给用户重复加钱。所以回调这步必须做成幂等——同一笔通知来多少次,结果都一样。这块平时不起眼,但出了事基本都是它。
思路
这三样我习惯叠在一起用,单独靠哪一个都不够稳。
状态机:PAID 是个单向终点
订单状态尽量简单:
PENDING → PAID |
PAID 只能从 PENDING 转过来。已经是 PAID 的订单再收到回调,直接返回成功,什么都不做——这是挡重复通知的第一步。
if (order.status == PAID) { |
数据库唯一键挡并发
单机用 JVM 锁就够,但服务一旦多实例部署,两个回调可能同时落到不同机器上,JVM 锁就形同虚设了。这种事在数据库层兜底更靠谱,加个唯一索引:
UNIQUE KEY uk_payment (out_trade_no, transaction_id) |
out_trade_no 是自己的订单号,transaction_id 是支付平台给的交易号。两个一起才能定位一次真实的支付——同一笔订单可能反复下过单(用户第一次没付,关掉又重开),但每次成功支付对应的 transaction_id 是不同的。两个实例同时插,数据库只让一条成功,另一条抛唯一键冲突,catch 住当幂等处理就行。
方法级锁把同一笔订单的并发串起来
并发不高的时候,方法上加个 synchronized 就够:
public synchronized Order markPaid(String outTradeNo, String transactionId, int paidAmountFen) { |
量级上来再换分布式锁(Redis、ZooKeeper 都行),锁的 key 用 out_trade_no,目的是把同一笔订单的并发请求收拢到一个临界区里,别让它们打架。锁尽量按订单号加,别锁整个方法,不然不同订单之间会互相拖慢。
金额校验
回调里除了状态,还得校验实付金额是不是等于应付金额。这一步是防”小额套大”的——比如应付 199,有人钻空子付了 1 分钱,光看 trade_state=SUCCESS 就放行,那就出大事了:
if (paidAmountFen != order.getAmountFen()) { |
金额全链路用整数分(int)传,下单、落库、回调、查单全是分,别碰 BigDecimal 和浮点,比较起来就是两个整数等不等,干净。
过期订单怎么处理
回调可能隔很久才到——用户下单后磨蹭了半小时才付款,而你这单早就超时关了。这时再收到支付成功通知,不能闭着眼把订单改成已支付,得拒绝或者走退款:
if (order.isExpired()) { |
钱已经扣了,货不能不发,但也不能按原订单发(价格、库存可能都变了),一般走退款 + 通知用户重新下单。
一个常见的坑
下面这种写法我见过不少:
if (wxCallback.getStatus().equals("SUCCESS")) { |
没有状态检查、没有唯一键、没有锁。测试环境基本跑不出问题,因为回调就来一次;上了线、量一上来,重复回调一打过来就出问题了。
说到底就是几件事一起上:状态机挡重复、唯一键挡并发、锁把同一笔订单的请求串起来,再加一道金额校验。哪一层单拎出来都不够用,叠在一起,回调就算被重试再多次,也不用担心发重复了。
