支付回调一定会重复,你的幂等挡不挡得住
谁会卡在这
一笔订单,用户付了一次,货发了两次。或者积分加了三次。
翻日志发现回调进来了好几条,每条都长得一模一样。
踩错的代价
先说清楚一件事:支付回调重复,不是异常,是常态。
微信和支付宝都会重推。你返回慢了、返回格式不对、网络抖一下、你的服务重启——任何一种情况,对方都会认为投递失败,隔一会儿再来一次。支付宝能在一天多的时间里推好几轮。
所以「幂等」在这里不是最佳实践,是对方的重试机制逼你必须做的事。
不做的代价,按发现难度排:
发多次货。 最直接,用户会告诉你。
余额/积分加多次。 用户不会告诉你。
分账分多次、优惠券核销多次。 等对账的时候才发现,那通常是一个月以后,而且你已经想不起来当时的代码为什么那么写。
三个坑
坑一:重复回调时返回了错误
这是最反直觉的一个。
你检测到这笔订单已经处理过了,很自然地想:这是个重复请求,应该报错。于是:
if (order.getPaid()) {
// 抛异常 = 告诉微信「我没收到」
throw new RuntimeException("重复回调");
}然后你会发现它推得更起劲了。
因为在对方眼里,抛异常 = 你没收到 = 我得再推一次。你越报错,它推得越勤,形成一个漂亮的闭环。
正确的做法是返回成功:
if (order.getPaid()) {
// 不是异常,是成功
log.info("dup callback {}", tradeNo);
return true;
}语义要理清楚:「这笔我已经处理过了」对支付平台来说就是成功,它的诉求只是「确认你收到了」。重复不是错误,是它的设计。
顺带说应答码:
- 业务处理完成(含重复)→ 按平台要求应答成功,让它别再推了
微信 v3 的「应答成功」是 HTTP 200 或 204,而且 body 要空。
失败才回 4XX/5XX 加一段 JSON:{"code":"FAIL","message":"..."}。
回 200 带一个 JSON body 是 v2 XML 时代的习惯,v3 下这么写,
微信解析不到预期的应答,照样当你没收到——你会看着日志里业务明明处理成功了,
回调还在一遍遍推。支付宝那边相反,要的是纯文本 success。
- 解密失败、验签失败、系统异常 → 返回 500 或失败应答,让它重推
第二条别嫌麻烦。 解密失败说明这条你根本没读懂,让它重推是对的——万一是你这边瞬时故障呢。
坑二:靠状态标志做幂等,但没上锁
大部分项目的幂等长这样:
Order order = orderService
.getByOutTradeNo(tradeNo);
if (order.getPaid()) {
return true; // 已处理,幂等返回
}
// ... 改状态、发货、加积分
order.setPaid(true);
orderService.updateById(order);单线程看没问题。并发下这个判断等于不存在。
「等于不存在」不是修辞。我把这两种写法各跑了一遍,同一笔订单并发推 N 次回调,数实际发了几次货:
| 并发回调 | 先查后改 | UPDATE ... WHERE paid=0 |
|---|---|---|
| 2 次 | 发货 2 次 | 发货 1 次 |
| 5 次 | 发货 5 次 | 发货 1 次 |
| 10 次 | 发货 10 次 | 发货 1 次 |
| 20 次 | 发货 20 次 | 发货 1 次 |
先查后改的拦截率是 0。 不是「大部分能拦住,偶尔漏一个」——是并发上来之后,一个都拦不住,推几次发几次。
原因很直白:所有线程都执行到第 2 行,都读到 paid = false,然后都往下走。那个 if 判断在并发下没有任何约束力,它只是让代码看起来做了防护。
这也解释了为什么这个 bug 这么难查:单元测试一定是过的(单线程),生产环境偶尔炸一次,日志里看起来一切正常——每条回调自己那一份日志都是对的。
三种堵法,从轻到重:
数据库唯一索引(最推荐)。 在支付流水表上给 out_trade_no 加唯一索引。重复的那条插入直接失败,捕获这个异常当成「已处理」。让数据库替你保证唯一性,比在应用层判断可靠得多——它不受并发影响。
乐观锁更新。 更新时带上状态条件:
// UPDATE ... SET paid = 1 WHERE paid = 0
// 只有当前未支付才更新,返回影响行数
int rows = mapper.markPaid(tradeNo);
if (rows == 0) {
// 没更新到,别人已经处理了
return true;
}
// 只有更新成功的那条才继续往下发货这个写法我最喜欢:一条 SQL 同时完成「判断」和「占位」,中间没有窗口期。
上面那张表右边一列就是它——20 个并发进来,发货 1 次。
分布式锁。 按订单号加锁。能用,但引入了 Redis 依赖和锁超时问题——前两种能解决的话,别上这个。
坑三:幂等逻辑复制了十几遍
这个坑不会让你半夜起来救火,但它会让你在一年后骂人。
我见过的真实情况:一个回调处理类里,同一套 if (已支付) return 的判断出现了十六次——商城订单一次、充值订单一次、会员订单一次、储值订单一次,微信一套、支付宝再来一套。
每一处单独看都对。问题在于:
哪天你发现幂等判断有 bug,要改十六个地方。改漏一个,就是一个偶发的重复发货,而且是最难查的那种。
正确的做法是把幂等收到一个地方,业务处理只管处理:
// 幂等在外层统一做,业务代码不关心重复
public boolean handle(String tradeNo,
Runnable business) {
// 原子操作:占位成功才往下走
if (!tryMarkProcessed(tradeNo)) {
return true; // 已处理过
}
business.run();
return true;
}判断标准很简单:如果你要给幂等加一行日志,需要改几个文件? 超过一个,就该收了。
一个真实的设计权衡
顺带说一个我觉得做得对的地方。
支付有两条链路——直连商户和服务商(收付通),它们的回调报文格式不一样、参数不一样,很容易写成两套处理逻辑。
但订单该怎么改状态、该发什么货,这两条链路应该完全一致。
所以正确的做法是:报文解析分开,业务处理合并。
直连回调 ─┐
├─→ 解析出订单号
收付通回调 ─┘ ↓
markOrderPaid()两条链路各自解析,然后汇到同一个方法上。这样改发货逻辑只改一处,两条链路自动一致。
这个分界线值得记:解析是渠道相关的,业务是渠道无关的。 分错了地方,就会有一条链路悄悄落后于另一条。
常见报错对照表
| 现象 | 真正的原因 | 解法 |
|---|---|---|
| 回调一直重推,业务早处理完了 | 重复时抛了异常或返回失败 | 已处理要返回成功,不是报错 |
| 回调一直重推,日志里根本没有记录 | 应答格式不对 | 微信 v3 要 200/204 空 body(回 JSON 是 v2 习惯);支付宝要纯文本 success |
| 偶发重复发货,日志看不出异常 | 查状态和改状态之间没锁,并发窗口 | 唯一索引或带条件的 UPDATE,别用先查后改 |
| 改了幂等逻辑,还是偶发重复 | 同一套判断复制了多处,改漏了 | 幂等收到一处,业务不关心重复 |
| 解密失败,直接返回了 200 | 没读懂却告诉对方「收到了」,这笔就永久丢了 | 验签失败要回 4XX/5XX + {"code":"FAIL"},让它重推 |
| 服务商链路的订单状态和直连不一致 | 两条链路各写了一套业务处理 | 解析分开,业务处理合并到同一个方法 |
| 分账/积分重复,订单却没重复 | 幂等只挡了主订单,后续动作没挡 | 幂等要挡住整个处理单元,不只是订单状态 |
这条链路上的其他几篇
- 《微信支付和支付宝,接入时到底差在哪》
- 《微信小程序支付,从关联商户号到收到第一笔钱》
