事物 分布式事务

引言

在支付、交易、订单等强一致性系统中,我们需要使用分布式事务来保证各个数据库或各个系统之间的数据一致性。

举个简单的例子来描述一下这里数据一致性的含义。

程序员小张向女友小丽转账100人民币,转账过程是:先扣除小张100元,再为小丽的账户添加100元。

如果在转帐过程中,扣款操作和打款操作要么同时执行,要么同时都不执行,我们就认为转帐过程保证了数据一致性。

上面的例子中,如果我们不使用分布式来保证转账过程中数据的一致性,就有可能出现小张账户上的钱被扣除,而小丽账户上的钱却没被添加的情况,其结果大家可以自行脑补。

事务是数据库特有的概念,分布式事务最初起源于处理多个数据库之间的数据一致性问题,但随着IT技术的高速发展,大型系统中逐渐使用SOA服务化接口替换直接对数据库操作,所以如何保证各个SOA服务之间的数据一致性也被划分到分布式事务的范畴。

单数据库事务例子

先来看看我们需要实现的交易系统:游戏中的玩家使用金币购买道具,交易系统需要负责扣除玩家金币并为玩家添加道具。

我们把交易系统的一次交易流程归纳为两步:

扣除玩家金币

为玩家添加道具

需求并不复杂,我们为金币系统在数据库中添加金币表,为道具系统在数据库中添加道具表,扣除金币与添加道具的操作只需执行相应的SQL即可。

这里我们假设金币表与道具表都在同一个数据库中,于是可以简单地使用单数据库事务来保证数据的一致性。

下面是使用单数据库事务进行一次正常交易的时序图:
在这里插入图片描述此时哪一步步骤出错,都会进行回滚操作,

  1. 在步骤[2]执行SQL扣除金币时出现异常,回滚事务即可保证数据一致
  2. 在步骤[4]执行SQL添加道具时出现异常,回滚事务即可保证数据一致
  3. 在步骤[6]提交事务时出现异常,回滚事务即可保证数据一致从而不会出现数据不一致的问题。

注意这里执行成功还没有落盘持久化,commit提交,也只是执行下面四步操作,刷redo日志是保证这时候数据库g了,磁盘上也有操作记录复原数据库。所以commit操作也可能会失败。

  1. 清理undo段信息,对于innodb存储引擎的更新操作来说,undo段需要purge,这里的purge主要职能是,真正删除物理记录。在执行delete或update操作时,实际旧记录没有真正删除,只是在记录上打了一个标记,而是在事务提交后,purge线程真正删除,释放物理页空间。因此,提交过程中会将undo信息加入purge列表,供purge线程处理。

  2. 释放锁资源,mysql通过锁互斥机制保证不同事务不同时操作一条记录,事务执行后才会真正释放所有锁资源,并唤醒等待其锁资源的其他事务;

  3. 刷redo日志,前面我们说到,mysql实现事务一致性和持久性的机制。通过redo日志落盘操作,保证了即使修改的数据页没有即使更新到磁盘,只要日志是完成了,就能保证数据库的完整性和一致性;

  4. 清理保存点列表,每个语句实际都会有一个savepoint(保存点),保存点作用是为了可以回滚到事务的任何一个语句执行前的状态,由于事务都已经提交了,所以保存点列表可以被清理了。

从ACID到CAP/BASE

事务

从上面消耗金币买装备的例子可以看出,事务是由一系列对系统中数据进行访问和更新的操作所组成的执行逻辑单元。
一方面,当并发访问时事务可以提供一个隔离方法,防止彼此间操作相互干扰。mysql通过锁互斥机制保证不同事务不同时操作一条记录,事务执行后commit操作才会真正释放所有锁资源,并唤醒等待其锁资源的其他事务。除了写操作,还有读MVVC。
另一方面,事务为数据库从失败恢复到正常状态提供方法,保持数据一致性。
A原子性C一致性I隔离性D持久性(未来总结数据库再详细笔记)

分布式事务

多个分布式的操作序列组成,也可以被定义为一种嵌套型的事务。
本文后续分析2PC、3PC都是强一致性模型,但是可以根据业务需求,适当放松,达到最终一致性。BASE 基本可用Basically Available、弱状态Soft state、最终一致性Eventually consistent。

分布式事务例子

随着玩家数量激增,金币表与道具表的总行数与访问量都急剧扩大,单台数据库不足以支撑起这两张表的读写请求,这时将金币表与道具表放在不同的数据库中是个不错的选择。
这里我们假设金币表被放入了金币数据库中,而道具表被放入了道具数据库中,通常我们将这种按不同业务拆分数据库的方式称之为数据库垂直拆分。

数据库垂直拆分能大大缓解数据库的压力问题,但多个数据库的存在意味着我们不能通过简单的单数据库事务来保证数据的一致性,如何保证多数据库之间数据的一致性,也就是分布式事务需要解决的问题。
回到我们的交易系统,先不考虑多数据库之间的数据一致性问题,简单的交易流程为:

