2. 支付基础

要治理重复扣款,先得把“一笔支付到底经历了什么”看清楚。很多团队一上来就加分布式锁、加唯一索引,结果线上仍然出现重复扣款,原因往往不是防重手段不够强,而是对支付链路和状态流转的理解有偏差。本节先把支付的地基打牢:链路、状态机、扣款流程、支付模式,以及幂等性需求究竟从哪里冒出来。

一、支付链路:一次请求要穿过多少层

从用户点击“确认支付”到资金真正到账,中间并不是一次调用,而是一条跨越多个系统的链路。典型链路如下:

  • 客户端/收银台:发起支付请求,携带订单号、金额、渠道、用户标识。
  • 支付网关:做参数校验、签名验签、路由选择、限流与风控前置。
  • 支付核心/交易服务:创建支付单,驱动状态机,落库并生成渠道请求。
  • 渠道适配层:把内部统一模型翻译成微信、支付宝、银联等渠道报文。
  • 外部渠道:真正执行扣款,返回同步结果或异步回调。
  • 账务/清结算:记账、对账、分账、结算,最终影响商户余额。

这条链路上每一跳都可能超时、重试、丢包或重复投递。客户端超时后会重发,网关重试会再打一次核心,渠道回调可能重复推送,消息队列可能重复消费。也就是说,重复请求不是异常,而是分布式系统的常态。幂等设计的目标不是“阻止重复请求到达”,而是“即使重复到达,也只产生一次业务效果”。

关键认知:支付链路的每一层都可能是重复的来源,也可能是幂等的防线。防重不能只押在一个点上。

二、交易状态机:支付单的生命周期

支付单不是简单的“成功/失败”两态,而是一个有明确迁移规则的状态机。常见状态包括:

  • INIT(已创建):支付单落库,尚未提交渠道。
  • PROCESSING(处理中):已向渠道发起扣款,结果未知。
  • SUCCESS(成功):渠道确认扣款成功,资金已扣。
  • FAILED(失败):渠道明确返回失败,未扣款或已冲正。
  • CLOSED(已关闭):超时未支付或主动取消。
  • REFUNDING/REFUNDED(退款中/已退款):成功后的逆向流程。

状态机的核心价值在于:只有处于特定前置状态的支付单,才允许执行特定动作。例如只有 PROCESSING 才能被回调置为 SUCCESS,只有 SUCCESS 才能发起退款。任何“从 SUCCESS 再次扣款”的路径,都应当在状态机层面被拒绝,而不是靠业务代码里零散的 if 判断。

最常见的重复扣款事故:支付单已 SUCCESS,但由于回调处理超时被重试,代码没有校验当前状态,直接又走了一遍扣款逻辑。

三、扣款流程与常见支付模式

扣款流程可以抽象为“请求—渠道—回调—记账”四段。同步返回只代表渠道受理,异步回调才是最终结果。不同支付模式的幂等难点也不同:

支付模式 典型场景 幂等关键点
同步支付 扫码、收银台一次性付款 商户订单号唯一,回调按支付单号去重
异步支付 网银跳转、H5 支付 跳转回跳与渠道回调可能双到达,需状态机收敛
代扣/协议支付 会员续费、自动扣款 扣款计划与扣款请求需双重幂等键
分账支付 平台抽佣、多方结算 主单幂等 + 分账明细幂等,避免重复分账
退款 售后退款、部分退款 退款单号唯一,按退款请求号防重

可以看到,无论哪种模式,幂等键的设计都是核心。通常使用“业务单号 + 操作类型”作为幂等键,例如 orderNo + PAY、orderNo + REFUND,保证同一业务动作只执行一次。

四、幂等性需求从哪里来

幂等需求不是凭空产生的,它来自链路上每一个“可能重复”的环节。归纳起来主要有五类来源:

  1. 客户端重试:用户网络抖动,App 自动重发或用户手动再点。
  2. 网关/服务重试:RPC 超时重试、负载均衡重发、Feign 重试策略。
  3. 渠道回调重复:渠道为保证可达性,多次推送同一笔结果。
  4. 消息重复消费:MQ 至少一次投递语义导致同一条消息被消费多次。
  5. 人工/定时补偿:对账补偿、定时任务重复触发扣款或退款。

下面是一段典型的回调处理伪代码,展示了幂等校验应该放在哪里:

public void handleChannelCallback(CallbackDTO dto) {
    // 1. 验签,防止伪造回调
    verifySign(dto);

    // 2. 以渠道流水号 + 支付单号做幂等键,尝试占位
    boolean first = idempotentRepo.tryLock(dto.getPayNo(), dto.getChannelSeq());
    if (!first) {
        log.info("duplicate callback ignored, payNo={}", dto.getPayNo());
        return;
    }

    // 3. 状态机校验:只有 PROCESSING 才能置为 SUCCESS
    PaymentOrder order = orderRepo.get(dto.getPayNo());
    if (order.getStatus() != PROCESSING) {
        log.warn("illegal state transition, payNo={}, status={}",
                 dto.getPayNo(), order.getStatus());
        return;
    }

    // 4. 更新状态并记账,同一事务内完成
    orderRepo.updateStatus(dto.getPayNo(), SUCCESS);
    accountService.record(order);
}
幂等校验要“先占位、再处理”,并且占位与业务处理尽量在同一事务或可补偿的流程中,避免占位成功但业务失败导致请求被永久丢弃。

五、常见误区与复盘

在实际项目中,以下几类误区最容易引发重复扣款:

  • 只靠前端防重:按钮置灰、防抖只能挡住用户手速,挡不住网络重试和渠道回调。
  • 用查询代替幂等:先查“是否已支付”再扣款,存在并发窗口,两个请求可能同时查到未支付。
  • 幂等键设计过粗:只用订单号,导致“支付”和“退款”互相干扰;或只用时间戳,无法稳定去重。
  • 状态机形同虚设:状态字段存在,但更新时不校验前置状态,等于没有状态机。
  • 忽略逆向流程:只防重复扣款,不防重复退款,退款同样会造成资金损失。

复盘这些事故会发现,重复扣款几乎都不是单点故障,而是“链路重试 + 状态机缺失 + 幂等键不当”三者叠加的结果。因此,治理思路应当是:以状态机为骨架,以幂等键为锁,以对账为兜底,三层配合,而不是寄希望于某一个技术手段解决所有问题。

支付链路中的重复来源与幂等防线 客户端 收银台 支付网关 验签/路由 交易核心 状态机 渠道适配 报文转换 外部渠道 真实扣款 ① 用户重发 ② RPC 重试 ③ 补偿重跑 ④ 回调重复 ⑤ MQ 重复消费 幂等键占位 orderNo + 操作类型 状态机校验 前置状态合法才迁移 对账兜底 T+1 核对渠道流水 治理原则 重复请求是常态,幂等不是阻止重复,而是让重复不产生二次业务效果 状态机为骨架 · 幂等键为锁 · 对账为兜底

把这一节的内容记住一句话:支付链路上的每一次重试、回调、补偿,都是幂等需求的来源;而状态机、幂等键和对账,是三道必须同时存在的防线。理解了这一点,后续的防重方案设计才不会变成“哪里漏了补哪里”的被动救火。