2. 支付基础
要治理重复扣款,先得把“一笔支付到底经历了什么”看清楚。很多团队一上来就加分布式锁、加唯一索引,结果线上仍然出现重复扣款,原因往往不是防重手段不够强,而是对支付链路和状态流转的理解有偏差。本节先把支付的地基打牢:链路、状态机、扣款流程、支付模式,以及幂等性需求究竟从哪里冒出来。
一、支付链路:一次请求要穿过多少层
从用户点击“确认支付”到资金真正到账,中间并不是一次调用,而是一条跨越多个系统的链路。典型链路如下:
- 客户端/收银台:发起支付请求,携带订单号、金额、渠道、用户标识。
- 支付网关:做参数校验、签名验签、路由选择、限流与风控前置。
- 支付核心/交易服务:创建支付单,驱动状态机,落库并生成渠道请求。
- 渠道适配层:把内部统一模型翻译成微信、支付宝、银联等渠道报文。
- 外部渠道:真正执行扣款,返回同步结果或异步回调。
- 账务/清结算:记账、对账、分账、结算,最终影响商户余额。
这条链路上每一跳都可能超时、重试、丢包或重复投递。客户端超时后会重发,网关重试会再打一次核心,渠道回调可能重复推送,消息队列可能重复消费。也就是说,重复请求不是异常,而是分布式系统的常态。幂等设计的目标不是“阻止重复请求到达”,而是“即使重复到达,也只产生一次业务效果”。
二、交易状态机:支付单的生命周期
支付单不是简单的“成功/失败”两态,而是一个有明确迁移规则的状态机。常见状态包括:
- INIT(已创建):支付单落库,尚未提交渠道。
- PROCESSING(处理中):已向渠道发起扣款,结果未知。
- SUCCESS(成功):渠道确认扣款成功,资金已扣。
- FAILED(失败):渠道明确返回失败,未扣款或已冲正。
- CLOSED(已关闭):超时未支付或主动取消。
- REFUNDING/REFUNDED(退款中/已退款):成功后的逆向流程。
状态机的核心价值在于:只有处于特定前置状态的支付单,才允许执行特定动作。例如只有 PROCESSING 才能被回调置为 SUCCESS,只有 SUCCESS 才能发起退款。任何“从 SUCCESS 再次扣款”的路径,都应当在状态机层面被拒绝,而不是靠业务代码里零散的 if 判断。
三、扣款流程与常见支付模式
扣款流程可以抽象为“请求—渠道—回调—记账”四段。同步返回只代表渠道受理,异步回调才是最终结果。不同支付模式的幂等难点也不同:
| 支付模式 | 典型场景 | 幂等关键点 |
|---|---|---|
| 同步支付 | 扫码、收银台一次性付款 | 商户订单号唯一,回调按支付单号去重 |
| 异步支付 | 网银跳转、H5 支付 | 跳转回跳与渠道回调可能双到达,需状态机收敛 |
| 代扣/协议支付 | 会员续费、自动扣款 | 扣款计划与扣款请求需双重幂等键 |
| 分账支付 | 平台抽佣、多方结算 | 主单幂等 + 分账明细幂等,避免重复分账 |
| 退款 | 售后退款、部分退款 | 退款单号唯一,按退款请求号防重 |
可以看到,无论哪种模式,幂等键的设计都是核心。通常使用“业务单号 + 操作类型”作为幂等键,例如 orderNo + PAY、orderNo + REFUND,保证同一业务动作只执行一次。
四、幂等性需求从哪里来
幂等需求不是凭空产生的,它来自链路上每一个“可能重复”的环节。归纳起来主要有五类来源:
- 客户端重试:用户网络抖动,App 自动重发或用户手动再点。
- 网关/服务重试:RPC 超时重试、负载均衡重发、Feign 重试策略。
- 渠道回调重复:渠道为保证可达性,多次推送同一笔结果。
- 消息重复消费:MQ 至少一次投递语义导致同一条消息被消费多次。
- 人工/定时补偿:对账补偿、定时任务重复触发扣款或退款。
下面是一段典型的回调处理伪代码,展示了幂等校验应该放在哪里:
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);
}
五、常见误区与复盘
在实际项目中,以下几类误区最容易引发重复扣款:
- 只靠前端防重:按钮置灰、防抖只能挡住用户手速,挡不住网络重试和渠道回调。
- 用查询代替幂等:先查“是否已支付”再扣款,存在并发窗口,两个请求可能同时查到未支付。
- 幂等键设计过粗:只用订单号,导致“支付”和“退款”互相干扰;或只用时间戳,无法稳定去重。
- 状态机形同虚设:状态字段存在,但更新时不校验前置状态,等于没有状态机。
- 忽略逆向流程:只防重复扣款,不防重复退款,退款同样会造成资金损失。
复盘这些事故会发现,重复扣款几乎都不是单点故障,而是“链路重试 + 状态机缺失 + 幂等键不当”三者叠加的结果。因此,治理思路应当是:以状态机为骨架,以幂等键为锁,以对账为兜底,三层配合,而不是寄希望于某一个技术手段解决所有问题。
把这一节的内容记住一句话:支付链路上的每一次重试、回调、补偿,都是幂等需求的来源;而状态机、幂等键和对账,是三道必须同时存在的防线。理解了这一点,后续的防重方案设计才不会变成“哪里漏了补哪里”的被动救火。