在这里插入图片描述
正常情况下,上面的流程不会产生数据一致性问题,但如果在步骤[7]执行SQL添加道具时出现异常,由于扣除金币的事务已经在步骤[5]提交无法回滚,就会出现扣除玩家金币后没有为玩家添加道具的数据不一致情况。

上面问题产生的原因其实是过早地向金币数据库提交事务,所以我们可以采取后置提交事务策略来解决此问题,即先在金币数据库与道具数据库上执行SQL,最后再提交金币数据库与道具数据库上的事务,这样当执行SQL出现异常时,我们就能通过同时回滚两个数据库上事务的方式,来保证数据一致性。

下面是使用后置提交事务进行一次正常交易的时序图:
在这里插入图片描述结合上图,我们讨论当出现异常时,后置提交事务如何避免数据不一致问题:

在步骤[3]执行SQL扣除金币时出现异常,回滚金币数据库上的事务即可保证数据一致

在步骤[5]执行SQL添加道具时出现异常,同时回滚金币数据库与道具数据库上的事务即可保证数据一致

在步骤[7]提交扣除金币事务时出现异常,同时回滚金币数据库与道具数据库上的事务即可保证数据一致

在步骤[9]提交添加道具事务时出现异常,由于扣除金币事务已提交无法回滚,会出现扣除玩家金币后没有为玩家添加道具的数据不一致情况

通过上面四种异常的处理方式,我们可以看出,使用后置提交事务的策略,虽然能避免SQL执行异常导致的数据不一致,但在最后提交事务遇到异常时却无能为力,所以我们需要引入新的事务提交方式。

二阶段提交(分布式事务的一致性)

2pc是一个非常经典的强一致、中心化的原子提交协议。这里所说的中心化是指协议中有两类节点:一个是中心化协调者节点(coordinator)和N个参与者节点(partcipant)。
两段式事务提交2PC(two-phase commit protocol)指的是在执行提交commit操作前,添加预提交prepare操作,其中预提交操作执行了传统意义上提交操作的大部分工作,我们可以简单地认为只要预提交prepare操作成功,后续的提交commit操作一定会成功。

我们将购买道具的交易流程改为两段提交,时序图如下:
在这里插入图片描述
上图中,我们在真正提交事务之前采用了预提交事务,预提交是一个很重的操作,一旦抛出异常,我们回滚事务即可,但预提交一旦成功,后续的提交操作则非常轻量,几乎可以认为不会出错,这样我们就使用两段提交保证了多个数据库之间数据的一致性。

其实上面的两段式事务也就是著名的XA事务,XA是由X/Open组织提出的分布式事务的规范,也是目前使用最为广泛的多数据库分布式事务规范。

prepare commit

在这里插入图片描述
第一个阶段如👆图所示,这个阶段被称之为请求/表决阶段。是个什么意思呢?

就是在分布式事务的发起方在向分布式事务协调者(Coordinator)发送请求时,Coordinator首先会分别向参与者(Partcipant)节点A、参与这节点(Partcipant)节点B分别发送事务预处理请求,称之为Prepare,有些资料也叫"Vote Request"。

说的直白点就是问一下这些参与节点"这件事你们能不能处理成功了",此时这些参与者节点一般来说就会打开本地数据库事务,然后开始执行数据库本地事务,并将Undo和Redo信息记入事务日志中,但在执行完成后并不会立马提交数据库本地事务,而是先向Coordinator报告说:“我这边可以处理了/我这边不能处理”。

如果所有的参与这节点都向协调者作了“Vote Commit”的反馈的话,那么此时流程就会进入第二个阶段了。

commit/rollback

在这里插入图片描述如果所有参与者节点都向协调者报告说“我这边可以处理”,那么此时协调者就会向所有参与者节点发送“全局提交确认通知(global_commit)”,即你们都可以进行本地事务提交了,此时参与者节点就会完成自身本地数据库事务的提交,并最终将提交结果回复“ack”消息给Coordinator,然后Coordinator就会向调用方返回分布式事务处理完成的结果。
在这里插入图片描述相反,在第二阶段除了所有的参与者节点都反馈“我这边可以处理了”的情况外,也会有节点反馈说“我这边不能处理”的情况发生,此时参与者节点就会向协调者节点反馈“Vote_Abort”的消息。此时分布式事务协调者节点就会向所有的参与者节点发起事务回滚的消息(“global_rollback”),此时各个参与者节点就会回滚本地事务,释放资源,并且向协调者节点发送“ack”确认消息,协调者节点就会向调用方返回分布式事务处理失败的结果。

不足

以上就是两阶段提交的基本过程了,那么按照这个两阶段提交协议,分布式系统的数据一致性问题就能得到满足吗?

