2. 支付基础
要真正理解“重复付款”是怎么发生的,必须先看清一笔支付在系统内部是如何被创建、流转、确认和终结的。很多防重方案失效,并不是因为开发人员不懂加锁,而是因为对支付流程的阶段划分、交易生命周期的边界、支付指令的幂等语义以及状态机的迁移规则理解不一致。本节把这四个基础概念串成一条主线,为后续的重复付款检测打下模型基础。
一、核心原理:一笔支付到底经历了什么
从用户点击“确认支付”到资金最终入账,支付系统内部并不是一次调用,而是一连串跨系统协作。典型链路可以抽象为四层:交易层负责生成业务订单与支付单;指令层负责把支付意图翻译成渠道可识别的请求;渠道层对接银行、第三方支付或清算网络;账务层完成记账与对账。重复付款往往发生在“交易层已生成、指令层已发出、但结果未知”的灰色区间。
这里的关键原理是:支付请求的“发出”与“成功”之间存在不可消除的不确定性。网络超时、渠道无响应、回调丢失,都会让系统无法立即判断这笔支付是否已经生效。如果此时简单重试,就可能产生第二笔真实扣款。因此,支付系统必须用“支付指令 + 状态机”来管理这种不确定性,而不是依赖单次调用的返回值。
二、交易生命周期与状态机
交易生命周期描述一笔支付从诞生到终结的全过程,状态机则规定它在每个阶段允许迁移到哪些状态。二者结合,才能判断“当前这笔支付是否还能再次发起”。一个常见的支付单状态集合如下:
| 状态 | 含义 | 是否终态 | 可否再次发起支付 |
|---|---|---|---|
| INIT | 支付单已创建,尚未请求渠道 | 否 | 可以,但需复用同一支付单号 |
| PROCESSING | 指令已发出,等待渠道结果 | 否 | 不可新建,只能查询或等待 |
| SUCCESS | 渠道确认扣款成功 | 是 | 绝对不可再次发起 |
| FAILED | 渠道明确返回失败 | 是 | 可基于新支付单重试 |
| UNKNOWN | 超时或结果不明,需补偿查询 | 否 | 不可新建,必须先查证 |
| CLOSED | 超时关闭或人工撤销 | 是 | 不可再次发起 |
状态机的价值在于:它把“能不能再付一次”变成一个可判定的问题。只要支付单处于 PROCESSING 或 UNKNOWN,任何新建支付指令的行为都应被拦截。下面是一段简化的状态迁移校验逻辑:
function canCreateNewInstruction(payOrder) {
const blocked = ['PROCESSING', 'UNKNOWN', 'SUCCESS', 'CLOSED'];
if (blocked.includes(payOrder.status)) {
throw new BizException('PAY_DUPLICATE_BLOCKED', payOrder.payOrderNo);
}
return true;
}
注意,FAILED 被允许重试,但必须生成新的支付单号或新的指令号,否则渠道侧可能把两次请求视为同一笔而返回缓存结果,造成状态误判。
三、支付指令与幂等语义
支付指令是交易层向渠道层发出的“动作请求”,它必须携带一个全局唯一的指令号(如 requestId 或 outTradeNo)。这个编号是防重的第一道闸门:渠道侧通常以该编号做幂等,同一编号重复提交只会返回首次结果。因此,系统内部必须保证“同一笔支付意图,指令号稳定不变”。
常见做法是把支付单号与指令号绑定,并在重试时复用同一指令号,而不是每次重试都生成新号。下面是一个指令构造示例:
{
"requestId": "PAY202406120001",
"payOrderNo": "PO202406120001",
"channel": "WECHAT",
"amount": 19900,
"currency": "CNY",
"notifyUrl": "https://api.example.com/pay/callback",
"idempotentKey": "PO202406120001"
}
四、案例与误区复盘
某电商平台在大促期间出现用户被扣两次款。复盘发现:支付网关在调用渠道超时后,自动触发了一次重试,但重试逻辑重新生成了 requestId,导致渠道把它当成两笔独立支付。与此同时,支付单状态仍停留在 PROCESSING,前端又允许用户再次点击支付,最终形成三笔指令、两笔成功扣款。
这个案例暴露了三个典型误区:
- 误区一:超时即失败。超时只代表结果未知,应进入 UNKNOWN 并启动查询补偿,而不是直接判定失败并重试。
- 误区二:重试就换新号。重试应复用原指令号,让渠道幂等生效;只有确认原指令失败后,才用新号重新发起。
- 误区三:状态机形同虚设。PROCESSING 状态下仍允许新建支付单,等于把防重责任完全推给渠道,一旦渠道幂等失效就必然重复。
正确的处理路径是:支付指令发出后进入 PROCESSING;超时则迁移到 UNKNOWN 并触发定时查询;查询确认成功则迁移 SUCCESS,确认失败则迁移 FAILED;只有 FAILED 或 CLOSED 才允许以新支付单重新发起。整个过程以状态机为准绳,以指令号为幂等锚点,重复付款才真正可控。