分布式事务使用方式探索
type
status
date
slug
summary
tags
category
icon
password
理论基础
分布式事务
随着业务的发展,系统复杂度的增加,越来越多的服务从单体架构向分布式架构转变,数据库分库分表也是非常常见的方案,因此分布式系统几乎无处不在。提到分布式系统,我们就不得不考虑数据一致性问题,分布式事务就是为了保证这种情况下不同数据库或者服务的数据一致性。
自有支付系统因为是和钱打交道的系统,所以对分布式一致性的保证就更加重要,目前自有支付有收银台、交易、支付、渠道、账户和营销等系统,如果系统间一致性问题处理不当,很容易就会造成资金损失。为此我们调研了目前分布式事务的实现方式,以及业界一些大厂支付部门的解决方案,希望能够将这些设计思想应用到我们的系统当中。
分布式事务有很多实现方式,比如2PC(两阶段提交)、XA模式、Saga模式和AT模式等。
- 两阶段提交顾名思义就是将事务分成两个阶段,prepare和commit/rollback,需要一个协调各个本地事务的事务管理器,其缺点在于执行过程中参与节点都处在阻塞状态,并且事务管理器也存在单点问题。
- XA(XA Transaction)是标准化组织X/Open推出的分布式事务规范,是数据库层面的分布式事务机制,其原理也是两阶段提交,所以也存在两阶段提交的一些缺点。另外,业务接入时还需要使用支持XA模式的数据库,才能实现跨库的分布式事务。
- Saga模式把分布式事务看作一组本地分支事务构成的事务链,事务链中的每个事务,都包含正向事务操作和反向补偿操作。Saga按顺序执行事务链中的分支事务,如果某个分支事务失败,则按反方向执行事务补偿操作。这种模式的问题在于补偿操作对业务是可见的,另外缺少事务隔离的机制。
- AT模式是蚂蚁开源的Seata解决方案中的一种模式,Seata会自动对SQL执行进行拦截和代理,生成二阶段提交和回滚操作。AT模式相比TCC模式,缺点在于不关注数据库操作以外的事务行为。
网上介绍这些实现的文章有很多,本文就不对这些实现方式做过多介绍了,我们重点关注其中一种——即TCC模式。
TCC
TCC是Try,Confirm,Cancel三个词的缩写,它要求参与分布式事务的每个分支事务实现三个操作:
- Try:进行业务检查和资源预留
- Confirm:确认提交,是真正执行的业务逻辑,使用Try阶段预留的业务资源。只有Try阶段所有业务分支事务都成功后才能执行Confirm。
- Cancel:回滚操作,当Try阶段有分支事务业务检查或资源预留失败时,就需要进行回滚,释放预留的资源。
TCC的核心要义在于两阶段,第一阶段保证了操作的可执行,第二阶段对可执行的操作实际执行,或者对不可提交的操作回滚。
TCC模式的缺点在于它对业务的侵入比较大,接入TCC的业务操作都需要被分成两阶段完成,即需要实现Try,Confirm,Cancel三个接口。但有舍必有得,不同于大多数关注数据库层面的解决方案,它的关注点在业务层面。在业务拆分时,能够解决微服务之间调用的一致性问题。适用于数据库热点更新严重,以及事务行为包含数据库以外操作的业务。