实际上分布式事务是一件非常复杂的事情,两阶段提交只是通过增加了事务协调者(Coordinator)的角色来通过2个阶段的处理流程来解决分布式系统中一个事务需要跨多个服务节点的数据一致性问题。但是从异常情况上考虑,这个流程也并不是那么的无懈可击。

假设如果在第二个阶段中Coordinator在接收到Partcipant的"Vote_Request"后挂掉了或者网络出现了异常,那么此时Partcipant节点就会一直处于本地事务挂起的状态,从而长时间地占用资源。当然这种情况只会出现在极端情况下,然而作为一套健壮的软件系统而言,异常Case的处理才是真正考验方案正确性的地方。

以下几点是XA-两阶段提交协议中会遇到的一些问题:

  1. 性能问题。从流程上我们可以看得出,其最大缺点就在于它的执行过程中间,节点都处于阻塞状态。各个操作数据库的节点此时都占用着数据库资源,只有当所有节点准备完毕,事务协调者才会通知进行全局提交,参与者进行本地事务提交后才会释放资源。这样的过程会比较漫长,对性能影响比较大。

  2. 协调者单点故障问题。事务协调者是整个XA模型的核心,一旦事务协调者节点挂掉,会导致参与者收不到提交或回滚的通知,从而导致参与者节点始终处于事务无法完成的中间状态。

  3. 丢失消息导致的数据不一致问题。在第二个阶段,如果发生局部网络问题,一部分事务参与者收到了提交消息,另一部分事务参与者没收到提交消息,那么就会导致节点间数据的不一致问题。

既然两阶段提交有以上问题,无论是协调者节点还是参与者节点出现问题,都会导致事务的失败,那么有没有其他的方案来解决呢?

三阶段提交(3PC)

三阶段提交又称3PC,其在两阶段提交的基础上增加了CanCommit阶段,并引入了超时机制。一旦事务参与者迟迟没有收到协调者的Commit请求,就会自动进行本地commit,这样相对有效地解决了协调者单点故障的问题。

但是性能问题和不一致问题仍然没有根本解决。下面我们还是一起看下三阶段流程的是什么样的?

CanCommit阶段

在这里插入图片描述
这个阶段类似于2PC中的第二个阶段中的Ready阶段,是一种事务询问操作,事务的协调者向所有参与者询问“你们是否可以完成本次事务?”,如果参与者节点认为自身可以完成事务就返回“YES”,否则“NO”。而在实际的场景中参与者节点会对自身逻辑进行事务尝试,其实说白了就是检查下自身状态的健康性,看有没有能力进行事务操作。

PreCommit阶段

在这里插入图片描述在阶段一中,如果所有的参与者都返回Yes的话,那么就会进入PreCommit阶段进行事务预提交。此时分布式事务协调者会向所有的参与者节点发送PreCommit请求,参与者收到后开始执行事务操作,并将Undo和Redo信息记录到事务日志中。参与者执行完事务操作后(此时属于未提交事务的状态),就会向协调者反馈“Ack”表示我已经准备好提交了,并等待协调者的下一步指令。

否则,如果阶段一中有任何一个参与者节点返回的结果是No响应,或者协调者在等待参与者节点反馈的过程中超时(2PC中只有协调者可以超时,参与者没有超时机制)。整个分布式事务就会中断,协调者就会向所有的参与者发送“abort”请求。

DoCommit阶段

在这里插入图片描述在阶段二中如果所有的参与者节点都可以进行PreCommit提交,那么协调者就会从“预提交状态”-》“提交状态”。然后向所有的参与者节点发送"doCommit"请求,参与者节点在收到提交请求后就会各自执行事务提交操作,并向协调者节点反馈“Ack”消息,协调者收到所有参与者的Ack消息后完成事务。

相反,如果有一个参与者节点未完成PreCommit的反馈或者反馈超时,那么协调者都会向所有的参与者节点发送abort请求,从而中断事务。

提升

相比较2PC而言,3PC对于协调者(Coordinator)和参与者(Partcipant)都设置了超时时间,而2PC只有协调者才拥有超时机制。这解决了一个什么问题呢?这个优化点,主要是避免了参与者在长时间无法与协调者节点通讯(协调者挂掉了)的情况下,无法释放资源的问题,因为参与者自身拥有超时机制会在超时后,自动进行本地commit从而进行释放资源。而这种机制也侧面降低了整个事务的阻塞时间和范围。

另外,通过CanCommit、PreCommit、DoCommit三个阶段的设计,相较于2PC而言,多设置了一个缓冲阶段保证了在最后提交阶段之前各参与节点的状态是一致的。

以上就是3PC相对于2PC的一个提高(相对缓解了2PC中的前两个问题),但是3PC依然没有完全解决数据不一致的问题。

