Posted on ::

从一个看起来很简单的需求说起

一个订单要能支付. 听上去就是加一个 status 字段: 未支付, 支付中, 已支付, 支付失败.

真做起来会发现不够用:

  • 支付失败了可以再试, 于是一个订单对应的是多笔支付记录, 不是一笔.
  • 用户点了支付, 跳到渠道的支付页面, 又退回来再点一次. 这时候不能建第二笔.
  • 支付结果是异步回来的, 回来的时候得知道它对应哪一笔.
  • 后来账单和预约也要支付, 流程几乎一样.

这几件事合起来, 就不是一个状态字段能表达的了.

先说清楚「一笔支付」是什么

一笔支付指的是一次支付尝试, 对应一条交易记录, 它自己有:

  • 交易 ID —— 全局唯一, 后面会看到它同时是发给渠道的幂等键;
  • 状态 —— 待支付 / 支付中 / 成功 / 失败 / 取消 / 已退款;
  • 金额, 支付方式, 以及渠道侧返回的流水号.

用户第一次点支付, 建一笔. 失败了再点, 是新的一笔, 而不是把原来那笔改回「待支付」重来. 原来那笔停在失败终态, 之后不再变动.

这一点值得强调: 交易记录进了终态就不再改. 所以对账的时候能还原出"这个订单被尝试支付过几次, 每次分别是什么结果". 如果复用同一条记录反复改状态, 这些信息就没了, 出了问题只能靠日志去猜.

历史流水与「当前有效」

第二件要分清楚的事: 一个业务对象会有很多笔支付, 但任一时刻最多只有一笔是「当前有效」的.

  • 历史流水: 每次发起支付都是一条记录, 有自己的状态和终态, 不删不改;
  • 当前有效: 这个订单现在到底压在哪一笔上.

把这两件事混在一起, 就会写出"取最新一条支付记录"这样的查询. 它在大多数时候是对的, 在并发的时候不是.