TCC模式的一个基础使用场景如上图所示,首先需要解释一下图中几个名词的概念:
- TC(Transaction Coordinator),是全局事务的协调者,负责处理事务的注册、提交和回滚
- TM(Transaction Manager),是事务的发起者,负责告诉TC事务的开始、提交和回滚
- RM(Resource Manager),资源管理器,每一个 RM 都会作为一个分支事务注册在 TC
- Resource,是分布式事务的参与者,每个Resource都需要实现Try、Commit和Cancel三个接口
- Reference:分布式事务切面,用来注册分布式事务和报告分布式事务状态
发起方请求TC开启全局事务,然后为参与方A注册一个分支事务,并调用参与方A的Try接口。参与方A做业务检查,预留资源,然后为参与方B注册分支事务,并调用参与方B的Try接口,B做完业务检查和资源预留后并返回后,发起方就可以提交全局事务。TC会根据全局事务中分支事务的结果,决定是提交还是回滚参与的分支事务,调用参与方的Commit或Cancel接口。这里的调用是需要保证成功的,如果失败了就需要TC重试,或者报警请求人工操作。
解决方案调研
支付宝
支付宝内部使用的分布式事务解决方案是XTS(eXtended Transaction Service),Seata是其对外开源的版本。为用户提供了 AT、TCC、SAGA 和 XA 事务模式。上文的设计思路参考了Seata的TCC模式实现方式。关于Seata TCC模式可以参考这篇文章分布式事务 Seata TCC 模式深度解析-InfoQ。
Seata的TCC模式最早是在蚂蚁金服落地使用的,也是应用在交易、支付和账户等系统上,所以其使用场景和自有支付系统非常契合,遗憾的是Seata是使用Java实现的,虽然github上有golang版本的实现,但是可用性还需要进一步调研。
以一个退款余额不足的场景来说明支付宝分布式事务的使用方式。支付宝的交易支付和账务等系统会处在一个全局事务当中,维护退款单号的平台是这个全局事务的发起者,当全局事务因为余额不足失败回滚之后,平台也不会落下这条订单,返回给业务方的是余额不足的错误。所以当我们用退款的单号去查询时,会得到订单不存在的结果。

微信
微信内部有微信支付和财付通涉及到交易系统。据了解财付通绝大多数场景下都是使用单机事务,通过对账机制发现不一致,然后补发MQ消息达到最终一致。微信支付在资金层则用了事务性的高可用KV存储系统和事务性消息组件保障系统间的一致性。
总的来说,微信在保证分布式系统一致性方面用了支付宝不同的方式,微信支付的思路是尽量将分布式事务转化为单机事务,或者是根据核心程度,将分布式事务分为主事务和次事务,次事务通过MQ等机制异步补偿完成,达到最终一致。
同样是退款余额不足这种情况,微信也不会落单,但是和支付宝的实现方式就完全不同了。据我们了解,可能是先落订单缓存,发现余额不足则不生成订单;也可能是先落订单,当发现余额不足之后再删除这笔订单,即使删除失败了,订单也只是在处理中状态,可以通过补偿机制推进到终态。
虽然实现方式不同,但是从业务视角来看这两者的表现是一样的——我们查询这笔订单时都会得到订单不存在的返回码,而且可以用原单号重试。
自有支付现状
自有支付目前并没有使用分布式事务组件,目前对于一致性的保证主要是通过重试或者回滚来实现的,部分实现类似Saga模式。还是用退款余额不足作为例子,自有支付目前的做法是会先落单,发现余额不足后订单就会卡在Processing的状态,这期间支付系统会自动向账务系统重试,业务方也可以用原退款单号进行重试。当商户余额充足之后,这笔订单就能推进到成功状态,如果订单卡住太久,就需要人工关单了。
这种实现方式相对比较简单,但也存在问题。如果业务方进行了关单,但是自有支付内部这笔订单还没有被关闭,后面自动重试成功了就会造成资损,实际上这种情况也出现过几次,退款余额不足也只是当前系统一致性问题里的一种,所以我们希望用更安全优雅的方式来实现系统的分布式一致性,这篇文章就希望借鉴TCC模式,设计和改造当前自有支付链路的模型。
设计思路
上文理论基础一节的图中展示的只是使用TCC保证分布式一致性的一个基本模式,在实际业务中,由于业务逻辑的复杂性,往往要比上面的模型复杂的多。基于上述TCC模式的思想,我们开始思考在自有三方支付的链路下如何使用TCC保证一致性。具体到业务场景中,关键在于要怎么把业务操作拆分成Try,Confirm和Cancel三个接口。
收单场景

目前,银行卡收单是由收银台发起交易,交易系统只落单并返回单号。然后收银台再请求支付系统发起支付,支付按照以下顺序请求营销、渠道和账户系统。
- 如果这笔银行卡收单使用了优惠券(比如使用招行卡立减活动),就先请求营销预锁优惠券
- 调用渠道系统做通道收款,把用户的钱从银行收进来
- 调用账户系统记账,账户会记动账流水明细,并变动相关账户的余额
- 调用营销系统实际使用优惠券 当以上步骤都成功之后,支付会返回成功的结果给收银台,并回调交易通知这笔收单的结果,交易就可以把这笔订单推进到终态。
对支付系统来说,它的三个下游系统需要保证状态的一致,但是营销用Redis管理优惠券,渠道需要使用RPC调用外部系统,账户使用Mysql记账,在这种情况下,XA以及AT模式这些数据库层面的解决方案就不太适用了,需要一种关注点在业务层面的一致性解决方案。
当前方案对一致性的保证是通过重试和回滚(或者叫冲正)方式实现的。重试是指如果有下游给支付返回了一个未明的结果,支付就需要重试,把这笔订单推进到终态。而如果有一个下游给支付返回了失败结果,支付就需要调用之前成功了的系统,把使用的资源回滚(或者叫冲正)。
改造
如果使用TCC模式,首先需要考虑的是如何将每个系统的操作拆分成Try, Confirm和Cancel。