缺点:在参与者接受到preCommit后,如果出现网络分区,此时协调者节点与参与者节点无法进行正常的网络通信,这时候参与者就会进行事务的提交,也不管是不是rollback,因此这会出现数据的不一致性。

结合消息队列MQ实现分布式系统的最终一致性

在业务需求不需要强一致性时,为提升性能,结合消息队列MQ实现分布式系统的最终一致性。
举个例子,以某互联网公司的用户余额充值为例,因为有充返活动(充值100元赠送20元),优惠比较大,用户Joe禁不住诱惑用支付宝向自己的余额账户充值了100元,支付成功后Joe的余额账户有了120元钱。
而该公司的关于用户余额充值的系统设计是这样的:
在这里插入图片描述
这里有个问题:“支付系统如何确保这笔余额充值消息一定会成功发送到MQ,并且用户余额系统一定能处理成功呢”?

  1. 上游服务(支付系统)如何确保完成自身支付成功状态更新后消息100%的能够投递到下游服务(用户余额系统)指定的Topic中?
    在这个流程中上游服务在进行本地数据库事务操作前,会先发送一个状态为“待确认”的消息至可靠消息服务,而不是直接将消息投递到MQ服务的指定Topic。可靠消息服务此时会将该消息记录到自身服务的消息数据库中(消息状态为->待确认),完成后可靠消息服务会回调上游服务表示收到了消息,你们可以进行本地事务的操作了。

之后上游服务就会开启本地数据库事务执行业务逻辑操作,这里支付系统就会将该笔支付订单状态更新为“已成功”。(注意,这里只是举个示例场景,在真正的实践中一般是不会把支付订单本身的状态与业务端回调放在一个事务流程中的,关于这部分的详细说明我们在下面的场景说明中再讨论)。

如果上游服务本地数据库事务执行成功,则继续向可靠消息服务发送消息确认消息,此时可靠消息服务就会正式将消息投递到MQ服务,并且同时更新消息数据库中的消息状态为“已发送”。(注意,这里可靠消息服务更新消息状态与投递消息至MQ也必须是在一个原子操作中,即消息投递成功则一定要将消息状态更新为“已发送”,所以在编程的细节中,可靠消息服务一般会先更新消息状态,然后再进行消息投递,这样即使消息投递失败,也可以对消息状态进行回滚->“待确认”,相反如果先进行消息投递再更新消息状态,可能就不好控制了)。

相反,如果上游本地数据库事务执行失败,则需要向可靠消息服务发送消息删除消息,可靠消息服务此时就会将消息删除,这样就意味着事务在上游消息投递过程中就被回滚了,而流程也就此结束了,此时上游服务可以需要通过业务逻辑的设计进行重发,这个就不再分布式事务的讨论范畴了。

说到这里,大家可能会有疑问了!因为在上述描述中,即使上游服务本地数据库事务执行成功了,但是在发送确认消息至可靠消息服务的过程中,以及可靠消息服务在投递消息至MQ服务的过程中,还是会存在失败的风险,这样的话还是会导致支付服务更新了状态,但是用户余额系统连消息都没有收到的情况发生?

实际上,实现数据一致性是一个复杂的活。在这个方案中可靠消息服务作为基础性的服务除了执行正常的逻辑外,还得处理复杂的异常场景。在实现过程中可靠消息服务需要启动相应的后台线程,不断轮训消息的状态,这里会轮训消息状态为“待确认”的消息,并判断该消息的状态的持续时间是否超过了规定的时间,如果超过规定时间的消息还处于“待确认”的状态,就会触发上游服务状态询问机制。

可靠消息服务就会调用上游服务提供的相关借口,询问这笔消息的处理情况,如果这笔消息在上游服务处理成功,则后台线程就会继续触发上图中的步骤5,更新消息状态为“已发送”并投递消息至MQ服务;反之如果这笔消息上游服务处理失败,可靠消息服务则会进行消息删除。通过这样以上机制就确保了“上游服务本地事务成功处理+消息成功投递”处于一个原子操作了。

  1. 下游服务(用户余额系统)如何确保对MQ服务Topic消息的消费100%都能处理成功?
    在1的过程中,确保了上游服务逻辑处理与MQ消息的投递具备原子性,那么当消息被成功投递到了MQ服务的指定Topic后,下游服务如何才能确保消息的消费一定能被成功处理呢?

在正常的流程中,下游服务等待消费Topic的消息并进行自身本地数据库事务的处理,如果处理成功则会主动通知可靠消息服务,可靠消息服务此时就会将消息的状态更新为“已完成”;反之,处理失败下游服务就无法再主动向可靠消息服务发送通知消息了。

此时,与消息投递过程中的异常逻辑一样,可靠消息服务也会启动相应的后台线程,轮询一直处于“已发送”状态的消息,判断状态持续时间是否超过了规定时间,如果超时,可靠消息服务就会再次向MQ服务投递此消息,从而确保消息能被再次消费处理。(注意,也可能出现下游服务处理成功,但是通知消息发送失败的情况,所以为了确保幂等,下游服务也需要在业务逻辑上做好相应的防重处理)。

