在资损风险中,接口交互的规约是非常需要被重视的一环,今天我们就通过一个查询接口来聊一聊在分布式场景下,机构间对交易结果的“一致性”的不明确而可能引发的资损风险。
1. 背景:分布式下的重试、幂等与查询
在分布式的场景下,由于网络的不可靠性,很容易出现请求超时或者服务不可用等问题,重试是非常常见的补偿手段,所以我们在设计接口的时候,一定会重点分析对外服务接口的幂等设计,保证同一笔业务订单不会被重复支付造成资损。尤其是在支付系统中,支付接口的幂等键是我们设计的重中之重。
除了重试请求,另外一种方式就是提供查询接口,用于查询支付接口的支付结果。通常来说我们认为查询接口不涉及资金的操作,风险较低,但其实把一个查询动作放到一整套交互链路中,也会有可能造成资损,今天我们就来唠一唠设计支付查询接口的讲究。
1. 案例:支付超时场景下的查询
1.1 正常的支付查询时序
假设一家银行为支付机构提供一个支付接口。在设计支付超时如何处理的方案时,支付机构要求银行提供查询接口,支付机构会对原请求做一次查询,确保拿到可重试的返回码时,机构才会发起重试。
但很显然,在支付超时的情况下,机构是没有拿到银行生成的银行支付单号的,所以对于银行来说,这个查询接口需要支持用机构的外部支付单号查询订单。但其实这样的设计暗藏着风险。
1.2 分布式场景下请求的时序错乱
以下图的时序为例,机构发起支付请求,但由于请求超时,机构会再发起一次查询。很有可能机构的查询请求会比支付请求先到银行,此时银行只能返回订单不存在的查询结果。但是实际上,此刻的订单不存在只能意味着支付结果未知,因为很可能随后的支付请求到了银行之后,银行会把这笔支付请求做成功(当然也有可能支付失败,所以说是未知结果)。
在这种情况下,外部机构如何处理“订单不存在”这个结果尤为关键,如果处理不当很有可能造成资损。
1.3 换单重试的资损风险
一旦外部机构,将“订单不存在”这个查询结果处理成支付失败,紧接着发起换单重试,即换幂等单号重新发起一次新的支付请求,很有可能造成两个支付请求都支付成功的结果。
如下图所示,outTradeNo 和 newOutTradeNo 两个外部支付单号代表的两笔支付请求都处理成功,也就是同一个业务订单,发生了两笔支付,造成资损。
2. 分析:先本地落单才能返回失败
我们再完整地分析一遍上述时序问题的资损风险:
如果银行没有将 outTradeNo 这笔支付单保存到数据库中,是无法确认真正收到这笔支付单之后的处理结果:成功、失败都有可能,所以在收到后发先至的查询时,只能返回订单不存在。但此时其实我们交出了资损防控的主动权,我们无法控制外部机构是否会做换单重试——newOutTradeNo,换幂等单号就意味着突破幂等,一旦 outTradeNo 和 newOutTradeNo都支付成功,就是资损。
所以,我们只有保证在返回查询结果之前,我们本地先持久化了对应的支付单信息,才能保证我们能将主动权掌握在自己手中。我们接下来分析三种应对方案。
2.1 解法1:以银行单号为查询条件
提供以银行单号为查询条件的查询接口,暗含的前提就是银行已经接到支付请求,且在本地持久化了对应的支付单,才能给外部机构返回了银行单号。这种查询接口多用于同步受理,异步处理的交易场景。
但这个方案无法解决在本文开头提到了支付超时问题,因为即便银行先收到了支付请求,外部机构收到的就是请求超时的结果,无法拿到银行单号。所以需要另寻他法。
2.2 解法2:在查询接口中落受理幂等表
在另外一个项目中,我们对外提供了以外部请求号为查询条件的查询接口,但我们自己的系统里新增了一张前置幂等表。
如下面的时序图所示,在查询请求后发先至的场景下,如果本地查询不到已受理的支付单,系统会先在受理幂等表中落一条状态为失败的受理单据,再返回订单不存在的结果。随后,支付请求姗姗来迟,再尝试落受理幂等表时,就会写入失败,系统就可以将这笔支付请求处理为失败——保证查询结果和支付结果的一致性!
但是,Everything has a price to pay,任何事情都有代价。
这个方案采取了最悲观地方式来处理本地无支付单据的查询请求,最为安全,但是也带来了一定的成本,也就是外部机构必须有换单重试支付请求的能力。因为一旦发生查询请求后发先至,当前的这个outTradeNo就报废了,为了使业务成功,必须要换一个新的外部请求单号。
3.3 解法3:一锤定音的冲正
3.3.1 银行与机构的冲正交互
在去年的项目中,我们最后选用的方案是,银行为机构提供了一个冲正接口,或者说是支付撤销接口
这个支付接口所在的业务是直接面向C端客户的,对时效性要求很高,一旦出现支付超时,机构侧就会将这笔支付处理为失败,并反馈给客户。同时,机构异步去处理这笔未知结果的支付的撤销动作:
在上述的交互图中:
- 第一阶段,机构调用银行超时,便将支付单处理成失败,返回给客户。
- 第二阶段,此时机构并不知道银行侧这笔交易的处理结果,此处机构选择的方案是重试原支付请求,直到拿到支付结果。
- 第三阶段,如果在银行侧已支付成功,则调用支付冲正接口将原正向的支付撤销。如果原支付单支付失败了,机构侧则不会再发起冲正请求
此处的交互,机构侧承担了判断原支付单是否成功的职责,所以是机构和银行共同完成了这个冲正动作。而实际上,更加标准的“冲正”,原单是否支付成功的判断是需要服务的提供方来完成的。
在十多年前的线下收单时代,我们去商场买东西,最常用的就是刷银行卡,用于刷卡的POS机中就包含这种真正意义上的“冲正”。
3.3.2 线下POS收单的冲正
POS机在和终端交互过程中,也会遇到响应超时。线下收单与上述的CASE一样,同样对时效性有要求,所以POS机可以直接向终端发起冲正请求,终端在判断原交易成功之后,会发起回滚。
但在极端场景下可能会发生回滚失败的可能:例如银行卡在扣款之后马上被冻结了,逆向资金无法入账。此时客户已经收到银行卡的扣款短信,而钱也未结算给商户,处在待清算的状态中,需要走特殊处理流程。所以冲正的交互在特殊场景下,也会存在资损风险——客户的钱没了,但业务没成功。
上述的流程图中,在“终端的部分”其实有复杂的多个参与方的交互:收单机构、收单行、发卡行、银联,由于不是本文的重点,先不展开细说。总之,我们看到,在银联主导的POS收单交互中,针对交易超时所设计的冲正操作在极端场景下仍然有资损风险。
进入网联时代之后,针对超时,网联在其制定的报文规范中推出了“终态报文”,而且在部分场景中,“终态”意味着需要强制参与方将交易做成功
3.3.2 网联与成功终态通知报文
我们用网联付款请求作为例子。网联付款请求的语义是:支付机构向网联发起付款申请,网联受理后向付款行转发付款申请,由付款行完成付款处理。付款行处理成功,平台向收款行发起付款申请,由收款行完成收款处理。
我们可以看见,其中的关键有两步
- 网联向付款行发起扣款请求,把钱扣成功
- 网联再向收款行发起收款请求,把钱给收款行
在下图的交互中,在向付款行扣钱成功后,如果收款行与网联的交互超时了,网联甚至可以直接给支付机构返回交易成功,然后给收款行一个成功终态通知:“你超时太久了,我等不到你的回复了,这笔钱已经从付款行扣过来了,我要把交易结果裁决成成功了。毕竟最难的扣款都成功了,你把钱收下来应该不是难事。”
我们试想一下,如果网联不设计“成功终态”,转而和银联一样,那么替代方案就是网联分别向付款行和收款行发送两个冲正动作,这个替代方案有两个缺点:
- 成本很高:很可能此时收款行已经收款成功,那么此时收付双方都要做回滚。
- 对付款行的冲正而言,仍然要面对可能回滚失败的资损风险。
可见,历史还是在不断的演进中进步的。
3.3.4 XTS二阶段的回滚
聊完银联和网联,也就是机构间的冲正交互,最后提一嘴银行行内的交互吧。其实我们可以看见,冲正流程的交互还是比较复杂的,所以在银行内部,大多数一致性的交互都交给XXX来完成了。与前面的查询后发先至类似,XXX的二阶段回滚,也存在后发先至的可能,应对方案仍然逃不开“分布式场景下的最终一致性一定要先落单”,所以在我们的防悬挂方案中,会用一条回滚记录去拦掉先发后置的一阶段请求。
3. 总结:分布式场景下的最终一致性
回顾全文,我们讨论了在分布式场景下,非常重要的几种达成最终一致性的手段:重试、查询、冲正、终态通知,以及这几种手段都必须要实现的幂等,而要做到幂等的前提一定是在自己的系统中对单据做了持久化,使幂等有据可依。退一步讲,只要落了单,我们还有兜底的对账来核对系统间的单据状态,也能保证最终一致性。
总结一下,这篇三千五百字的文章契税可以浓缩为9个字:“不落单不要返回失败!” 能让大家记住这句话也就达到了这篇文章的目的了。
参考资料:
- 万字长文,详解线上线下收单业务
- https://zhuanlan.zhihu.com/p/65403703
- 网络支付清算平台报文交换技术规范
- 中国银联POS终端规范