参照上文TCC模式的基础使用场景,可以将收单场景改造成如上图的流程,由收银台发起全局事务,交易、支付、营销、渠道和账户系统作为分支参与到全局事务中。下面就分系统来详细说明一下各个如何拆分接口。
收银台
收银台负责发起全局事务,因为在收单场景下,发起交易后是由收银台去调用支付,此时如果在交易发起事务则无法把支付纳入到全局事务当中。
交易
- Try:落一条事务控制记录,以下简称Tcc Order,落控制记录是为了解决空回滚、幂等和事务悬挂的问题,简单来说,就是在操作前都根据全局事务ID和分支事务ID查询事务控制记录,并检查记录的状态,根据查询结果和当前阶段做异常处理。具体如何解决可以参考 分布式事务 Seata TCC 模式深度解析 这篇文章的TCC 异常控制一节
- Confirm:生成成功的交易单,Tcc Order状态修改为success(已提交)
- Cancel:Tcc Order状态修改为fail(已回滚)
因为生成Tcc Order和将其状态置为已提交或已回滚的操作是通用的,下文中如果不提到默认就是已经有这一系列操作。
支付
- Try:为营销、渠道和账户注册分支事务,并调用它们的Try接口
- Confirm:生成状态为成功的支付单
- Cancel:将TCC Order状态修改为失败
营销
在现状一节其实看到,营销系统已经在使用两阶段的思想保证一致性,也就是将优惠券的使用分为锁定和实际消耗。所以改造就比较简单:
- Try:锁定优惠券
- Confirm:实际使用优惠券,生成相关订单
- Cancel:释放优惠券
渠道
- Try:调用银行进行通道收款,生成收款订单
- Confirm:除修改TCC Order状态外无操作
- Cancel:因为通道收款的回滚比较复杂,涉及到退款,所以我们希望尽可能保证成功,首先要保证其他分支事务Try接口的成功率高,如果真的需要回滚,一种方式是发起一笔通道退款,但是通道退款也有可能失败。另一种是报警转为人工处理,如果出现概率很低的话,人工处理应该是可以接受的。
之所以在Try阶段就调用银行进行通道收款,而非在Confirm阶段,是为了避免资损的风险。如果在Confirm阶段才通道收款,营销的优惠券就会实际扣减,商户也收到了这一笔待结算的资金。可是银行却是不能保证成功的,这时就发生了资损。
账户
银行卡收单场景下,用户的银行卡扣了一笔钱,这笔钱在账户系统中实际上需要记到一个商户的待结算户中,等待后续的结算,分账等操作。但在Try阶段,这笔钱不能立即加到待结算户里,因为此时这笔钱还不属于商户,不能被结算。所以,除了余额amount字段外,账户需要新增内部未达余额字段(unreach_amount)。以一笔100元的收单为例,其中用户实际支付97元,使用3元的优惠券:
- Try:对应商户待结算户的UnreachAmount += 97,营销优惠券户 SystemAmount += 3
- Confirm:生成动账流水&明细,待结算户UnreachAmount -= 97,Amount += 97,优惠券户SystemAmount -= 3,Amount-=3
- Cancel:待结算户UnreachAmount -= 97
转账场景
现状
和银行卡收单场景不同,转账场景并不存在收银台直接调支付的情况,所以可以用交易系统作为分布式事务的发起者。并且也不涉及到营销和渠道系统,只是两个内部账户之间的转账。这两个内部账户可能在不同的数据库中,所以转账场景下的分布式事务主要是需要保证账户余额的一致性。
目前账户系统的做法是将一次转账分为扣钱和加钱两部分,先做扣钱操作,再做加钱操作。如果扣钱时发现余额不足,就返回失败。而如果扣钱成功后,做加钱操作时出现问题,就会向上游返回一个第一步成功的错误码,支付系统不会将这个错误码视为失败,而是会继续重试直到成功或者人工进行处理。
改造