TCC与AT

一个分布式的全局事务,整体是 两阶段提交 的模型。全局事务是由若干分支事务组成的,分支事务要满足 两阶段提交 的模型要求,即需要每个分支事务都具备自己的:

一阶段 prepare 行为
二阶段 commit 或 rollback 行为

根据两阶段行为模式的不同,我们将分支事务划分为 Automatic (Branch) Transaction Mode 和 TCC (Branch) Transaction Mode.

AT 模式基于 支持本地 ACID 事务 的 关系型数据库:
一阶段 prepare 行为:在本地事务中,一并提交业务数据更新和相应回滚日志记录。
二阶段 commit 行为:马上成功结束,自动 异步批量清理回滚日志。
二阶段 rollback 行为:通过回滚日志,自动 生成补偿操作,完成数据回滚。

相应的,TCC 模式,不依赖于底层数据资源的事务支持:
一阶段 prepare 行为:调用 自定义 的 prepare 逻辑。
二阶段 commit 行为:调用 自定义 的 commit 逻辑。
二阶段 rollback 行为:调用 自定义 的 rollback 逻辑。
所谓 TCC 模式,是指支持把 自定义 的分支事务纳入到全局事务的管理中。

TCC(Try-Confirm-Cancel)又称补偿事务。其核心思想是:“针对每个操作都要注册一个与其对应的确认和补偿(撤销操作)”。它分为三个操作:
Try阶段:主要是对业务系统做检测及资源预留。
Confirm阶段:确认执行业务操作。
Cancel阶段:取消执行业务操作
TCC事务的处理流程与2PC两阶段提交类似,不过2PC通常都是在跨库的DB层面,而TCC本质上就是一个应用层面的2PC。

TCC事务例子

之前我们的交易系统在进行购买道具时,都是直接操作金币表与道具表,下面我们对交易系统的架构进行升级:

将与金币相关的操作独立成一套金币服务,将与道具相关的操作独立成一套道具服务,交易系统在扣除金币与添加道具时,不再直接操作数据库表,而是调用相应服务的SOA接口。

基于SOA接口的最简交易时序图如下:
在这里插入图片描述 上图中,我们的交易系统不再直接操作数据库表,而是通过调用SOA接口的方式扣除金币与添加道具。

我们考虑在步骤[3]调用SOA接口添加道具时出现异常,由于之前已经调用SOA接口扣除金币成功,于是就会出现扣除玩家金币后,没有为玩家添加道具的不一致情况。

为保证各个SOA服务之间的数据一致性,我们需要设计基于SOA接口的分布式事务。

目前比较流行的SOA分布式事务解决方案是TCC事务,TCC事务的全称为:Try-Confirm/Cancel,翻译成中文即:尝试、确定、取消。

简单来说,TCC事务是一种编程模式,如果SOA接口的提供者与调用者都遵从TCC编程模式,那么就能最大限度的保证数据一致性。

下面我们以扣除金币这一操作,来说明一下TCC编程模式。

非TCC模式的扣除金币操作,接口提供者只需要提供一个SOA接口即可,接口的作用就是扣除金币。

而TCC模式的扣除金币操作,接口提供者针对扣除金币这一操作需要提供三个SOA接口:

  1. 扣除金币Try接口,尝试扣除金币,这里只是锁定玩家账户中需要被扣除的金币,并没有真正扣除金币,类似于信用卡的预授权;假设玩家账户中100金币,调用该接口锁定60金币后,锁定的金币不能再被使用,玩家账户中还有40金币可用

  2. 扣除金币Confirm接口,确定扣除金币,这里将真正扣除玩家账户中被锁定的金币,类似于信用卡的确定预授权完成刷卡

  3. 扣除金币Cancel接口,取消扣除金币,被锁定的金币将返还到玩家的账户中,类似于信用卡的撤销预授权取消刷卡

SOA接口调用者如何使用这三个接口呢?

调用者先执行扣除金币Try接口,再去执行其他任务(比如添加道具),当其他任务执行成功,调用者执行扣除金币Confirm接口确认扣除金币,而当其他任务执行异常,调用者则执行扣除金币Cancel接口取消扣除金币。

这里我们假设添加道具的SOA接口也满足TCC模式,下图是使用TCC事务进行道具购买的时序图:

