线上最容易惹麻烦的一件事,就是“钱相关”的 bug。 微信支付里最典型的一个坑,就是:同一笔业务订单,被付了两次、甚至多次。
这篇就聊聊从服务端视角,怎么设计“防重复支付”,尽量用人话说清楚,用 Java 举例子。
一、先搞清楚:什么叫“重复支付”?
很多同学一说重复支付,就只想到“用户连点了两次支付按钮”。其实场景比这个多:
-
用户付完钱,页面卡住了,以为没付成功,又重新点了一次。 -
手机信号不好,微信那边回调你服务端多次。 -
业务方自己手欠,用同一个业务单号,调了两次“下单”接口。 -
定时任务补偿时没做幂等,又把支付逻辑跑了一遍。
注意一个细节:微信侧有自己的订单号 transaction_id,商户侧有自己的业务订单号,比如 bizOrderNo / out_trade_no。我们要防的是:同一个业务订单(比如同一张商品订单),最终只应该成功收一笔钱。
二、微信本身已经做了什么防重?
微信支付其实已经帮我们做了一部分工作:
-
out_trade_no在同一个商户号下面要求唯一。 -
同一个 out_trade_no重复发起,微信有的场景会直接告诉你“订单已支付”或者“订单号已存在”。 -
微信的支付结果通知(回调)是至少一次投递,也就是说有可能重复。
翻译成人话就是:
-
你只要保证自己给微信传的 out_trade_no不乱搞,它就不会帮你扣两次钱。 -
但是:业务系统的“扣一次库存、送一次优惠券、改一次订单状态”这一整串操作,要你自己保证只执行一次。
三、服务端整体思路:所有动作都围着“支付单”转
比较稳妥的做法是:把“支付”当成一个独立的“支付单”来管理,而不是到处散落逻辑。
简单设计一个支付表(随便举个例子):
CREATE TABLE pay_order (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
biz_order_no VARCHAR(64) NOT NULL, -- 业务订单号(例如商城订单)
out_trade_no VARCHAR(64) NOT NULL, -- 传给微信的商户订单号
amount BIGINT NOT NULL, -- 支付金额,单位分
status VARCHAR(16) NOT NULL, -- INIT / PAYING / SUCCESS / FAILED / CLOSED
pay_channel VARCHAR(32) NOT NULL, -- WECHAT
version INT NOT NULL DEFAULT 0, -- 乐观锁版本号
create_time DATETIME NOT NULL,
update_time DATETIME NOT NULL,
UNIQUE KEY uk_biz_order (biz_order_no),
UNIQUE KEY uk_out_trade_no (out_trade_no)
);
这里真正防重复的“刺刀”有几个:
-
biz_order_no唯一:同一张业务订单,只会对应一条支付单。 -
out_trade_no唯一:同一个商户订单号,不会在微信那边被创建两次。 -
status + version:配合乐观锁,解决并发更新的问题。
四、创建支付单时如何防重?
最常见的流程是:用户在前端点“去支付”,服务端收到请求,生成一条支付单,然后调微信统一下单接口。
要防重,核心有两句话:
-
先查/创建本地支付单,再考虑调微信。 -
对同一个 biz_order_no,只允许存在状态正常的一条记录。
伪代码(Spring 风格的大概长这样):
@Service
public class PayService {
@Transactional
public WechatPrepayResponse createPayOrder(String bizOrderNo, Long userId) {
// 1. 查业务订单状态(比如商城订单),已经支付成功直接返回
Order order = orderRepository.findByBizOrderNo(bizOrderNo);
if (order == null) {
throw new IllegalArgumentException("业务订单不存在");
}
if (order.isPaid()) {
// 幂等处理:已经是已支付的订单,就不要再下微信单了
return buildPaidResponseFromHistory(bizOrderNo);
}
// 2. 查是否已有支付单
PayOrder payOrder = payOrderRepository.findByBizOrderNo(bizOrderNo);
if (payOrder == null) {
// 2.1 没有就新建一条
payOrder = new PayOrder();
payOrder.setBizOrderNo(bizOrderNo);
payOrder.setOutTradeNo(generateOutTradeNo(bizOrderNo));
payOrder.setAmount(order.getPayAmount());
payOrder.setStatus(PayStatus.INIT.name());
payOrder.setPayChannel("WECHAT");
payOrderRepository.save(payOrder);
} else {
// 2.2 已经存在支付单
if (PayStatus.SUCCESS.name().equals(payOrder.getStatus())) {
// 本地记着已经成功了,直接走幂等返回
return buildPaidResponseFromHistory(bizOrderNo);
}
// 如果是 INIT / PAYING,可以复用这笔 payOrder 继续调微信
}
// 3. 调微信统一下单
WechatUnifiedOrderRequest request = buildWechatRequest(order, payOrder);
WechatUnifiedOrderResult result = wechatClient.unifiedOrder(request);
// 4. 更新支付单状态
payOrder.setStatus(PayStatus.PAYING.name());
payOrderRepository.save(payOrder);
// 5. 返回给前端 prepay 信息
return buildPrepayResponse(result);
}
}
这里其实就干了三件事:
-
同一个 biz_order_no,永远只操作那一条pay_order。 -
发现本地已经是支付成功,就直接走“幂等返回”,不再让用户创建新单。 -
即便前端误操作、接口重试多次,**都只是在重复使用同一条支付单和同一个 out_trade_no**。
五、最关键的一环:回调通知如何防重?
真正决定“这笔钱算不算付成功”的,是微信的异步通知(notify_url)。
特点很简单:
-
微信会重试通知,直到你返回 SUCCESS或者超出最大重试次数。 -
所以你必须把通知处理做成幂等操作。
伪代码示例:
@RestController
@RequestMapping("/wechat/pay")
public class WechatPayNotifyController {
@PostMapping("/notify")
public String payNotify(HttpServletRequest request) {
// 1. 读取并验签
String body = readRequestBody(request);
WechatNotifyData notifyData = wechatClient.parseAndVerify(body);
if (notifyData == null) {
// 验签失败,按微信要求返回失败
return buildFailXml("SIGN_ERROR");
}
String outTradeNo = notifyData.getOutTradeNo();
String transactionId = notifyData.getTransactionId();
Long totalFee = notifyData.getTotalFee();
String tradeState = notifyData.getTradeState(); // SUCCESS / 其他
// 2. 查本地支付单
PayOrder payOrder = payOrderRepository.findByOutTradeNo(outTradeNo);
if (payOrder == null) {
// 本地没有记录,通常是配置错误或者恶意请求
return buildFailXml("ORDER_NOT_FOUND");
}
// 3. 金额、状态校验
if (!Objects.equals(payOrder.getAmount(), totalFee)) {
return buildFailXml("AMOUNT_NOT_MATCH");
}
// 4. 幂等处理:如果已经是成功了,直接返回 SUCCESS
if (PayStatus.SUCCESS.name().equals(payOrder.getStatus())) {
return buildSuccessXml();
}
// 5. 使用乐观锁更新支付单状态
int updated = payOrderRepository.updateStatusToSuccess(
payOrder.getId(),
PayStatus.SUCCESS.name(),
payOrder.getVersion(),
transactionId
);
if (updated == 0) {
// 说明有别的线程比我先一步处理了,同样走幂等
return buildSuccessXml();
}
// 6. 在同一个事务中,更新业务订单的状态
try {
orderService.onPaySuccess(payOrder.getBizOrderNo());
} catch (Exception e) {
// 这里要十分小心:
// - 要么抛异常回滚,让微信稍后再通知
// - 要么把失败记录到本地表,走人工/任务补偿
throw e;
}
return buildSuccessXml();
}
}
updateStatusToSuccess 可以这么写(伪代码):
@Modifying
@Query("update PayOrder p set p.status = :status, p.version = p.version + 1, " +
"p.transactionId = :transactionId, p.updateTime = now() " +
"where p.id = :id and p.version = :version " +
"and p.status in ('INIT', 'PAYING')")
int updateStatusToSuccess(@Param("id") Long id,
@Param("status") String status,
@Param("version") Integer version,
@Param("transactionId") String transactionId);
这里有几个关键点:
-
用 status in ('INIT','PAYING')限制状态机,避免从 CLOSED / FAILED 反向改回 SUCCESS。 -
带上 version做乐观锁,防止高并发时多次写入产生脏数据。 -
更新支付单和业务单,最好放在一个事务里面,保证“要么都成功,要么都失败”。
六、查询接口也要“防重思维”
不少产品会给前端一个“轮询查询支付结果”的接口。这里也容易踩坑。
思路其实很简单:
-
查询接口永远只查本地支付单 / 业务订单状态。 -
如果本地还没成功,可以适当去调一次微信“订单查询”接口同步一下。 -
无论前端怎么疯狂轮询,最多也只是让你多查了几次数据库/微信,不应该重新发起支付。
伪代码:
@RestController
@RequestMapping("/pay")
public class PayQueryController {
@GetMapping("/status")
public PayStatusDTO queryPayStatus(@RequestParam String bizOrderNo) {
PayOrder payOrder = payOrderRepository.findByBizOrderNo(bizOrderNo);
if (payOrder == null) {
return PayStatusDTO.notFound();
}
// 已成功,直接返回
if (PayStatus.SUCCESS.name().equals(payOrder.getStatus())) {
return PayStatusDTO.success();
}
// 还在 INIT / PAYING,可以选用:本地直接返回“支付中”即可
// 或者再调一次微信订单查询接口做同步(注意限流)
return PayStatusDTO.processing();
}
}
注意千万别在这个查询接口里,发现没成功就“顺手再帮用户下单一次”,那就真成“官方制造重复支付”了。
七、再补几招“锦上添花”的防重复策略
上面这些是服务端必须做的,再说几个常见但不是核心的:
-
前端按钮防抖 / 禁用支付按钮点击后直接置灰,等结果回来再恢复,能减少“误点多次”的概率,但绝对不能当成唯一手段。
-
支付 token / nonce服务端在创建支付前发一个一次性的 token,前端带着 token 调起支付接口,成功/失败后 token 作废,同一个 token 只能用一次。 这个更适合“防重复提单”,和支付本身一起用效果更好。
-
Redis 分布式锁比如对
bizOrderNo做一个短期锁:String lockKey = "lock:pay:" + bizOrderNo;
boolean locked = redisTemplate.opsForValue().setIfAbsent(lockKey, "1", 10, TimeUnit.SECONDS);
if (!locked) {
// 有别的请求正在处理这笔订单,可以直接返回“处理中”
}但要记住:锁只是辅助,真正硬保证的还是数据库的唯一约束 + 状态机。
如果只记得一件事,那就是:
防重复支付 = 支付单表 + 唯一约束 + 幂等回调 + 正确的状态机。
-END-
我为大家打造了一份RPA教程,完全免费:songshuhezi.com/rpa.html
🔥东哥私藏精品🔥
东哥作为一名老码农,整理了全网最全《Java高级架构师资料合集》。总量高达650GB。点击下方公众号回复关键字java 全部免费领取