转账场景下交易支付系统的三个接口和收单场景下基本相同,所以下面重点关注账务系统的改造。在转账做资源预留时,付款方会有一笔钱处在冻结状态不能够使用,所以账户需要新增内部冻结金额字段(system_amount)。下面以付款方向收款方转账30元为例:
- Try:付款方SystemAmount += 30,收款方UnreachAmount += 30
- Confirm:生成两条动账流水&明细,付款方SystemAmount -= 30,Amount -= 30,收款方UnreachAmount -= 30,Amount += 30
- Cancel:付款方SystemAmount -= 30,收款方UnreachAmount -= 30
账务系统实际上是通过system_amount和unreach_amount实现了冻结资源和记录状态的功能。在分布式事务进行时,如果出款方账户上发生了其他的出款交易(假设金额为payment_amount),那就需要满足以下条件才能出款:
𝐴𝑚𝑜𝑢𝑛𝑡−𝑆𝑦𝑠𝑡𝑒𝑚𝐴𝑚𝑜𝑢𝑛𝑡−𝑃𝑎𝑦𝑚𝑒𝑛𝑡𝐴𝑚𝑜𝑢𝑛𝑡>=0
退款场景(银行卡收单退款)
现状

目前收单退款是由交易发起退款,调用支付,再由支付调用账户、渠道、营销等系统。退款分为以下两步:
- 退款下账,调用账务系统,从商户账户中扣除用户支付的金额
- 退款完成,按以下顺序调用:
- 调用账务,退款到优惠券账户
- 调用营销,退优惠券
- 调用渠道,进行通道退款
之所以最后做失败率最高的通道退款,有两个原因。一是绝大多数情况下,退款是不允许失败的,做重试后如果还是失败,就需要转为人工处理,即做“保退(保证退款)”操作;二是为了保证资金安全,把出款动作放到最后做是更合理的,否则容易造成资损。
改造