在这里插入图片描述 对照上图,我们分析一下TCC事务如何在各种异常情况下,保证数据的一致性:

  1. 在步骤[1]调用扣除金币Try接口时出现异常,调用扣除金币Cancel接口即可保证数据一致

  2. 在步骤[3]调用添加道具Try接口时出现异常,调用扣除金币Cancel接口与添加道具Cancel接口即可保证数据一致

  3. 在步骤[5]调用扣除金币Confirm接口时出现异常,调用扣除金币Cancel接口与添加道具Cancel接口即可保证数据一致

  4. 在步骤[7]调用添加道具Confirm接口时出现异常,由于扣除金币操作已经确定不能再取消,所以这里会引发数据不一致

通过上面四种异常,我们可以看出,即使我们使用了TCC事务,也无法完美的保证各个SOA服务之间的数据一致性。

但TCC事务为我们屏蔽了大多数异常导致的数据不一致,同时一般情况下,进行Confirm或Cancel操作时产生异常的概率极小极小,所以对于一些强一致性系统,我们还是会使用TCC事务来保证多个SOA服务之间的数据一致性。

TCC与AT区别

  1. 分支事务的注册,TCC 模式下分支事务是在进入参与方 Try 方法之前的切面中注册的,而且分支实现完毕不需要再次汇报分支状态;但 AT 模式不一样,分支事务是在代理数据源提交本地事务之前注册的,注册成功才能提交一阶段本地事务,如果注册失败报锁冲突则一直阻塞等待直到该全局锁被释放,且本地提交之后不论是否成功还需要再次向 TC 汇报一次分支状态。
    比如100金币,AT模式下,这60金币的消费买道具,要在这个事务二阶段提交后,释放全局锁,新的消费事务才能再注册并且提交一阶段。而TCC进行try后,那60金币消费完,也就是变成不可用的状态,可以不用等confirm或者cancel,直接花费剩下的40

  2. AT 模式由于一阶段已经完成数据修改,因此二阶段可以异步提交,但回滚是同步的,回滚失败才会异步重试;但是 Seata 中 TCC 模式二阶段 commit 是同步提交的,可以最大程度保证 TCC 模式的数据一致性,但是笔者认为在要求性能的场景下,TCC的二阶段也可以改为异步提交.
    比如AT模式在收到confirm后,也就是不用回滚了,数据是正确的,只是还没落盘等,所以这个操作我们可以异步进行。而TCC二阶段是需要confirm操作的。

再谈最终一致性(异步数据补偿)

我们先将事务一致性划分为两类:

  1. 强一致性事务,请求结束后,数据就已经一致

  2. 最终一致性事务,请求结束后,数据没有一致,但一段时间后数据能保持一致

其实我们使用的基于后置提交的多数据库事务与TCC事务都属于强一致性事务,使用强一致性事务能保证事务的实时性,但却很难在高并发环境中保证性能。

再来看最终一致性事务,最终一致性事务这几个字看起来很牛逼,但说白了就是异步数据补偿,即在核心流程我们只保证核心数据的实时数据一致性,对于非核心数据,我们通过异步程序来保证数据一致性。

由于最终一致性事务引入了异步数据补偿机制,主流程的执行流程被简化,性能自然得到提高。

目前主流触发异步数据补偿的方式有两种:

  1. 使用消息队列实时触发数据补偿,核心流程在保证核心数据的一致性后,使用消息队列的方式通知异步程序进行数据补偿,这种方式能近乎实时的使数据达到最终一致性,但如果消息队列或异步程序出现异常,数据一致性也将不能保证

  2. 使用定时任务周期性触发数据补偿,核心流程在保证核心数据的一致性后直接返回,由定时任务周期性触发数据补偿程序,这种方式虽不能像消息队列那样能近乎实时的使数据达到最终一致性,但数据补偿程序出现异常时,我们能比较容易在下个周期对数据进行修复,能最大限度的保证数据的一致性

上面两种异步数据补偿的方式各有利弊,消息队列方式实时性强,但在异常情况下一致性弱,而定时任务方式实时性弱,但在异常情况下一致性强。

其实最优的策略是同时使用消息队列与定时任务触发数据补偿。

正常情况下,我们使用消息队列近乎实时的异步触发数据补偿,而针对那些极少发生的异常,我们使用定时任务周期性的修补数据。

这样在正常情况下,我们能近乎实时的使数据达到最终一致性,而对于一些异常数据则按照定时任务的执行周期,周期性的达到最终一致性。

例子

为了描述性能问题的产生,我们将交易系统的需求略作修改:游戏中的玩家使用金币购买道具A(强一致性要求),系统将自动赠送给玩家道具B,道具C与道具D(最终一致性要求)。

这里我们假设我们到道具服务不支持批量添加道具,而只有基于TCC模式的添加单个道具的接口。
回到上面的新版交易系统:游戏中的玩家使用金币购买道具A,系统将自动赠送给玩家道具B,道具C与道具D。

在这里插入图片描述 上图中,我们使用TCC事务保证了扣除金币与添加道具A数据一致,然后发送赠送消息并结束请求,赠送系统收到消息后负责添加道具B、C、D,最终保证数据一致。

