Skip to content

支付回调一定会重复,你的幂等挡不挡得住

谁会卡在这

一笔订单,用户付了一次,货发了两次。或者积分加了三次。

翻日志发现回调进来了好几条,每条都长得一模一样。

踩错的代价

先说清楚一件事:支付回调重复,不是异常,是常态。

微信和支付宝都会重推。你返回慢了、返回格式不对、网络抖一下、你的服务重启——任何一种情况,对方都会认为投递失败,隔一会儿再来一次。支付宝能在一天多的时间里推好几轮。

所以「幂等」在这里不是最佳实践,是对方的重试机制逼你必须做的事。

不做的代价,按发现难度排:

发多次货。 最直接,用户会告诉你。

余额/积分加多次。 用户不会告诉你。

分账分多次、优惠券核销多次。 等对账的时候才发现,那通常是一个月以后,而且你已经想不起来当时的代码为什么那么写。

三个坑

坑一:重复回调时返回了错误

这是最反直觉的一个。

你检测到这笔订单已经处理过了,很自然地想:这是个重复请求,应该报错。于是:

java
if (order.getPaid()) {
    // 抛异常 = 告诉微信「我没收到」
    throw new RuntimeException("重复回调");
}

然后你会发现它推得更起劲了。

因为在对方眼里,抛异常 = 你没收到 = 我得再推一次。你越报错,它推得越勤,形成一个漂亮的闭环。

正确的做法是返回成功

java
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 或失败应答,让它重推

第二条别嫌麻烦。 解密失败说明这条你根本没读懂,让它重推是对的——万一是你这边瞬时故障呢。

坑二:靠状态标志做幂等,但没上锁

大部分项目的幂等长这样:

java
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 加唯一索引。重复的那条插入直接失败,捕获这个异常当成「已处理」。让数据库替你保证唯一性,比在应用层判断可靠得多——它不受并发影响。

乐观锁更新。 更新时带上状态条件:

java
// UPDATE ... SET paid = 1 WHERE paid = 0
// 只有当前未支付才更新,返回影响行数
int rows = mapper.markPaid(tradeNo);
if (rows == 0) {
    // 没更新到,别人已经处理了
    return true;
}
// 只有更新成功的那条才继续往下发货

这个写法我最喜欢:一条 SQL 同时完成「判断」和「占位」,中间没有窗口期。
上面那张表右边一列就是它——20 个并发进来,发货 1 次。

分布式锁。 按订单号加锁。能用,但引入了 Redis 依赖和锁超时问题——前两种能解决的话,别上这个。

坑三:幂等逻辑复制了十几遍

这个坑不会让你半夜起来救火,但它会让你在一年后骂人。

我见过的真实情况:一个回调处理类里,同一套 if (已支付) return 的判断出现了十六次——商城订单一次、充值订单一次、会员订单一次、储值订单一次,微信一套、支付宝再来一套。

每一处单独看都对。问题在于:

哪天你发现幂等判断有 bug,要改十六个地方。改漏一个,就是一个偶发的重复发货,而且是最难查的那种。

正确的做法是把幂等收到一个地方,业务处理只管处理:

java
// 幂等在外层统一做,业务代码不关心重复
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"},让它重推
服务商链路的订单状态和直连不一致两条链路各写了一套业务处理解析分开,业务处理合并到同一个方法
分账/积分重复,订单却没重复幂等只挡了主订单,后续动作没挡幂等要挡住整个处理单元,不只是订单状态

这条链路上的其他几篇

  • 《微信支付和支付宝,接入时到底差在哪》
  • 《微信小程序支付,从关联商户号到收到第一笔钱》

签名-B

大粽子