改造后退款的流程和收单场景整体类似,不同的是全局事务可以由交易系统发起。
在改造前,为了保证一致性,退款实际上分成了下账和完成两步。其实这其中就包含了一定的预留资源的思想——要保证商户有这么多钱可以用于退款。所以在改造为TCC模式时也可以参考这种思路。
下面以做一笔100元的收单退款为例,资金组成为用户银行卡支付97元,使用了3元的优惠券,具体说明各系统需要怎样拆分接口。
交易
发起事务,根据结果提交或回滚事务并落单。
支付
- Try:为账务、营销和渠道注册分支事务,调用各系统的Try接口
- Confirm:生成成功的支付单
- Cancel:除修改Tcc Order状态为失败外无操作
账户
- Try:商户待结算户 SystemAmount += 100,营销户 UnreachAmount += 3
- Confirm:生成动账流水&明细,商户待结算户SystemAmount -= 100,Amount -= 100,营销户 Amount += 3, UnreachAmount -= 3
- Cancel:商户待结算户 SystemAmount -= 100, 营销户 UnreachAmount -= 3
营销
- Try:新增一张冻结的3元优惠券
- Confirm:优惠券解冻,并生成业务订单
- Cancel:删除冻结的优惠券
渠道
- Try:除落Tcc Order之外无其他操作
- Confirm:通道退款并落单,需要保证成功(重试&人工保退)
- Cancel:除修改Tcc Order状态为失败外无操作
需要注意的是,渠道是在Confirm阶段做实际的通道退款,这同样是出于资金安全的考虑,我们需要保证其他操作都成功之后再将资金退到用户的银行卡。和收单时一样,渠道同样会受到银行系统无法保证退款成功的限制。所以在Confirm阶段,退款需要有重试机制,如果重试仍然无法解决问题, 就需要人工操作,付款退到用户其他银行卡,甚至是使用线下打款的方式来完成退款。
付款场景
改造
付款场景和上面的退款场景类似,由交易发起全局事务,支付调用账户和渠道。以一笔100元的商户代付为例:
交易
发起事务,根据结果提交或回滚事务并落单。
支付
- Try:为账务和渠道系统注册分支事务,并调用Try接口
- Confirm:生成成功的支付单
- Cancel:除修改Tcc Order状态为失败外无操作
账户
- Try:商户现金户 SystemAmount += 100
- Confirm:生成动账流水&明细,商户现金户SystemAmount -=100, Amount -= 100
- Cancel:商户现金户 SystemAmount -= 100
这里账户系统在Confirm时就扣减商户余额实际是存在风险的,因为渠道系统Confirm阶段的出款是有可能不成功的。一种解决方式是要尽量保证渠道付款的成功(重试或人工处理等操作),否则进行回退操作。另一种解决方式就是在渠道出款成功后才实际扣减账户的余额,可以参考下文关于外部系统中支付宝的解决方式。
渠道
- Try:落Tcc Order(状态为init)
- Confirm:通道付款并落单
- Cancel:除修改Tcc Order状态为失败外无操作
关于外部系统
大家可能已经注意到了,在上述四个场景中,只要涉及到渠道系统,设计就会相对复杂一些。除了上面说到的资金安全方面的原因,这些设计的根本原因是渠道需要和外部系统交互,比如银行,小贷公司等。这些外部系统接入TCC模式的难度很大,也就是说最终一致性无法由TC保证。
当这些外部系统返回失败时,针对所处阶段和场景的不同,主要有以下三种处理方式:
- Try阶段失败:
Try阶段的失败可以直接进入Cancel阶段做资源释放操作,这是符合TCC原理的。以收单场景为例,渠道需要在Try阶段就做通道收款,因为如果账户系统的Confirm阶段成功修改了余额,通道收款却失败了,就有可能导致资损。这样如果其他系统Try失败,渠道进入Cancel阶段,为了回滚操作,就要将钱退回到用户的银行卡,如果成功,这次TCC事务就完成了回滚。但如果退款时银行也返回失败,整个事务就会卡在中间状态。这时可能就需要使用第二种处理方式。
- Cancel阶段失败:
Cancel阶段的失败无法像上面一样进入Cancel阶段做释放资源操作。这时候一种解决方式就是重试,当多次重试无法解决事,可能就需要人工处理。比如上面说到的,Cancel阶段退回资金到用户银行卡失败,人工处理的解决方式可以是将资金以付款的形式退至用户的另一张银行卡。
- Confirm阶段失败:
Confirm阶段的失败要更加特殊一点,因为其他分支事务的Confirm可能已经成功了,无法再逆向操作。所以除了解决外部系统的问题之外,还需要回滚其他分支事务的操作。这里就需要做一次逆向操作。以一次付款为例,Confirm阶段向用户银行卡打款失败了,但是其他系统的余额,单据等信息已经提交更改了,这时候就需要通过回退操作恢复余额等信息。
另一种做法是增加过渡户,来容忍系统的不一致状态。以支付宝的做法为例,出款时账户系统会先将资金从余额转移到银行卡过渡户中实现资源的冻结。渠道系统出款成功后才会通过事务型消息的方式驱动账户系统记银行卡过渡户转到待清算户。如果渠道系统出款失败了,会推动支付系统做回退操作,把资金从银行卡过渡户转回余额。
需要注意的是这里的回退操作并不包含在本次全局事务中,而是一次独立的反向事务,因为即使资金还留在银行卡过渡户中,但是对于系统来说这是一种可以接受的终态了,后续只需要通过回退操作将资金退回到余额当中。
展望
本文所介绍的内容只是基于当前自有支付的现状和TCC原理的构想和设计,距离真正落地还有一定距离。实现TCC模式能解决我们系统中很多一致性方面的问题,但TCC也不是万能的银弹,虽然它功能强大,能根据业务进行调整和定制,但是接入起来比较复杂,对业务代码侵入大。为了解决这一问题,蚂蚁金服对那些对性能要求并不高,业务体量并不大的中小业务推出了FMT(Framework-Managerment-Transaction)模式,这种模式下,大部分工作由框架自动完成,业务只需要关注一阶段实现的业务SQL,二阶段的自动提交回滚由框架自动完成。
另外,为了提高性能,蚂蚁金服还会在大促高峰期对二阶段做异步化处理,因为一阶段的Try已经能决定整个事务的状态,二阶段可以等到高峰期过去之后再做。虽然会对结果造成延迟,但是大大提高了吞吐量。随着自有支付的业务越来越复杂,流量越来越高,这些改造也正是我们需要努力的方向。
Loading...