这里如果消息队列或赠送服务出现异常我们的最终一致性将难以保证,所以我们可以再引入一个定时任务,周期性的触发异常数据补偿。

这样我们就实现了一个既能保证最终数据一致,又能保证性能的道具买赠系统。

Seata

官方文档
seata分布式事务源码解析(目录)

Seata AT模式

整体机制

两阶段提交协议的演变:

一阶段:业务数据和回滚日志记录在同一个本地事务中提交,释放本地锁和连接资源。(这里注意按照2PC对应,这算PreCommit,一个预提交干了不少事,把本来单机库里面commit做到释放本地锁也给释放了,这时候别的事务来修改数据可是畅通无阻了,这时候这个事务发现要回滚,那别的事务之前读到的脏数据就很尴尬,Seata默认的全局隔离级别是读未提交,所以为了写隔离,提出全局锁)

在这里插入图片描述

二阶段:
提交异步化,非常快速地完成。
在这里插入图片描述

回滚通过一阶段的回滚日志 UndoLog 信息进行反向补偿。(在事务链涉及的服务的数据库中新建 undo_log 表用来存储 UndoLog 信息,用于二阶段回滚操作,表中包含xid、branchId、rollback_info 等关键字段信息。)
在这里插入图片描述客户端接收到服务端的 branch rollback 请求,先根据 resourceId 拿到对应的数据源代理,然后根据 xid 和branchId 查询出 UndoLog 记录,反序列化其中的 rollback 字段拿到数据的前后快照,我们称该全局事务为A。

根据具体 SQL 类型生成对应的 UndoExecutor,校验一下数据 UndoLog 中的前后快照是否一致或者前置快照和当前数据(这里需要 SELECT 一次)是否一致,如果一致说明不需要做回滚操作,如果不一致则生成反向 SQL 进行补偿,在提交本地事务前会检测获取数据库本地锁是否成功,如果失败则说明存在其他全局事务(假设称之为 B)的一阶段正在修改相同的行,但是由于这些行的主键在服务端已经被当前正在执行二阶段回滚的全局事务 A 锁定,因此事务 B 的一阶段在本地提交前尝试获取全局锁一定是失败的,等到获取全局锁超时后全局事务 B 会释放本地锁,这样全局事务 A 就可以继续进行本地事务的提交,成功之后删除本地UndoLog 记录。整个流程如下图所示:
在这里插入图片描述

写隔离

  1. 一阶段本地事务提交前,需要确保先拿到 全局锁 。
  2. 拿不到 全局锁 ,不能提交本地事务。
  3. 拿 全局锁 的尝试被限制在一定范围内,超出范围将放弃,并回滚本地事务,释放本地锁。
    示例:
    两个全局事务 tx1 和 tx2,分别对 a 表的 m 字段进行更新操作,m 的初始值 1000。
    tx1 先开始,开启本地事务,拿到本地锁,更新操作 m = 1000 - 100 = 900。本地事务提交前,先拿到该记录的 全局锁 ,本地一阶段提交释放本地锁。 tx2 后开始,开启本地事务,拿到本地锁,更新操作 m = 900 - 100 = 800。本地事务提交前,尝试拿该记录的 全局锁 ,tx1 二阶段全局提交前,该记录的全局锁被 tx1 持有,tx2 需要重试等待 全局锁 。

tx1 二阶段全局提交,释放 全局锁 。tx2 拿到 全局锁 提交本地事务。
如果 tx1 的二阶段全局回滚,则 tx1 需要重新获取该数据的本地锁,进行反向补偿的更新操作,实现分支的回滚。

此时,如果 tx2 仍在等待该数据的 全局锁,同时持有本地锁,则 tx1 的分支回滚会失败。分支的回滚会一直重试,直到 tx2 的 全局锁 等锁超时,放弃 全局锁 并回滚本地事务释放本地锁,tx1 的分支回滚最终成功。

因为整个过程 全局锁 在 tx1 结束前一直是被 tx1 持有的,所以不会发生 脏写 的问题。

读隔离

在数据库本地事务隔离级别 读已提交(Read Committed) 或以上的基础上,Seata(AT 模式)的默认全局隔离级别是 读未提交(Read Uncommitted) 。
如果应用在特定场景下,必需要求全局的 读已提交 ,目前 Seata 的方式是通过 SELECT FOR UPDATE 语句的代理。
SELECT FOR UPDATE 语句的执行会申请 全局锁 ,如果 全局锁 被其他事务持有,则释放本地锁(回滚 SELECT FOR UPDATE 语句的本地执行)并重试。这个过程中,查询是被 block 住的,直到 全局锁 拿到(二阶段提交完成,全局锁得到释放),即读取的相关数据是 已提交 的,才返回。

出于总体性能上的考虑,Seata 目前的方案并没有对所有 SELECT 语句都进行代理,仅针对 FOR UPDATE 的 SELECT 语句。

