分布式事务解决方案

  由于微服务对应的库表拆分,导致原来在一个库中的数据可能被分散到多个数据库中,一个业务流程可能涉及多个数据库,如何保证在多个库中的数据的事务问题,是一个常见的微服务问题,一般会有强一致性和最终一致性两种解决方案,强一致性表示必须同时成功或同时失败,最终一致性表示只要最终的状态是同时成功或者同时失败即可,不需要保证在任何同一时间内的一致性。

  目前对于分布式事务有七种常见的解决方案:XA、TCC、本地消息表、可靠事务消息、尽最大努力通知、saga、AT,其中XA、TCC、AT是强一致性的落地方案,而本地消息表、可靠事务消息、尽最大努力通知、saga是最终一致性的落地方案,在强一致的解决方案中,又可以分为两阶段提交和三阶段提交,其中TCC就是典型的三阶段提交。

一、XA 与两阶段提交、三阶段提交

  XA主要包含AP、RM、TM三个成员,其中AP(Application)表示应用程序;RM(Resource Manager)表示资源管理器,例如数据库;TM(Transaction Manager)表示事务管理器

(一)两阶段提交

  前期:配置RM,把多个RM注册到TM;

  使用分布式事务时:AP向TM发起一个全局事务,生成一个全局的事务ID(XID),TM会通知所有的RM;AP通过从TM中获取的RM连接操作RM完成数据操作。这时,AP在每次操作RM时都会将XID传递给RM;AP结束全局事务,TM通知各个RM结束全局事务。根据AP通知TM的结果,执行提交或者回滚操作。具体流程图如下所示:

        

   两段式优缺点:

    优点:两阶段提交将一个事务的处理分为投票和处理两个阶段,他的优点在于充分的考虑了分布式系统的不可靠因素,并使用了非常简单的方式(两阶段提交)就把由于系统不稳定而导致事务提交失败的概率降到了最小。

    缺点:当然其缺点也非常的明显,其存在同步阻塞、过于保守、事务协调者的单点故障、脑裂导致的数据不一致等缺点。

      同步阻塞:所有的RM执行事务都是阻塞的,对于任何一次指令都必须要有明确的响应才能进行下一步操作,否则将处于阻塞状态,其占用的资源也一直被锁定。

      过于保守:任何一个节点失败都会导致整个分布式事务回滚。

      事务协调者的单点故障:如果协调者在第二阶段出现了问题,那么所有的参与者都将一直处于锁定状态。

      脑裂导致数据不一致问题:由于网络问题,在第二阶段有部分参与者收到了commit指令并提交了事务,但是有一部分参与者没有收到commit指令而导致事务无法提交,最终造成了数据不一致。

(二)三阶段提交

  三阶段提交是在两阶段提交的基础上做出的改进,其利用了超时机制解决了同步阻塞的问题。

  三阶段提交的描述如下:

    CanCommit:询问阶段,事务协调者向参与者发送执行请求,询问是否可以完成指令,参与者只需要回答是不是即可,不需要真正的处理事务,这个阶段会有超时终止机制。

    PreCommit:准备阶段,事务协调者会根据参与者的反馈结果决定是否要继续进行,如果在询问阶段所有的参与者都返回了可以进行事务操作,那么事务协调者会向所有的参与者发送PreCommit请求,参与者收到请求后写 redo log 和 undo log ,执行事务操作但不提交事务,然后返回ACK响应,等待事务协调者的下一步指令;如果在询问阶段存在参与者返回不能参与事务,那么事务协调者会通知所有的参与者中断事务。

    DoCommit:提交或回滚阶段,这个阶段也会存在两种情况,如果在PreCommit阶段所有的参与者都返回成功,那么事务协调者会向所有的参与者发送事务提交请求;反之,如果在PreCommit阶段存在参与者返回失败,事务协调者则通知所有的参与者回滚事务。

  三段式优缺点:

    优点:使用超时机制解决了同步阻塞的问题、过于保守等问题

    缺点:由于其不再是同步阻塞,有可能造成数据不一致

  两段式提交和三段式提交选型:

    虽然从优缺点上来看,三段式是对于两段式的优化与改进,而实际工作中,反而两段式应用的更广,这是因为三段式可能存在数据不一致的问题,两段式最多是服务不可用,三段式是会将数据搞混,反而更难以修复

