跳至正文
Starone

金融科技

设计具备韧性的支付系统

衡量一个支付系统,要看请求超时、没人知道钱到底有没有划出去的时候它如何表现。本指南涵盖幂等、账本、状态机、未知结果、webhook、对账,以及如何缩小卡数据的涉及范围。

· 阅读约 5 分钟 · Starone 工程团队

一笔支付的成功路径很短:校验,调用支付服务商,记录结果。几乎所有的工程工作都在别处,在那些网络调用超时、回调重复到达或乱序到达、进程在两次写入之间挂掉,或者服务商的记录与你的记录对不上的情形里。

我们的立场是,支付系统的设计应当基于这样一个假设:每一次外部调用都可能以三种状态之一结束,即成功、失败或未知。只对前两种建模的系统,迟早会对某人重复扣款,或者弄丢资金的去向。下面这些实践,就是为了让第三种状态变得安全。

在每一个边界上使用幂等键

重试不可避免,所以每一个涉及资金划转的操作都必须可以安全地重复执行。客户端为每个逻辑操作生成一个幂等键,并在每次尝试时一并发送。服务端把这个键存放在唯一约束之下,同时保存请求的指纹,并在处理完成后保存响应。带相同键的重复请求直接返回已保存的响应,不再执行一次。

它是否奏效取决于细节。幂等键必须在副作用发生之前插入,并且与支付记录处于同一个事务,这样两次并发尝试就不可能同时继续下去。如果一个请求复用了某个键,payload 却不同,应当直接拒绝,而不是悄悄把它当作原始请求。如果重复请求到达时第一次尝试仍在处理中,应当返回冲突或让它等待,而不是启动第二次执行。随后,同一个键或由它派生出的键还要向下游传给服务商,因为止步于自家 API 的幂等,防不住在收单机构那一侧的重复扣款。

以复式记账账本作为事实来源

一个可变的余额列只能回答一个问题,即当前余额,同时却毁掉了回答其他所有问题所需的信息。我们用仅追加的复式记账账本来记录资金变动:每笔交易由若干分录组成,借方与贷方之和为零;分录从不更新或删除;出错时用一笔冲正交易来纠正,历史保持完整。

零和不变量的校验成本很低,却能拦住一大类缺陷,因为没有一笔不平衡的交易,资金就不可能凭空出现或消失。金额是以该币种最小单位表示的整数,币种与之一同存储,绝不使用浮点数。余额由分录推导,可以缓存,但缓存的数字必须始终可以重新算出。取舍在于存储更多、读查询更复杂,而为了一份能够自我解释的记录,我们认为这个代价是公道的。

显式的状态机

一笔支付会经历已创建、已授权、已请款、已结算、已失败和已退款等状态。把它们编码为显式的状态机,配上一张固定的允许转换表,就能把一大类 bug 变成被拒绝的操作。对一笔从未授权过的支付发起请款,或者第二次退款超出了已请款金额,都会在转换检查处失败。

  • 在数据库中通过对当前状态做条件更新来强制执行状态转换,这样两个并发事件不可能同时成功。
  • 为等待明确答复的操作设置“处理中”或“未知”状态;这是一种正常状态,而不是错误。
  • 把每一次状态转换记录为不可变的事件,并附上原因、操作者和时间戳。
  • 让终态真正成为终态:结算之后的变更是退款或拒付这样的新操作,而不是修改原记录。

结果未知的超时

当对服务商的调用超时时,请求可能根本没有到达,可能已被拒绝,也可能已经成功只是响应丢了。把超时当作失败并让客户再试一次,重复扣款就是这么来的。把它当作成功,则会在没有收到款项的情况下把货发出去。

正确的处理方式是把这笔支付置为未知状态,然后设法查明结果。系统使用自己的参考号查询服务商的状态接口,或者用同一个幂等键重试原始请求,并配合退避,直到得到明确的答复。如果在规定的时间窗口内仍然没有答复,就把这个案例升级到运营处理队列,并附上足够的上下文,交由人工处理。

Webhook 是线索,不是事实

服务商通过 webhook 通知异步结果,而 webhook 的投递至少一次,不保证顺序,偶尔还会完全收不到。处理逻辑必须把这三种情况都考虑进去。

  • 基于原始请求体验证签名,并拒绝时间戳过旧的请求,以防伪造和重放。
  • 在应用任何变更之前,先按服务商的事件标识去重。
  • 先持久化事件并尽快确认,再异步处理,避免处理过慢引发大量重新投递。
  • 通过状态机应用事件,这样一个迟到的“已授权”事件就无法覆盖一笔已请款的支付。
  • 为长期处于待处理状态的支付运行轮询任务,因为漏掉一个 webhook 绝不能让一笔支付悬而未决。

把对账作为一等流程

无论在线路径做得多么谨慎,内部账本与服务商的记录有时还是会不一致。对账就是系统性地比对两者:每一笔内部交易都与服务商的结算报告逐一匹配,结算总额再与银行对账单匹配。任何一方出现的未匹配项,都会成为一条被跟踪的差异,有负责人,也有账龄。

这需要与服务商共用的稳定参考号,需要容忍跨截止时间造成的时间差,需要对手续费和货币兑换做显式处理,还需要在差异超出预期水平时告警。一个无法自动对账的设计是不完整的,因为对账这项控制手段,负责发现其他所有机制都漏掉的失败。

缩小 PCI 范围,以及这些建议的适用边界

保护卡数据最安全的办法就是不碰它。托管支付字段或跳转到服务商页面,可以让卡号不出现在你的浏览器代码、服务器、日志和备份里,服务商则返回一个代表该卡的令牌。这会大幅缩小整个系统中适用 PCI DSS 要求的部分。在必须直接处理卡数据的场合,这项功能会被隔离到一个规模很小、单独管控、以支持这些要求为目标而设计的服务中,其他任何组件都不允许看到这些数据。

这些实践都有成本,投入要与场景相称。一家只通过单一服务商的托管收银台收款的企业,完全可以依赖该服务商的记录加上每日对账,而不必自建账本。当系统持有余额、在多方之间分账,或者使用多家服务商时,完整的复式记账账本、状态机和对账引擎才变得必要。唯一不能因规模小而省掉的原则是幂等:任何发起支付的代码,从第一个版本起就需要它。

正在做类似的事情

我们很乐意交流心得。欢迎与撰写本文的工程师聊聊。