欢迎光临
我们一直在努力

扫码支付防重复扣款详细设计

扫码支付:防止用户重复扣款(幂等/去重)详细设计

目标:不管用户怎么点(连点、刷新、回退重试)、网络怎么抖(超时重传)、回调怎么重复(支付宝 notify 多次)、系统怎么重启,都保证同一笔业务订单最多扣一次,并且最终状态可对账可恢复。
适用:支付宝 扫码支付(当面付)、也适用于微信“付款码/扫码”等同类模型。
核心关键词:业务订单号 outTradeNo、幂等键、支付单、状态机、唯一约束、回调幂等、超时补偿、对账。


1. 先说清楚:重复扣款从哪来?

常见触发点(真实线上高频):

  • 用户连点“确认支付”(前端重复触发下单/预下单)
  • 用户刷新页面/返回再进(再次发起创建支付)
  • 网络超时重试:前端/网关/SDK 认为失败又重试
  • 服务端重试:你们自己为了“保证下单成功”做了重试
  • 支付宝回调 notify 重复(它是允许重复通知的)
  • 查询接口轮询与回调并发到达(race condition)
  • 并发/分布式:同一订单同时被多个实例处理
  • 结论:支付系统必须把“重复”当作常态。
    防重复扣款的本质 = 用稳定幂等键把所有重复请求收敛到同一笔“支付单”。


    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(二维码有效期)

    关键唯一索引:

  • uk_order_channel:(order_id, channel) —— 同一个业务订单在同一渠道只允许一笔支付单
  • uk_out_trade_no:(out_trade_no) —— outTradeNo 全局唯一(对支付宝也是要求)
  • uk_trade_no:(trade_no) —— 防止同一支付宝交易号被绑定多笔支付单(强校验)
  • 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(确保接口幂等)

    关键逻辑(必须幂等)

  • 校验订单存在 & 金额正确 & 未支付
  • 创建或获取支付单(核心:同一订单只允许一笔支付单)
  • 如果支付单已 SUCCESS:直接返回“已支付”
  • 如果支付单存在且二维码未过期:直接返回已有二维码(幂等返回)
  • 否则调用支付宝 precreate(生成二维码),更新 payment(状态推进)
  • 关键并发控制(推荐实现方式)

    方式 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”

    标准处理步骤

  • 验签(不通过直接返回 fail)
  • 解析 out_trade_no / trade_no / total_amount / trade_status
  • 回调去重(推荐)
    • 尝试插入 dedup 表(唯一键 trade_no 或 notify_id)
    • 插入失败 = 已处理过 → 直接返回 success
  • 查询 payment(通过 out_trade_no)
  • 金额校验(total_amount 必须等于 payment.amount)不一致直接告警并拒绝
  • 原子更新支付单状态(只能从非 SUCCESS 推到 SUCCESS)
    • SQL 示例:UPDATE payment
      SET status='SUCCESS', trade_no=?, updated_at=NOW()
      WHERE out_trade_no=? AND status IN ('INIT','PRECREATED','PAYING');

    • 返回行数 = 1 表示本次真正完成状态推进
    • 返回行数 = 0 表示已证明“之前有人处理过”,幂等结束
  • 推进业务订单(order)为 PAID(同样用条件更新保证幂等)
  • 触发后续业务(发货、权益、发券)—— 必须走消息/事件,并且事件也要幂等
  • 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 崩、回调失败。
    要保证“最终一致”,必须有:

  • 日终对账:拉支付宝账单,与 payment/order 比对
  • 差异修复:
    • 支付宝成功但本地未 SUCCESS → 补写 SUCCESS 并补发业务事件
    • 本地 SUCCESS 但支付宝无记录 → 标记异常并人工核查
  • 强审计日志:所有状态推进留 trace(outTradeNo/tradeNo)

  • 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));
    }


    赞(0)
    未经允许不得转载:171主机测评 » 扫码支付防重复扣款详细设计
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址