二、TCC

  1、TCC简介

    TCC分为Try、Confirm、Cancel三个阶段:

      Try 阶段:尝试执行,完成所有业务检查(一致性), 预留必须业务资源(准隔离性)

      Confirm 阶段:确认执行真正执行业务,不作任何业务检查,只使用 Try 阶段预留的业务资源,Confirm 操作要求具备幂等设计,Confirm 失败后需要进行重试。

      Cancel 阶段:取消执行,释放 Try 阶段预留的业务资源。Cancel 阶段的异常和 Confirm 阶段异常处理方案基本上一致,要求满足幂等设计。

        

 

  2、实现原理

    其是通过数据库的唯一索引保证全局事务的,在本地数据库,建立分支事务状态表sub_trans_barrier,唯一键为全局事务id-子事务id-子事务分支名称(try|confirm|cancel),执行try,会往分支事务状态表中插入一条数据(全局事务id+分支事务id+try)

  2、TCC优点:

    并发度较高,无长期资源锁定。

    一致性较好,不会发生SAGA已扣款最后又转账失败的情况

  TCC缺点:

    开发量较大,需要提供Try/Confirm/Cancel接口。

   3、适用场景

    TCC适用于订单类业务,对中间状态有约束的业务

  4、TCC存在的问题:

    空回滚:

      try由于网络等问题没有提交,但是提交了cancel;

      解决方案,cancel查询分支事务状态表(全局事务id+分支事务id+try),如果数据存在,就不是空回滚,直接进行回滚操作,如果没有数据,往分支事务状态表中插入一条数据(全局事务id+分支事务id+try),插入成功,直接返回成功,不做处理;

    幂等:

      适用分支事务状态表(全局事务id+分支事务id+try)中数据库唯一索引来保证只插入一次

    悬挂:

      Cancel 接口比 Try 接口先执行。cancel执行时,如果是空回滚,就会插入分支事务状态表(全局事务id+分支事务id+try)数据,待执行try时,不能插入,解决了悬挂问题。

        

三、本地消息表

  本地消息表这个方案最初是 ebay 架构师 Dan Pritchett 在 2008 年发表给 ACM 的文章。设计核心是将需要分布式处理的任务通过消息的方式来异步确保执行。

  大致流程如下:

        

 

  写本地消息和业务操作放在一个事务里,保证了业务和发消息的原子性,要么他们全都成功,要么全都失败。

  容错机制:

    扣减余额事务 失败时,事务直接回滚,无后续步骤

    轮序生产消息失败, 增加余额事务失败都会进行重试

  本地消息表的特点:

    不支持回滚

    轮询生产消息难实现,如果定时轮询会延长事务总时长,如果订阅binlog则开发维护困难

  适用于可异步执行的业务,且后续操作无需回滚的业务

四、可靠消息

  在上述的本地消息表方案中,生产者需要额外创建消息表,还需要对本地消息表进行轮询,业务负担较重。阿里开源的RocketMQ 4.3之后的版本正式支持事务消息,该事务消息本质上是把本地消息表放到RocketMQ上,解决生产端的消息发送与本地事务执行的原子性问题。

  事务消息发送及提交:

    发送消息(half消息)

    服务端存储消息,并响应消息的写入结果

    根据发送结果执行本地事务(如果写入失败,此时half消息对业务不可见,本地逻辑不执行)

    根据本地事务状态执行Commit或者Rollback(Commit操作发布消息,消息对消费者可见)

  正常发送的流程图如下:

        

 

 

   补偿流程:

    对没有Commit/Rollback的事务消息(pending状态的消息),从服务端发起一次“回查”,Producer收到回查消息,返回消息对应的本地事务的状态,为Commit或者Rollback,事务消息方案与本地消息表机制非常类似,区别主要在于原先相关的本地表操作替换成了一个反查接口。

  事务消息特点如下:

    长事务仅需要分拆成多个任务,并提供一个反查接口,使用简单

    事务消息的回查没有好的方案,极端情况可能出现数据错误

  适用于可异步执行的业务,且后续操作无需回滚的业务