所以把「当前有效」显式建成一个东西, 一个从业务对象指向当前交易的指针:

  flowchart LR
    Target["业务对象
订单 / 账单 / 预约"] Gateway["支付渠道"] Result["异步结果
回调 / 查询 / 前端跳转"] Unknown["调用栈失败
受理状态未知"] subgraph TxModule["支付模块"] Active["ActivePayment
当前有效交易指针"] Tx["Transaction
历史流水 + 当前状态"] Final["终态
成功 / 失败 / 取消 / 退款"] Active --> Tx Tx --> Final end Target --> TxModule Tx -->|"发起支付
交易 ID 作为幂等键"| Gateway Gateway --> Result Result -->|"收敛结果, 更新状态"| Tx Tx -->|"支付中: 幂等拦截"| Target Gateway -.-> Unknown Unknown -.->|"调用失败 ≠ 业务失败
保持支付中"| Tx

这个指针有三条约束:

  1. 唯一索引. 数据库层面保证一个业务对象只能有一条有效记录. 这是兜底, 不是优化 —— 应用层的判断一定会有漏网的并发路径.
  2. 创建新交易和切换指针在同一个事务里. 否则会出现"交易建好了但没人指向它", 或者反过来.
  3. 切换时带上旧的交易一起匹配, 匹配不上就不切.

第三条是关键. 两个请求同时想发起支付, 都读到"当前没有有效交易", 都去创建. 没有第三条, 后一个会盖掉前一个, 于是有两笔交易都以为自己是有效的. 把旧值作为更新条件, 就只有一个能成功 —— 是 CAS 的思路, 只不过落在数据库上.

一个前提: 渠道必须支持幂等键

接外部支付渠道之前, 有一件事要先确认: 渠道必须支持幂等键. 后面讲的所有做法都建立在这个前提上.

具体说, 发起支付的时候要带一个自己生成的唯一标识, 渠道保证同一个标识只受理一次. 第二次带着同样的标识过来, 它返回的是第一次的结果, 而不是再扣一次钱. 这个东西在各家渠道的文档里通常叫「商户订单号」或者 idempotency key.

前面说的交易 ID 正好就是这个键, 两件事是配套的:

  • 同一笔支付的重发 —— 同一个交易 ID —— 渠道认得出来, 不会重复受理;
  • 失败后重新发起 —— 新的一笔, 新的交易 ID —— 渠道当成一个新请求处理.

所以"一笔支付一个 ID"不只是为了记账. 没有它, 你没办法告诉渠道"这是同一次尝试的重发"; 而没有渠道侧的幂等保证, 你根本不敢重发.

如果渠道不支持幂等键, 后面的做法就都要打折扣: 超时之后不敢重发, 只能干等查询或回调; 查询接口还必须能按你自己的标识来查, 否则拿回一堆渠道流水, 你对不上是哪一笔; 最坏的情况是只能人工对账.

这件事在接入前确认, 比写完之后再补要容易得多.

三个「不等于」

接了外部支付渠道之后, 真正的麻烦才开始. 踩过的坑可以归纳成三句话.

一, 受理成功 ≠ 支付成功

调用渠道的支付接口返回成功, 只说明对方受理了这笔请求. 用户可能还没输密码, 3DS 验证可能还没过, 发卡行可能还要几秒钟.

所以接口返回之后, 交易的状态是「支付中」, 不是「成功」. 真正的结果通过回调, 主动查询或者前端跳转带回来.

这条比较容易接受. 下一条就不是了.

二, 调用失败 ≠ 业务失败

这是最危险的一条.

调渠道的接口, 超时了. 或者连接断了. 或者收到一个 5xx. 直觉是把这笔标成失败, 让用户重试.

这个直觉会导致重复扣款.

因为超时只说明你没收到响应, 不说明对方没收到请求. 对方很可能已经受理了, 正在处理, 过一会还会给你回调:

  sequenceDiagram
    participant B as 业务系统
    participant G as 支付渠道
    B->>G: 发起支付
    Note over G: 已受理, 开始处理
    G--xB: 超时 / 连接断开
    Note over B: 若此时判失败
用户重试 → 重复扣款 G->>B: 回调: 支付成功 Note over B: 保持「支付中」
才能正确收敛

正确的做法是: 调用栈失败的时候不下结论. 保持「支付中」, 把判断留给查询接口和回调. 宁可让用户多等一会, 也不能让他付两次.

顺带一提, 这个问题是在测试阶段发现的, 不是线上. 能在测试阶段撞上, 是因为特意去构造了渠道受理超时的场景 —— 这类路径不主动造是撞不到的, 而它一旦在生产上出现, 后果是资金差错.

三, 「支付中」必须是一道幂等挡板

有了前面两条, 「支付中」这个状态就承担了很重的职责: 只要还在支付中, 任何重复的支付请求都不能建新单, 也不能重新发起.

用户在渠道的支付页面上刷新, 后退再点一次, 前端超时自动重试 —— 这些都会打到同一个入口. 幂等在这里不是加分项, 是整套设计成立的前提.

多种支付方式, 一个生命周期

支付方式会越来越多, 而且形态差别很大:

  • 服务端直接发起: 拿到卡号或者已保存的支付凭证, 直接调渠道扣款;
  • 需要用户跳走: 生成一个页面地址, 用户跳到渠道那边完成, 再跳回来;
  • 需要用户在别处操作: 生成一个二维码或者付款码, 用户拿另一个 App 扫;
  • 异步到账: 生成一个待付款凭证, 用户去便利店或者网银付, 可能几小时甚至几天之后才到账.

它们的差异全部在发起那一步: 参数不一样; 有的调完就结束了, 有的要先生成一个地址或者二维码存进交易里; 有的发起之后还要等用户在另一个地方操作.

但发起之后的事情完全一样 —— 都进入支付中, 都等异步结果, 都要回写, 都要幂等. 所以差异收敛在入口, 共性收敛在生命周期:

发起支付   ->  按支付方式分支
收敛结果   ->  成功 / 失败 / 取消 / 退款

新增一种支付方式, 只需要在发起那一步加一个分支, 不用碰状态机.

多个业务对象, 一套支付

后来账单和预约也要支付.

最省事的做法是把订单那套复制一份. 但支付这块的复杂度上面已经讲过了 —— 复制一份意味着幂等, CAS, 异步回写这些坑要重新踩一遍, 而且三份代码会慢慢长得不一样.

更好的做法是把方向反过来: 支付模块不去了解业务, 而是定义一份契约, 让业务对象来实现它.

type PayTarget interface {
	PayCheck(ctx context.Context) error   // 现在能不能支付
	Pay(ctx context.Context) error        // 锁定为支付中
	PaySuccess(ctx context.Context) error // 支付成功后回写
	PayFailed(ctx context.Context) error  // 支付失败后回写
}

支付模块拿到一个目标 ID 和它的契约实现, 就能跑完整个流程. 业务模块只关心自己的状态和金额从哪来, 完全不需要知道渠道, 回调, 幂等这些东西:

  flowchart LR
    Tx["支付模块
统一编排"] Target["Target
订单 ID / 账单 ID / 预约 ID"] Contract["PayTarget 契约
PayCheck / Pay
PaySuccess / PayFailed"] Order["订单"] Invoice["账单"] Booking["预约"] Method["支付方式
统一发起入口"] Direct["服务端直接发起"] Redirect["跳转渠道页面"] Code["二维码 / 付款码"] Offline["待付款凭证
异步到账"] Gateway["支付渠道"] Tx --> Target Tx -->|"检查 / 锁定 / 回写"| Contract Contract -.-> Order Contract -.-> Invoice Contract -.-> Booking Tx --> Method Method --> Direct Method --> Redirect Method --> Code Method --> Offline Direct --> Gateway Redirect --> Gateway Code --> Gateway Offline --> Gateway

接一个新的业务对象, 要写的就是那四个方法.

这其实就是依赖倒置: 把"支付调用业务"定义成接口, 而不是让每个业务自己去调支付.

小结

  • 一笔支付就是一次尝试, 有自己的 ID 和终态; 重试是新的一笔, 不是把旧的改回去.
  • 接入前先确认渠道支持幂等键, 交易 ID 就是这个键. 这是下面所有做法的前提.
  • 把「历史流水」和「当前有效」分开. 后者要显式建模, 用唯一索引兜底, 用带旧值的条件更新来切换.
  • 异步支付里, 受理不等于成功, 调用失败不等于业务失败. 拿不准的时候保持支付中, 不要急着下结论.
  • 「支付中」是一道幂等挡板, 不是一个普通状态.
  • 支付方式的差异收在发起那一步, 业务对象的差异收在契约里.

这几件事单看都不复杂, 但它们是有先后的: 先有渠道侧的幂等键, 才谈得上重发; 先有唯一的当前有效交易, 才谈得上幂等; 先有幂等, 才敢在超时的时候不下结论.

Table of Contents