扫码支付:防止用户重复扣款(幂等/去重)详细设计
目标:不管用户怎么点(连点、刷新、回退重试)、网络怎么抖(超时重传)、回调怎么重复(支付宝 notify 多次)、系统怎么重启,都保证同一笔业务订单最多扣一次,并且最终状态可对账可恢复。
适用:支付宝 扫码支付(当面付)、也适用于微信“付款码/扫码”等同类模型。
核心关键词:业务订单号 outTradeNo、幂等键、支付单、状态机、唯一约束、回调幂等、超时补偿、对账。
1. 先说清楚:重复扣款从哪来?
常见触发点(真实线上高频):
结论:支付系统必须把“重复”当作常态。
防重复扣款的本质 = 用稳定幂等键把所有重复请求收敛到同一笔“支付单”。
2. 设计总览:两层防线(缺一不可)
2.1 第一层:业务侧幂等(防止多次创建多笔支付)
- 同一业务订单只能生成一笔有效支付单(或一笔“进行中”的支付单)
- 依赖:唯一约束 / 幂等表 / 分布式锁 / Redis SETNX / 事务
2.2 第二层:支付结果处理幂等(防止回调/查询导致重复入账)
- notify、主动查询、补单等任何渠道到达,都只能把支付单状态推进一次
- 依赖:状态机 + 原子更新 + 幂等消费 + 去重表
3. 核心数据模型(强烈建议按这个拆)
3.1 业务订单表(order)
- order_id(业务主键)
- order_no(业务订单号,展示/对外)
- amount、title、user_id
- status:CREATED / PAYING / PAID / CLOSED / REFUNDING / REFUNDED
3.2 支付单表(payment)
这是防重复扣款的主战场,强烈建议独立出来,不要把支付字段散落在 order 表里。
字段建议:
- pay_id(主键)
- order_id
- out_trade_no(你们生成的支付订单号,给支付宝用)
- channel(ALIPAY)
- amount
- status:INIT / PRECREATED / PAYING / SUCCESS / FAIL / CLOSED
- trade_no(支付宝交易号,成功后写入)
- qr_code(预下单二维码内容/链接)
- request_id(幂等键,可选:每次预下单请求的 id)
- created_at / updated_at
- version(乐观锁,可选)
- expire_time(二维码有效期)
关键唯一索引:
3.3 回调去重表(pay_notify_dedup)(推荐)
- id
- channel
- trade_no(支付宝交易号)或 notify_id(支付宝通知里有 notify_id 时也可用)
- out_trade_no
- payload_hash
- created_at
唯一索引:(channel, trade_no) 或 (channel, notify_id)
作用:回调重复投递时,秒级去重,避免业务层重复执行。
4. outTradeNo / 幂等键怎么选?
4.1 outTradeNo(支付订单号)原则
- 必须稳定且唯一
- 同一笔业务订单重复发起支付,要么复用同一个 outTradeNo(强幂等),要么生成新 outTradeNo 但要保证旧的关闭(更复杂)
推荐(简单且稳):一单一 outTradeNo
- 对同一 order_id:永远复用同一个 outTradeNo
- 用户重复点、刷新、重试:都只会返回同一个二维码/同一个支付单
4.2 requestId(请求幂等键)什么时候用?
- 用于接口级别去重:比如“预下单接口”被网关重试了
- 但它不能替代 outTradeNo(因为用户可能多次进入页面,requestId 不同)
推荐:两层幂等
- 业务幂等键:order_id + channel(决定是不是同一笔支付单)
- 请求幂等键:request_id(决定重复 HTTP 请求不重复处理)
5. 流程设计(端到端)
5.1 预下单生成二维码(precreate)
API
POST /pay/alipay/precreate
入参:orderId(或 orderNo)、requestId(确保接口幂等)
关键逻辑(必须幂等)
关键并发控制(推荐实现方式)
方式 A:DB 唯一约束 + 事务(最靠谱)
- 先 INSERT payment(order_id, channel, out_trade_no, status=INIT)
- 若唯一冲突(uk_order_channel):说明已存在,改为 SELECT 取出
- 这样无需分布式锁,靠数据库强一致去重
方式 B:Redis SETNX 锁(适合高并发减少 DB 冲突)
- SETNX lock:pay:{orderId} 1 EX 3
- 拿不到锁就短暂重试/直接返回“处理中”并让前端轮询
- 仍建议最终落 DB 唯一约束做兜底(锁可能丢)
工程建议:A 为主,B 为辅。
5.2 用户扫码支付(发生在支付宝侧)
你们不参与扣款执行,但要负责:
- 展示二维码
- 监听支付结果(回调 or 轮询)
5.3 支付宝异步通知 notify(最关键的幂等点)
API
POST /pay/alipay/notify(公网可达)
处理原则
- notify 可能重复、可能乱序、可能延迟
- 必须“验签 + 幂等 + 原子状态推进 + 返回 success”
标准处理步骤
- 尝试插入 dedup 表(唯一键 trade_no 或 notify_id)
- 插入失败 = 已处理过 → 直接返回 success
- SQL 示例:UPDATE payment
SET status='SUCCESS', trade_no=?, updated_at=NOW()
WHERE out_trade_no=? AND status IN ('INIT','PRECREATED','PAYING'); - 返回行数 = 1 表示本次真正完成状态推进
- 返回行数 = 0 表示已证明“之前有人处理过”,幂等结束
notify 返回
- 成功处理(包括幂等重复):返回字符串 success
- 否则返回 fail(支付宝会重试)
5.4 主动查询(兜底:防 notify 丢失)
原因:公网回调可能被防火墙/网关丢弃,必须兜底。
做法:Web 端轮询 /pay/query?orderId=…,服务端查 payment 状态:
- 若 payment 已 SUCCESS → 返回成功
- 若未成功但超过一定时间(如 30s/60s) → 调用支付宝“交易查询”接口确认
- 查询到成功 → 走与 notify 同一套“状态推进函数”(共用逻辑)
重点:notify 和 query 必须复用同一套“幂等推进”方法,避免双写。
6. 关键实现:状态机 + 原子推进(避免并发双成功)
6.1 状态机建议
- INIT:支付单创建
- PRECREATED:已生成二维码
- PAYING:可选(你也可以省略)
- SUCCESS:支付成功(终态)
- CLOSED:超时关闭/用户取消(终态)
- FAIL:失败(终态或中间态)
6.2 原子推进的写法(推荐)
- 用 WHERE status IN (…) 做“条件更新”
- 或加 version 乐观锁(WHERE version=?)
只要保证:从非 SUCCESS → SUCCESS 的更新只能成功一次
就不会重复扣款(哪怕回调来 10 次、查询来 10 次)。
7. 防止“重复发起多笔支付”的策略(重要)
这里是很多团队踩坑的地方:
用户觉得没付上,再点一次,结果生成了第二张码,最后两笔都付了(这才是真“重复扣款”灾难)。
推荐策略:同一订单只允许一个“进行中支付单”
- 订单状态为 CREATED/PAYING 时:
- 若 payment 未过期:重复返回同一个二维码
- 若过期:先尝试关闭旧支付单(本地置 CLOSED + 调用支付宝关闭交易),再生成新的(可选)
关键:前端也要配合
- 按钮防抖(1~2s)
- 展示二维码后禁止重复发起
- 支付中倒计时 + “刷新二维码”明确入口
8. 幂等的“终极兜底”:资金对账 + 反查补单
再严谨也会有极端情况:消息丢、DB 崩、回调失败。
要保证“最终一致”,必须有:
- 支付宝成功但本地未 SUCCESS → 补写 SUCCESS 并补发业务事件
- 本地 SUCCESS 但支付宝无记录 → 标记异常并人工核查
9. 典型测试用例(必须覆盖)
- 用户连点预下单:只能生成 1 笔 payment
- 用户刷新页面:返回同一二维码
- 网关超时重试:requestId 去重不重复调用支付宝
- notify 重复 10 次:只能推进一次 SUCCESS
- notify 与 query 并发:只有一次 SQL 更新成功
- 金额被篡改:校验失败 + 告警
- 关闭后再支付:禁止或按规则生成新支付单(可控)
- 回调丢失:查询兜底能补成功
- 服务重启/多实例:依旧幂等
10. 可直接套用的“幂等推进”伪代码
@Transactional
public void applyPaid(String outTradeNo, String tradeNo, BigDecimal payAmount) {
Payment pay = paymentRepo.findByOutTradeNo(outTradeNo);
if (pay == null) throw new BizException("PAYMENT_NOT_FOUND");
// 金额校验(强制)
if (pay.getAmount().compareTo(payAmount) != 0) {
alarm("AMOUNT_MISMATCH", outTradeNo, tradeNo);
throw new BizException("AMOUNT_MISMATCH");
}
// 原子推进:只允许成功一次
int updated = paymentRepo.markSuccessIfNotSuccess(outTradeNo, tradeNo);
if (updated == 0) {
// 已处理过:幂等返回
return;
}
// 订单推进同样幂等(where status != PAID)
orderRepo.markPaidIfNotPaid(pay.getOrderId());
// 业务后续:发 MQ(消息也要幂等)
mq.send(new OrderPaidEvent(pay.getOrderId(), outTradeNo, tradeNo));
}