五、尽最大努力通知

  尽最大努力通知总结成一句话就是:衰减通知,提供回查接口

  发起通知方通过一定的机制最大努力将业务处理结果通知到接收方。具体包括:

    有一定的消息重复通知机制。因为接收通知方可能没有接收到通知,此时要有一定的机制对消息重复通知。

    消息校对机制。如果尽最大努力也没有通知到接收方,或者接收方消费消息后要再次消费,此时可由接收方主动向通知方查询消息信息来满足需求。

  前面介绍的的本地消息表和事务消息都属于可靠消息,与这里介绍的最大努力通知有什么不同?

    可靠消息一致性,发起通知方需要保证将消息发出去,并且将消息发到接收通知方,消息的可靠性关键由发起通知方来保证。

    最大努力通知,发起通知方尽最大的努力将业务处理结果通知为接收通知方,但是可能消息接收不到,此时需要接收通知方主动调用发起通知方的接口查询业务处理结果,通知的可靠性关键在接收通知方。

  解决方案上,最大努力通知需要:

    提供接口,让接受通知放能够通过接口查询业务处理结果

    消息队列ACK机制,消息队列按照间隔1min、5min、10min、30min、1h、2h、5h、10h的方式,逐步拉大通知间隔 ,直到达到通知要求的时间窗口上限。之后不再通知。

  最大努力通知适用于业务通知类型,例如微信交易的结果,就是通过最大努力通知方式通知各个商户,既有回调通知,也有交易查询接口 

六、saga:通过时间监听机制,分段提交回滚

  Saga是这一篇数据库论文sagas提到的一个方案。其核心思想是将长事务拆分为多个本地短事务,由Saga事务协调器协调,如果正常结束那就正常完成,如果某个步骤失败,则根据相反顺序一次调用补偿操作。

  把上面的转账作为例子,一个成功完成的SAGA事务时序图如下:

        

  Saga一旦到了Cancel阶段,那么Cancel在业务逻辑上是不允许失败了。如果因为网络或者其他临时故障,导致没有返回成功,那么TM会不断重试,直到Cancel返回成功。

  Saga事务的特点:

    并发度高,不用像XA事务那样长期锁定资源

    需要定义正常操作以及补偿操作,开发量比XA大

    一致性较弱,对于转账,可能发生A用户已扣款,最后转账又失败的情况

  论文里面的SAGA内容较多,包括两种恢复策略,包括分支事务并发执行,我们这里的讨论,仅包括最简单的SAGA

  SAGA适用的场景较多,长事务适用,对中间结果不敏感的业务场景适用

