分布式事务模型与常见解决方案
1. 背景
首先抛出一个问题,如果在一台机器上,数据库是如何解决事务问题的?很容易想到,数据库的ACID四个特性来保证的,原子性、一致性、隔离性和持久性。
- 原子性(Atomicity):一个事务内的所有操作看成一个原子操作,要么全部执行,要么都不执行。
- 一致性(Consistency): 指在事务开始之前和事务结束以后,数据满足完整性约束,比如A、B两人各有一千元,无论怎么转账,两人最终余额加起来的总额保持2000不变。
- 隔离性(Isolation):指多个事务并发执行,互不干扰,也不影响最终结果。
- 持久性(Durability):指的是一个事务完成了之后数据就被永远保存下来。
拿mysql为例,数据先写日志文件,后写数据文件,如果写日志文件成功,并提交,发现数据文件没有,就做redo log,随后做redo操作让数据刷盘;如果日志文件没提交,需要写undo log,用于回滚,AD特性是日志文件保证的,CI特性是锁保证的。
而在分布式系统中,事务是由多个系统对应的多个数据库组成,涉及跨系统与跨库操作,本地数据库事务无能为力,需要引入分布式事务来保持数据的一致性。
下面给出本篇要讨论的分布式事务的概览,会重点分析几种常见的实现方案与原理。
2. 二阶段模型
引入原因
两阶段也叫做2pc
,在分布式系统中,每个系统节点能感知自己的服务成功与失败,但是无法感知其他节点的服务是否成功,就需要引入一个协调者
来掌控所有节点的操作结果,这些节点叫做参与者
,协调者控制所有节点的逻辑满足ACID特性。
举个例子来说,电商场景中,支付系统支付完成后,调用订单系统更新订单状态。事务管理器Transaction Manager
简称TM
,充当协调者角色,Resource Manager
简称RS
,充当参与者角色。
流程
第一阶段:预提交阶段
- 预提交请求:协调者会询问所有的参与者结点,是否可以执行提交操作;
- 锁定资源:各个参与者开始事务执行的准备工作,如为资源上锁,预留资源,写undo/redo log……
- 返回响应:参与者响应协调者,如果事务的准备工作成功,返回yes,否则返回no。
第二阶段:执行事务提交
- 正式提交请求:当协调者收到的所有节点都返回yes时,协调者向所有参与者节点发送提交请求;
- 节点事务提交:参与者正式完成提交操作,并释放整个事务期间占用的资源;
- 返回响应:参与者向协调者返回事务完成结果;
- 事务完成:协调者收到所有节点返回yes后,完成事务。
以上是两阶段的正常流程,如果参与者在第一阶段返回no,或者TM在第一阶段询问请求超时,无法获得响应结果,事务就会中断事务,并向所有参与者节点发起回滚请求,参与者节点利用之前写的undo日志进行回滚,并释放所占资源;TM收到所有回滚完成结果后,取消事务。
二阶段思想采用的是先投票(vote),后执行(do commit),典型的例子就是西式教堂里的结婚场景,牧师询问新郎新娘,你是否愿意.....,当各自回答愿意后(锁定一生资源),牧师会宣布:宣布你们正式成为夫妻,......,正式结婚(事务提交)。
二阶段的问题
- 同步阻塞锁资源:二阶段是一种尽量保证强一致性的分布式事务,同步阻塞导致长时间资源锁定,性能低。
- 超时:如果第一阶段中,RM没有收到询问请求,导致请求超时,或是TM没有收到响应,导致响应超时;导致此场景的原因要么TM故障,要么节点故障导致。
- 数据不一致:如果TM单点故障,在第一阶段没问题,在第二阶段,有些节点提交完了,有些还没提交故障,导致数据不一致。
seata实现
seata阿里开源的分布式事务,默认方式实现两阶段提交,流程如下:
- 服务A向seata server注册全局事务id,开启全局事务,管理分支事务,同时将正逆操作绑定在一个本地事务里,undo_log表存放逆向操作数据;
- 服务B与C跟A一样,执行自己的分支事务,分支事务成功后提交,失败后回滚,并通知Seata Server,
- 全部分支成功后,全局事务提交,事务执行成功,如果有一个分支事务执行失败,Seata Server通知已成功的分支事务执行逆向操作回滚
mysql实现
mysql的redo log就是两阶段提交的典型例子,因为binlog属于逻辑日志,redo log属于物理日志,redo log日志保证修改的数据不丢失,可以基于日志恢复,而binlog记录了做了哪些sql操作,两个日志同时保证数据一致性。
从图中可看出,事务的提交过程有两个阶段,就是将 redo log 的写入拆成了两个步骤:prepare 和 commit,中间再穿插写入binlog,具体如下:
prepare 阶段
:将 XID(内部 XA 事务的 ID) 写入到 redo log,同时将 redo log 对应的事务状态设置为 prepare,然后将 redo log 持久化到磁盘;
commit 阶段
:把 XID 写入到 binlog,然后将 binlog 持久化到磁盘,接着调用引擎的提交事务接口,将 redo log 状态设置为 commit,此时该状态并不需要持久化到磁盘,只需要 write 到文件系统的 page cache 中就够了,因为只要 binlog 写磁盘成功,就算 redo log 的状态还是 prepare 也没有关系,一样会被认为事务已经执行成功。
通过这种两阶段提交的方案,就能够确保redo-log、bin-log两者的日志数据是相同的。
3. 三阶段模型
为了降低二阶段问题发生的概率,引入了三阶段模型,也叫3PC
。三阶段在二阶段之前加入了询问环节,询问不锁定资源
。3PC
包含了三个阶段,分别是准备阶段、预提交阶段和提交阶段,对应的英文就是:CanCommit、PreCommit 和 DoCommit。
三阶段的改善
- 增加了询问环节,减少资源锁定的时间。
- 引入了超时机制,减少资源锁定概率。当TM未收到响应,给RM发中断事务命令;当RM在三阶段没收到do commit请求,RM自动提交。
- 通过超时对应的处理机制,增加了数据一致性的概率。
4. 基于消息队列、定时任务与本地事件表的方案
在分布式系统中,数据的状态在多个系统中流转,通过消息队列、定时任务与本地事件表可以有效的处理分布式事务的数据一致性。假设有如下场景,有支付系统与订单系统两个子系统,具体流程如下:
step1
: 支付系统通过第三方回调完成本地支付流水状态更新,同时在事件表中新增一条记录;
step2
: 定时任务扫描事件表里的新增记录,将事件表里的支付流水状态更新为已发送,然后发送一条消息到消息中间件,如果发送失败,本地事务可以回滚;
step3
: 订单系统中的消费者监听消息,将消息与对应的状态插入事件表;
step4
:定时任务读取本地事务表,执行本地业务,更新订单表状态,最后将事件表的状态再更新为终态。
优点
- 扩张性强:如果后续要通知积分系统、仓库系统、物流系统等,可以直接通过消息中间件解耦,订单系统只需完成步骤一的操作即可。
- 保证幂等性:发送消息,可以用事件id、事件类型、事件内容组成的数据报文发送,保证每一个事件id在数据库中只插入一次,如果有重复处理,数据库抛异常,本地事务将回滚。
缺点
- 不适合用在数据量太大的场景,如果数据量太大,频繁插入对数据库性能要求较高;
- 事件表数据会越来越多,已处理完的数据需要考虑迁移到历史库,分离冷热数据。
注意事项
生产环境中,如果多台机器部署的话,需要考虑分布式定时任务,或者定时任务配合分布式锁来操作,保证同一时刻只有一条记录被定时扫描并执行。
5. LCN方案
背景
LCN框架在2017年6月份发布第一个版本,从开始的1.0,已经发展到了5.0版本。
LCN名称是由早期版本的LCN框架命名,在设计框架之初的1.0 ~ 2.0的版本时框架设计的步骤是如下,各取其首字母得来的LCN命名。
LCN全称分别对应如下解释:
锁定事务单元(lock)
确认事务模块状态(confirm)
通知事务(notify)
5.0以后由于框架兼容了LCN、TCC、TXC三种事务模式,为了避免区分LCN模式,特此将LCN分布式事务改名为TX-LCN分布式事务框架。
TX-LCN由两大模块组成, TxClient、TxManager,TxClient作为模块的依赖框架,提供TX-LCN的标准支持,TxManager作为分布式事务的控制方。事务发起方或者参与反都由TxClient端来控制。
核心步骤
- 创建事务组
是指在事务发起方开始执行业务代码之前先调用TxManager创建事务组对象,然后拿到事务标示GroupId的过程。
- 加入事务组
添加事务组是指参与方在执行完业务方法以后,将该模块的事务信息通知给TxManager的操作。
- 通知事务组
是指在发起方执行完业务代码以后,将发起方执行结果状态通知给TxManager,TxManager将根据事务最终状态和事务组的信息来通知相应的参与模块提交或回滚事务,并返回结果给事务发起方。
LCN事务模式
LCN模式是通过代理Connection的方式实现对本地事务的操作,然后在由TxManager统一协调控制事务。当本地事务提交回滚或者关闭连接时将会执行假操作,该代理的连接将由LCN连接池管理。
所以该模式的本质是:TM代理了数据源机制,保持了请求与连接的对应关系。RM假释放资源
,LCN并不生产事务,LCN只是本地事务的协调工。
如下图:
假设服务已经执行到关闭事务组的过程,那么接下来作为一个模块执行通知给TxManager,然后告诉他本次事务已经完成。
那么如图中Txmanager 下一个动作就是通过事务组的id,获取到本次事务组的事务信息;然后查看一下对应有那几个模块参与,如果是有A/B/C 三个模块;
那么对应的对三个模块做通知:提交、回滚。
那么提交的时候是提交给谁呢?
是提交给了我们的TxClient 模块。然后TxCliient 模块下有一个连接池,就是框架自定义的一个连接池(如图DB 连接池);这个连接池其实就是在没有通知事务之前一直占有着这次事务的连接资源,就是没有释放。但是他在切面里面执行了close 方法。在执行close的时候。
如果需要(TxManager)分布式事务框架的连接。他被叫做假关闭
,也就是没有关闭,只是在执行了一次关闭方法。实际的资源是没有释放的。这个资源是掌握在LCN 的连接池里的。
当TxManager 通知提交或事务回滚的时候呢?
TxManager 会通知我们的TxClient 端。然后TxClient 会去执行相应的提交或回滚。
提交或回滚之后再去关闭连接。这就是LCN 的事务协调机制。说白了就是代理DataSource 的机制;相当于是拦截了一下连接池,控制了连接池的事务提交。
特点:
- 该模式对代码的嵌入性为低。
- 该模式仅限于本地存在连接对象且可通过连接对象控制事务的模块。
- 该模式下的事务提交与回滚是由本地事务方控制,对于数据一致性上有较高的保障。
- 该模式缺陷在于代理的连接需要随事务发起方一共释放连接,增加了连接占用的时间。
TCC事务模式
TCC事务机制相对于传统事务机制(X/Open XA Two-Phase-Commit),其特征在于它不依赖资源管理器(RM)对XA的支持,而是通过对(由业务系统提供的)业务逻辑的调度来实现分布式事务。主要由三步操作,Try: 尝试执行业务、 Confirm:确认执行业务、 Cancel: 取消执行业务。
如图所示:
特点:
- 该模式对代码的嵌入性高,要求每个业务需要写三种步骤的操作。
- 该模式对有无本地事务控制都可以支持使用面广。
- 数据一致性控制几乎完全由开发者控制,对业务开发难度要求高。
6. TCC方案
TCC方案分为Try、Confirm、Cancel三个阶段,属于补偿性分布式事务。
Try:尝试待执行的业务
这个过程并未执行业务,只是完成所有业务的一致性检查,并预留好执行所需的全部资源;
Confirm:执行业务
这个过程真正开始执行业务,由于Try阶段已经完成了一致性检查,因此本过程直接执行,而不做任何检查。并且在执行的过程中,会使用到Try阶段预留的业务资源。
Cancel:取消执行的业务,如果任何一个服务的业务方法执行出错,那么这里就需要进行补偿。
这种TCC方案适用于一致性要求极高的系统中,比如金钱交易相关的系统中,不过可以看出,其基于补偿的原理,因此,需要编写大量的补偿事务的代码,比较冗余。不过现有开源的TCC框架,比如TCC-transaction。一般来说跟钱相关的,跟钱打交道的,支付、交易相关的场景,我们会用TCC,严格保证分布式事务要么全部成功,要么全部自动回滚,严格保证资金的正确性,保证在资金上不会出现问题。
7. 可靠消息最终一致性方案
本方案,干脆不用本地的消息表了,直接基于MQ 来实现事务。比如阿里的RocketMQ 就支持消息事务。
流程如下:
- A 系统先发送一个 prepared 消息到 mq,如果这个 prepared 消息发送失败那么就直接取消操作别执行了;
- 如果这个消息发送成功过了,那么接着执行本地事务,如果成功就告诉 mq 发送确认消息,如果失败就告诉 mq 回滚消息;
- 如果发送了确认消息,那么此时 B 系统会接收到确认消息,然后执行本地的事务;
- mq 会自动定时轮询所有 prepared 消息回调你的接口,问你,这个消息是不是本地事务处理失败了,所有没发送确认的消息,是继续重试还是回滚?一般来说这里你就可以查下数据库看之前本地事务是否执行,如果回滚了,那么这里也回滚吧。所以mq轮询,就是避免可能本地事务执行成功了,而确认消息却发送失败了。
- 这个方案里,要是系统 B 的事务失败了咋办?重试咯,自动不断重试直到成功,如果实在是不行,要么就是针对重要的资金类业务进行回滚,比如 B 系统本地回滚后,想办法通知系统 A 也回滚;或者是发送报警由人工来手工回滚和补偿。
上图是早期RocketMQ的实现,依赖zookeeper,因为RocketMQ想追求AP模型,实现高可用,并更轻量化,将zookeeper去掉了。
8. 最大努力型通知方案
1.系统 A 本地事务执行完之后,发送个消息到 MQ;
2.这里会有个专门消费 MQ 的最大努力通知服务,这个服务会消费 MQ 然后写入数据库中记录下来,或者是放入个内存队列也可以,接着调用系统 B 的接口;
3.要是系统 B 执行成功就 ok 了;要是系统 B 执行失败了,那么最大努力通知服务就定时尝试重新调用系统 B,反复 N 次,最后还是不行就放弃。