工作机制

以一个示例来说明整个 AT 分支的工作过程。

业务表:product

Field Type Key
id bigint(20) PRI
name varchar(100)
since varchar(100)
AT 分支事务的业务逻辑:

update product set name = ‘GTS’ where name = ‘TXC’;

一阶段

过程:

  1. 解析 SQL:得到 SQL 的类型(UPDATE),表(product),条件(where name = ‘TXC’)等相关的信息。
  2. 查询前镜像:根据解析得到的条件信息,生成查询语句,定位数据。
    select id, name, since from product where name = ‘TXC’;

得到前镜像:
id name since
1 TXC 2014

  1. 执行业务 SQL:更新这条记录的 name 为 ‘GTS’。
  2. 查询后镜像:根据前镜像的结果,通过 主键 定位数据。
    select id, name, since from product where id = 1`;

得到后镜像:
id name since
1 GTS 2014

  1. 插入回滚日志:把前后镜像数据以及业务 SQL 相关的信息组成一条回滚日志记录,插入到 UNDO_LOG 表中。
{
	"branchId": 641789253,
	"undoItems": [{
		"afterImage": {
			"rows": [{
				"fields": [{
					"name": "id",
					"type": 4,
					"value": 1
				}, {
					"name": "name",
					"type": 12,
					"value": "GTS"
				}, {
					"name": "since",
					"type": 12,
					"value": "2014"
				}]
			}],
			"tableName": "product"
		},
		"beforeImage": {
			"rows": [{
				"fields": [{
					"name": "id",
					"type": 4,
					"value": 1
				}, {
					"name": "name",
					"type": 12,
					"value": "TXC"
				}, {
					"name": "since",
					"type": 12,
					"value": "2014"
				}]
			}],
			"tableName": "product"
		},
		"sqlType": "UPDATE"
	}],
	"xid": "xid:xxx"
}
  1. 提交前,向 TC 注册分支:申请 product 表中,主键值等于 1 的记录的 全局锁
  2. 本地事务提交:业务数据的更新和前面步骤中生成的 UNDO LOG 一并提交。
  3. 将本地事务提交的结果上报给 TC。
二阶段-回滚
  1. 收到 TC 的分支回滚请求,开启一个本地事务,执行如下操作。
  2. 通过 XID 和 Branch ID 查找到相应的 UNDO LOG 记录。
  3. 数据校验:拿 UNDO LOG 中的后镜与当前数据进行比较,如果有不同,说明数据被当前全局事务之外的动作做了修改。这种情况,需要根据配置策略来做处理,详细的说明在另外的文档中介绍。
  4. 根据 UNDO LOG 中的前镜像和业务 SQL 的相关信息生成并执行回滚的语句:
    update product set name = ‘TXC’ where id = 1;
  5. 提交本地事务。并把本地事务的执行结果(即分支事务回滚的结果)上报给 TC。
二阶段-提交
  1. 收到 TC 的分支提交请求,把请求放入一个异步任务的队列中,马上返回提交成功的结果给 TC。释放全局锁。
  2. 异步任务阶段的分支提交请求将异步和批量地删除相应 UNDO LOG 记录。

Seata全局锁(分布式事务的隔离性)

A、B向两个节点提交了同一个账户的订
单信息,在单机服务器时候,我们通过读写锁避免事务的冲突,而分布式就需要一个全局锁。

本地事务提交前必须先向服务端注册分支,分支注册信息中包含由表名和行主键组成的全局锁,如果分支注册过程中发现全局锁正在被其他全局事务锁定则抛出全局锁冲突异常,客户端需要循环等待,直到其他全局事务释放锁之后该本地事务才能提交。如果此事务需要回滚,发现此时本地锁在别的事务身上,而这个别的事务在不断的尝试获得全局锁,以便实现一阶段提交,但是尝试失败后便会回滚事务并且释放本地锁,这时候此事务就能够回滚了。因为虽然一阶段提交会释放本地锁,但是全局锁一直是这个事务所持有的,所以别的事务也就做做完,连一阶段提交都不能。Seata 以这样的机制保证全局事务间的写隔离。

全局锁的释放
由于二阶段提交是异步进行的,当服务端向客户端发送 branch commit 请求后,客户端仅仅是将分支提交信息插入内存队列即返回,服务端只要判断这个流程没有异常就会释放全局锁。因此,可以说如果一阶段成功则在二阶段一开始就会释放全局锁,不会锁定到二阶段提交流程结束。
但是如果一阶段失败二阶段进行回滚,则由于回滚是同步进行的,全局锁直到二阶段回滚完成才会被释放。

引用

分布式事务
分布式事务
seata
阅读此人博客
官方文档
seata分布式事务源码解析(目录)
敖丙也有一篇文章,结合书
2021.10.19完成分布式事物的学习

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值