七、AT(Seata)

  AT 是 Springcloud Alibaba中提出的一种分布式事务解决方案,包含TM、TC、RM三个角色。

    TM向TC注册全局事务并声称全局唯一的XID

    RM向TC注册分支事务,将其纳入该XID对应的全局事务的范围

    RM向TC汇报资源的准备状态

    TC汇总所有事务参与者的执行状态,决定分布式事务是提交还是回滚

    TC通知所有的参与者提交或回滚

  AT原理:

    1、在第一阶段中,由于存在DatasourceProxy,其会基于数据源代理对原执行的sql进行解析,将回滚日志和业务数据写入undo_log日志中,例如某一款商品的库存为100,此时下单了一件,业务事务操作是将库存减一,那么DatasourceProxy会先查询数据库的数量(此时为100),然后进行事务操作,然后再查询数据库的数量(此时为99),然后在undo_log中新入回滚日志,将回滚日志和业务数据一同提交事务。

      在上面对于undo_log的建表语句中可以看到其存在rollback_info的字段,该字段中包含beforeImage和afterImage两个内容,也就是事务操作前的数据镜像和事务操作后的数据镜像,例如上面的例子,在beforeImage中就是100,在after中就是99。

      在提交前,seata客户端向TC注册分支事务,申请tbl_repo表中主键值等于1的记录的全局锁。

      本地事务提交:业务数据的更新和生成的UNDO_LOG一起提交

      将本地事务提交的结果上报给TC

   2、第二阶段模式实现原理

    TC接收到所有的分支事务的状态汇报后,决定对全局事务进行提交或者回滚。

    如果决定提交全局事务,只需要清除掉UNDO_LOG的数据即可,因为数据已经在第一阶段提交了。流程如下:分支事务收到TC的提交全局事务的请求,将请求放入一个异步任务队列中,并马上返回提交成功的结果给TC。从异步队列中执行分支,提交请求,批量删除响应的UNDO_LOG日志.这里采用的是异步处理的方式,是因为TC并不需要知道分支事务的执行结果,分支事务只需要清除UNDO_LOG即可,即便是日志清除失败,也不会对分布式事务造成影响。

    如果决定回滚全局事务,就是拿着UNDO_LOG进行数据回滚。所有的分支事务收到TC的事务回滚请求后,分支事务参与者开启一个本地事务执行如下流程:通过XID和branchID查找到对应的UNDO_LOG;数据校验,拿UNDO_LOG中的afterImage镜像数据与当前业务表中的数据进行比较,如果不同,说明数据被当前全局事务之外的操作修改了数据,那么将不会回滚;如果afterImage数据与当前业务表中的数据一致,则根据UNDO_LOG中的beforeImage镜像数据和业务SQL的相关信息生成回滚语句并执行;提交本地事务,并把本地事务的执行结果上报TC

  3、事务隔离性:

    在AT模式中,当多个全局事务操作同一张表时,他的事务隔离性保证是基于全局锁来实现的。

    写隔离

      写隔离是在多个全局事务针对一张表的同一个字段进行更新操作时,避免全局事务在没有提交之前被其他全局事务修改。一个事务提交时,首先获取本地锁,然后执行本地事务,准备提交前,先获取全局事务,获取成功,则提交本地事务,释放本地锁,然后在第二阶段提交后,释放全局锁;如果在提交本地事务前获取全局锁失败,则会一直重试,直到重试成功或者到达最大重试次数。

      一个事务回滚时,由于需要执行数据回滚,因此需要重新获取本地锁,此时如果别的操作已经拿到本地锁,则需要等待,如果拿到本地锁,则可以正常执行回滚,执行完毕后,释放全局锁。

    读隔离

      数据库的四种隔离级别分别为:读未提交、读已提交、不可重复度、可串行化。

       在本地数据库的隔离级别为读已提交以上时,Seata AT事务模式的默认全局事务隔离级别为读未提交,这就容易产生脏堵,但是对于分布事务场景中,这种最终一致性的方案是可以被接受的。如果必须要求事务隔离级别为读已提交时,Seata通过SelectForUpdateExecutor执行器对select语句做select for update代理,select for update在执行时会申请获取全局锁,如果此时拿不到全局锁,则查询会被阻塞

  4、AT & XA

  AT和XA最大的区别就是,XA事务的两阶段提交,一般锁定资源后持续到第二阶段的提交或者回滚后才释放资源;而AT是在第一阶段提交完成后就马上释放了资源。所以AT模式降低了锁范围,从而提升了分布式事务的处理效率,之所以这么处理,是因为Seata记录了回滚日志,即便第二阶段发生了异常,也可以根据UNDO_LOG中记录的数据进行回滚。

posted @ 2022-05-16 16:36  李聪龙  阅读(970)  评论(0编辑  收藏  举报