目录
一、背景
1.1、本地事务的基本概念
1.2、本地事务的基本特性
1.3、为什么需要分布式事务?
二、分布式事务常见解决方案
2.1、两阶段提交(2PC)
2.1.1、2PC实现原理
准备阶段(Prepare phase)
提交阶段(Commit phase)
场景枚举
2.2.2、2PC存在的问题
2.2、三阶段提交(3PC)
2.2.1、3PC实现原理
CanCommit 阶段
PreCommit 阶段
DoCommit 阶段
2.2.2、实现流程图解
2.2.3、3PC的缺点
2.3、柔性事务TCC
2.3.1、为什么会出现TCC?
2.3.2、TCC的实现原理
2.3.3、实现案例
2.3.4、存在的优势及不足
2.3.5、注意事项
业务操作分两阶段完成
允许空回滚
防悬挂控制
幂等控制
业务数据可见性控制
业务数据并发访问控制
2.4、基于消息队列的异步模型
2.4.1、业务方提供操作成功回查功能
2.4.2、本地消息表
三、参考
事务就是一个程序执行单元,里面的操作要么全部执行成功,要么全部执行失败,不允许只成功一半另外一半执行失败的事情发生。例如一段事务代码做了两次数据库更新操作,那么这两次数据库操作要么全部执行成功,要么全部回滚。
Atomicity(原子性)
:一个事务(transaction)中的所有操作,要么全部完成,要么全部不完成,不会结束在中间某个环节。事务在执行过程中发生错误,会被恢复(Rollback)到事务开始前的状态,就像这个事务从来没有执行过一样。
Consistency(一致性)
:在事务开始之前和事务结束以后,数据库的完整性没有被破坏。这表示写入的资料必须完全符合所有的预设规则,这包含资料的精确度、串联性以及后续数据库可以自发性地完成预定的工作。
Isolation(隔离性)
:数据库允许多个并发事务同时对其数据进行读写和修改的能力,隔离性可以防止多个事务并发执行时由于交叉执行而导致数据的不一致。事务隔离分为不同级别,包括读未提交(Read uncommitted)、读提交(read committed)、可重复读(repeatable read)和串行化(Serializable)。
Durability(持久性)
:事务处理结束后,对数据的修改就是永久的,即便系统故障也不会丢失
其实分布式事务从实质上看与数据库事务的概念是一致的,既然是事务也就需要满足事务的基本特性(ACID),只是分布式事务相对于本地事务而言其表现形式有很大的不同。
本地事务的时代,如果需要同时操作数据库的多条记录,而这些操作可以放到一个事务中,那么我们可以通过数据库提供的事务机制就可以实现。
而随着微服务架构的推进,原本一个本地逻辑执行单元,被拆分到了多个独立的微服务中,这些微服务又分别操作了不同的数据库和表。
分布式事务是为了解决微服务架构(形式都是分布式系统)中不同节点之间的数据一致性问题。这个一致性问题本质上解决的也是传统事务需要解决的问题,即一个请求在多个微服务调用链中,所有服务的数据处理要么全部成功,要么全部回滚。当然分布式事务问题的形式可能与传统事务会有比较大的差异,但是问题本质是一致的,都是要求解决数据的一致性问题。
而分布式事务的实现方式有很多种,接下来我们分别来介绍下这几种分布式事务实现方式的原理。
两阶段提交又称2PC(two-phase commit protocol),2PC是一个非常经典的强一致、中心化的原子提交协议。这里所说的中心化是指协议中有两个角色:一个是分布式事务协调者(coordinator)和N个参与者(participant)。
两阶段提交,顾名思义就是要进行两个阶段的提交:第一阶段,准备阶段(投票阶段);第二阶段,提交阶段(执行阶段)。
分布式事务的发起方,向分布式事务协调者(事务管理器TransactionManager)发送请求,
TransactionManager分别向参与者(Participant)A、参与者(Participant)B分别发送事务预处理请求,称之为Prepare,有些资料也叫”Vote Request”。
此时这些参与者节点一般来说就会打开本地数据库事务,然后开始执行数据库本地事务,每个数据库参与者在本地执行事务并写本地的Undo/Redo日志,但在执行完成后并不会立马提交数据库本地事务,而是先向TransactionManager进行“Vote Commit”的反馈,告知处理结果。
如果所有的参与者都向协调者做了“Vote Commit”的反馈的话,那么流程进入第二个阶段。
成功情况
如果所有参与者均反馈的是成功,协调者就会向所有参与者发送“全局提交确认通知(global_commit)”,参与者Participant就会完成自身本地数据库事务的提交,并将提交结果回复“ack”消息给协调者TransactionManager,然后协调者TransactionManager就会向调用方返回分布式事务处理完成的结果。如果有任何一个参与者返回失败,则回滚事务。
失败情况
如果参与者向协调者反馈“Vote_Abort”消息,即返回了失败的消息。此时分布式事务协调者Coordinator就会向所有的参与者Participant发起事务回滚的消息(“global_rollback”),此时各个参与者就会回滚本地事务,释放资源,并且向协调者发送“ack”确认消息,协调者就会向调用方返回分布式事务处理失败的结果。
以上就是两阶段提交的基本过程了,那么按照这个两阶段提交协议,分布式系统的数据一致性问题就能解决么?
其实,2PC只是通过增加了事务协调者(Coordinator)的角色来通过2个阶段的处理流程来解决分布式系统中一个事务需要跨多个服务的数据一致性问题。
以下几点是XA-两阶段提交协议中会遇到的一些问题:
性能问题:2PC中的所有的参与者节点都为事务阻塞型,当某一个参与者节点出现通信超时,其余参与者都会被动阻塞占用资源不能释放。
协调者单点故障问题:由于严重的依赖协调者,一旦协调者发生故障,而此时参与者还都处于锁定资源的状态,无法完成事务commit操作。虽然协调者出现故障后,会重新选举一个协调者,可无法解决因前一个协调者宕机导致的参与者处于阻塞状态的问题。
网络闪断导致脑裂:第二阶段中协调者向参与者发送commit命令之后,一旦此时发生网络抖动,导致一部分参与者接收到了commit请求并执行,可其他未接到commit请求的参与者无法执行事务提交。进而导致整个分布式系统出现了数据不一致。
三阶段提交又称3PC,在2PC的基础上增加了CanCommit阶段,并引入了超时机制。一旦事务参与者迟迟没有收到协调者的Commit请求,就会自动进行本地commit,这样相对有效地解决了协调者单点故障的问题。
协调者向参与者发出CanCommit ,进行事务询问操作,所有参与者都反馈yes后,才能进入下一个阶段。(这一个阶段时不锁表,不像2pc 第一个阶段就开始锁表,3pc的阶段一是为了先排除个别参与者不具备提交事务能力的前提下,而避免锁表。)简单来说就是检查下自身状态的健康性。
有任何一个参与者反馈的结果是No,整个分布式事务就会中断,协调者就会向所有的参与者发送“abort”请求。
在阶段一中,如果所有的参与者都返回Yes的话,那么就会进入PreCommit阶段进行事务预提交。此时分布式事务协调者会向所有的参与者发送PreCommit请求,参与者收到后开始执行事务操作,并将Undo和Redo信息记录到事务日志中。参与者执行完事务操作后(此时属于未提交事务的状态),就会向协调者反馈“Ack”表示已经准备好提交,并等待协调者的下一步指令。
有任何一个参与者反馈的结果是No,或协调者在等待参与者节点反馈的过程中超时(2PC中只有协调者可以超时,参与者没有超时机制)。整个分布式事务就会中断,协调者就会向所有的参与者发送“abort”请求。
在阶段二中如果所有的参与者都可以进行PreCommit提交,那么协调者就会从“预提交状态”->“提交状态”。然后向所有的参与者发送”doCommit”请求,参与者在收到提交请求后,执行事务提交操作,并向协调者反馈“Ack”消息,协调者收到所有参与者的Ack消息后完成事务。
同样,如果有一个参与者节点未完成PreCommit的反馈或者反馈超时,那么协调者都会向所有的参与者节点发送abort请求,从而中断事务。
相比较2PC而言,3PC对于协调者(Coordinator)和参与者(Participant)都设置了超时时间,解决了参与者在长时间无法与协调者节点通讯(协调者挂掉了)的情况下,无法释放资源的问题,因为参与者自身拥有超时机制会在超时后,自动进行本地commit从而进行释放资源。而这种机制也侧面降低了整个事务的阻塞时间和范围。
另外,通过CanCommit、PreCommit、DoCommit三个阶段的设计,相较于2PC而言,多设置了一个缓冲阶段保证了在最后提交阶段之前各参与节点的状态是一致的。
3PC在去除阻塞的同时也引入了新的问题,那就是参与者接收到precommit消息后,如果出现网络分区,此时协调者所在的节点和参与者无法进行正常的网络通信,在这种情况下,该参与者依然会进行事务的提交,这必然出现数据的不一致性。
无论是 2PC 还是 3PC,都存在一个大粒度资源锁定的问题。我们先来想象这样一种场景,用户在电商网站购买商品1000元,使用余额支付800元,使用红包支付200元。
我们看一下在 2PC 中的流程:
prepare 阶段:
下单系统插入一条订单记录,不提交
余额系统减 800 元,给记录加锁,写 redo 和 undo 日志,不提交
红包系统减 200 元,给记录加锁,写 redo 和 undo 日志,不提交
commit 阶段:
下单系统提交订单记录
余额系统提交,释放锁
红包系统提交,释放锁
为什么说这是一种大粒度的资源锁定呢?
因为在 prepare 阶段,当数据库给用户余额减 800 元之后,为了维持隔离性,会给该条记录加锁,在事务提交前,其它事务无法再访问该条记录。
但实际上,我们只需要预留其中的 800 元,不需要锁定整个用户余额。这是 2PC 和 3PC 的局限,因为这两者是资源层的协议,无法提供更灵活的资源锁定操作。
为了解决这个问题,TCC 应运而生。TCC 本质上也是一个二阶段提交协议,但和 JTA 中的二阶段协议不同的是,它是一个服务层的协议,因此开发者可以根据业务自由控制资源锁定的粒度。
TCC 将事务的提交过程分为 try-confirm-cancel(实际上 TCC 就是 try、confirm、cancel 的简称) 三个阶段:
try:完成业务检查、预留业务资源
confirm:使用预留的资源执行业务操作(需要保证幂等性)
cancel:取消执行业务操作,释放预留的资源(需要保证幂等性)
流程如下:
事务发起方向事务协调器发起事务请求,事务协调器调用所有事务参与者的 try 方法完成资源的预留,这时候并没有真正执行业务,而是为后面具体要执行的业务预留资源,这里完成了一阶段。
如果事务协调器发现有参与者的 try 方法预留资源时候发现资源不够,则调用参与方的 cancel 方法回滚预留的资源,需要注意 cancel 方法需要实现业务幂等,因为有可能调用失败(比如网络原因参与者接受到了请求,但是由于网络原因事务协调器没有接受到回执)会重试。
如果事务协调器发现所有参与者的 try 方法返回都 OK,则事务协调器调用所有参与者的 confirm 方法,不做资源检查,直接进行具体的业务操作。
如果协调器发现所有参与者的 confirm 方法都 OK 了,则分布式事务结束。
如果协调器发现有些参与者的 confirm 方法失败了,或者由于网络原因没有收到回执,则协调器会进行重试。这里如果重试一定次数后还是失败,常见的是做事务补偿。
通过一个支付场景看看 TCC 在该场景中的流程:
Try 操作
tryX 下单系统创建待支付订单
tryY 冻结账户红包 200 元
tryZ 冻结资金账户 800 元
Confirm 操作
confirmX 订单更新为支付成功
confirmY 扣减账户红包 200 元
confirmZ 扣减资金账户 800 元
Cancel 操作
cancelX 订单处理异常,资金红包退回,订单支付失败
cancelY 冻结红包失败,账户余额退回,订单支付失败
cancelZ 冻结余额失败,账户红包退回,订单支付失败
可以看到,我们使用了冻结代替了原先的账号锁定(实际操作中,冻结操作可以用数据库减操作+日志实现),这样在冻结操作之后,事务提交之前,其它事务也能使用账户余额。
这种分布式事务的实现方式的优势在于,可以让应用自己定义数据库操作的粒度,使得降低锁冲突、提高吞吐量成为可能。
而不足之处则在于对应用的侵入性非常强,业务逻辑的每个分支都需要实现try、confirm、cancel三个操作。
// 改动前
// 修改代码状态
orderClient.updateStatus();
// 改动后
orderClient.tryUpateStatus();
orderClient.confirmUpateStatus();
orderClient.cancelUpateStatus();
此外,其实现难度也比较大,需要按照网络状态、系统故障等不同的失败原因实现不同的回滚策略。为了满足一致性的要求,confirm和cancel接口还必须实现幂等。
接入TCC前,业务操作只需要一步就能完成,但是在接入TCC之后,需要考虑如何将其分成2阶段完成,把资源的检查和预留放在一阶段的Try操作中进行,把真正的业务操作的执行放在二阶段的Confirm操作中进行;
TCC服务要保证第一阶段Try操作成功之后,二阶段Confirm操作一定能成功;
事务协调器在调用TCC服务的一阶段Try操作时,可能会出现因为丢包而导致的网络超时,此时事务协调器会触发二阶段回滚,调用TCC服务的Cancel操作。
TCC服务在未收到Try请求的情况下收到Cancel请求,这种场景被称为空回滚;TCC服务在实现时应当允许空回滚的执行。
事务协调器在调用TCC服务的一阶段Try操作时,可能会出现因网络拥堵而导致的超时,此时事务协调器会触发二阶段回滚,调用TCC服务的Cancel操作;在此之后,拥堵在网络上的一阶段Try数据包被TCC服务收到,出现了二阶段Cancel请求比一阶段Try请求先执行的情况;
用户在实现TCC服务时,应当允许空回滚,但是要拒绝执行空回滚之后到来的一阶段Try请求;
无论是网络数据包重传,还是异常事务的补偿执行,都会导致TCC服务的Try、Confirm或者Cancel操作被重复执行;用户在实现TCC服务时,需要考虑幂等控制,即Try、Confirm、Cancel 执行次和执行多次的业务结果是一样的。
TCC服务的一阶段Try操作会做资源的预留,在二阶段操作执行之前,如果其他事务需要读取被预留的资源数据,那么处于中间状态的业务数据该如何向用户展示,需要业务在实现时考虑清楚;通常的设计原则是“宁可不展示、少展示,也不多展示、错展示”。
TCC服务的一阶段Try操作预留资源之后,在二阶段操作执行之前,预留的资源都不会被释放;如果此时其他分布式事务修改这些业务资源,会出现分布式事务的并发问题。
用户在实现TCC服务时,需要考虑业务数据的并发控制,尽量将逻辑锁粒度降到最低,以最大限度的提高分布式事务的并发性。
基于消息队列的异步模型指的是在核心业务执行完成后,同步的对外发出一个消息,供其他模块消费使用后执行各自的业务。
例:电商的下单操作,假如其他的验证都已经完成,满足下单的情况下,给订单表写入订单信息后,系统生成了订单号,订单模块知道把带订单号的订单信息对外发出一个消息(MQ),下单操作就算完成。订单模块发出的MQ供后续的库存扣减,财务收款,仓库生产等业务使用,这里的库存扣减,财务收款和仓库生产属于不同的模块,各自的操作有自己的事务,这就实现了把一个大事务拆分为多个小事务,各自去执行,互不影响,提升系统性能的目的。
目前基于消息队列的异步模型,其实现方式有多种,在这里我列出两种进行介绍,一种是业务方提供本地操作成功回查功能,一种是基于本地事务消息表。
整体的流程描述:本地事务执行前,把需要发送的mq消息先发送到MQ服务器上,但该消息属于不能投递的消息,需要有标识标明。当MQ发送方收到MQ服务器返回的收到发送的mq消息的确认后,执行本地事务,根据本地事务的执行结果再向MQ的服务器发送该消息的本地事务执行结果,告诉MQ服务器是把mq消息投递到消费方还是把mq消息回滚丢弃。当MQ服务端超时未收到MQ发送端对mq消息的处理通知(Commit OR Rollback)时,MQ服务端会向MQ发送方进行查询该mq消息的事务状态,以确定该mq消息的处理结果是投递还是丢弃。整体的操作流程图如下:
该方案需要业务方针对每一个事务提供一个回查接口给MQ服务端;同时,MQ服务端还需要有定时任务来检查未投递的消息,并计算消息是否已经超时,如果已经超时需要回查业务方提供的回查事务状态接口以便确认对该消息的处理;该方案对业务的侵入比较大,不利于方案的扩展。
目前支持该模式的消息队列有Apache RocketMQ。
可参考:【RocketMQ】揭开事务消息的神秘面纱
该方案指的是主业务数据表和待发送的消息存储表共存于同一个数据库,便于数据写入的时候由一个事务保证,数据写入成功后,事务即成功。此时,需要一个任务来执行对消息表里的消息进行发送到MQ,消息发送MQ成功后,删除消息表里的消息,达到异步处理的目的。比如:还是下单操作,下单完成后,需要通知库存扣减库存,财务进行收款,仓库进行发货等操作,设计时,可以把订单表与订单消息表共同存储到一个数据库里,当订单表写入的时候也同步写订单消息表,2个表同时写成功后,事务才算成功。然后再通过一个任务来查询订单消息表把订单消息表里的消息发送到MQ,当收到MQ的成功回执消息后,再把消息表里的消息进行删除。库存扣减、财务收款、仓库发货等均消费MQ里的该消息来完成各自的业务操作。
该方式的操作流程模型如下:
在该方案模型中,当MQ发送方把消息发送后,长时间未收到MQ服务端的结果回执,此时需要再次发送该消息,这导致消息的发送可能会重复多次发送,需要消费方保证处理消息的接口幂等;该方案的优点是对业务的侵入小,核心业务只需要关注把自己业务做完的同时把需要发送的消息写入消息表即可。该方案也是在企业生产中落地实现用的比较多的方案之一。
京东物流技术团队|分布式事务,你了解多少?
阿里云|分布式事务之TCC服务设计和实现注意事项
京东云开发者|浅谈分布式事务及解决方案
看了 5种分布式事务方案,我司最终选择了 Seata,真香!