前言
分布式事务指事务的操作位于不同的节点上,需要保证事务的 AICD 特性。
如在微服务系统中,如果一个业务需要使用到不同的微服务,并且不同的微服务对应不同的数据库。
例如在下单场景下,库存和订单如果不在同一个节点上,就涉及分布式事务。
这个业务逻辑涉及到两个微服务,一个是库存服务(库存减一),另一个是订单服务(订单数加一),
如果在执行这个业务逻辑时没有使用分布式事务,当库存与订单其中一个出现故障时,就很可能出现这样的情况:库存数据库的值减少了1,但是订单数据库没有变化;或是库存没变化,多了一个订单,也就是出现了数据不一致现象。
所以在类似的场合下我们要使用分布式事务,保证数据的一致性。
1. 分布式事务的解决思路
引入mysql中的两阶段提交策略
在谈分布式事务的解决思路之前,我们先来看看单一数据源是如何做事务处理的,我们可以从中获取一些启发。
以mysql的InnoDB引擎为例,由于mysql中有两套日志机制,一套是存储层的redo log,另一套是server层的binlog,每次更新数据都要对两个日志进行更新。为了防止写日志时只写了其中一个而没有写另外一个,mysql使用了一个叫两阶段提交的方式保证事务的一致性。具体是这样的:
假设创建一个这样的数据库:
1 | mysql> create table haoran(ID int primary key, c int); |
然后执行一条这样的更新语句:
1 | mysql> update haoran set c = c+1 where ID =2; |
这条更新语句的执行流程是这样子的:
首先执行器会找引擎取ID=2这一行数据
拿到数据后会把数据进行+1操作,然后调用引擎接口把新数据写入
引擎将数据更新到内存中,并将操作记录到redo log里,此时redo log处于prepare状态。但它不会提交事务,只是通知执行器已经完成任务,可以随时提交。
执行器生成这个操作的binlog,并把binlog写入磁盘
最后执行器调用引擎的事务接口,把redo log改为提交状态,更新完成。
上述过程中,redo log写完后没有直接提交,而是处于prepare状态,等通知执行器并把binlog写完后,redo log再进行提交。这个过程就是两阶段提交.
可能你会问为什么要有两阶段提交?如果不采用两阶段提交的话,也就是使用一阶段提交,那就相当于按顺序执行写redo log和binlog,如果写完redo log 后系统出现了故障,那么就会只有redo log记录了操作,binlog没有记录,造成数据不一致;使用两阶段提交的话,假设写完redo log后系统出现了故障,由于事务还没有提交,所以可以顺利回滚。
两阶段提交的设计还有什么好处?首先要奠定一个概念:一个操作执行的时间越长,这个操作就越有可能失败。打个比方,你吃饭要用20分钟,上厕所要用1分钟,在吃饭的过程中收到微信消息的概率肯定比去上厕所的过程中收到微信消息的概率大。由于在数据库中更新操作的时间要远大于提交事务的时间,所以先把更新操作做完,等所有耗时操作都做完最后再提交事务,能够最大程度保证事务执行成功。
2. 分布式事务常用解决方案
1. 两阶段提交(2PC)
两阶段提交(Two-phase Commit,2PC),通过引入协调者(Coordinator 事务管理器) 决定这些参与者是否要真正执行事务。
1. 准备阶段
协调者询问参与者事务是否执行成功,参与者发回事务执行结果。
2. 提交阶段
如果事务在每个参与者上都执行成功,事务协调者发送通知让参与者提交事务;否则,协调者发送通知让参与者回滚事务。
需要注意的是,在准备阶段,参与者执行了事务,但是还未提交。只有在提交阶段接收到协调者发来的通知后,才进行提交或者回滚。
3. 举例
在第一阶段,新增一个事务管理者的角色,通过它来协调各个数据源。还是拿开头的订单案例讲解,在执行下订单的逻辑时,先让各个数据库去执行各自的事务,比如从库存中减1,在订单库中加1,但是完成后不提交,只是通知事务管理者已经完成了任务。
到了第二阶段,由于在阶段一我们已经收到了各个数据源是否就绪的信息,只要有一个数据源没有就绪,在第二阶段就通知所有数据源回滚;如果全部数据源都已经就绪,就通知所有数据源提交事务。
总结一下这个两阶段提交的过程就是:首先事务管理器通知各个数据源进行操作,并返回是否准备好的信息。等所有数据源都准备好后,再统一发送事务提交(回滚)的通知让各个数据源提交事务。由于最后的提交操作耗时极短,所以操作失败的可能性会很低。
4.问题
同步阻塞 所有事务参与者在等待其它参与者响应的时候都处于同步阻塞状态,无法进行其它操作。(可用倒计时等操作解决)
单点问题 协调者在 2PC 中起到非常大的作用,发生故障将会造成很大影响。特别是在阶段二发生故障,所有参与者会一直等待状态,无法完成其它操作。
- 数据不一致 在阶段二,如果协调者只发送了部分 Commit 消息,此时网络发生异常,那么只有部分参与者接收到 Commit 消息,也就是说只有部分参与者提交了事务,使得系统数据不一致。
太过保守 任意一个节点失败就会导致整个事务失败,没有完善的容错机制。
2. 补偿事务(TCC)
Try、Confirm、Cancel。
TCC 其实就是采用的补偿机制,其核心思想是:针对每个操作,都要注册一个与其对应的确认和补偿(撤销)操作。它分为三个阶段:
Try 阶段主要是对业务系统做检测及资源预留
Confirm 阶段主要是对业务系统做确认提交,Try阶段执行成功并开始执行 Confirm阶段时,默认 Confirm阶段是不会出错的。即:只要Try成功,Confirm一定成功。
Cancel 阶段主要是在业务执行错误,需要回滚的状态下执行的业务取消,预留资源释放。
举个例子,假入 Bob 要向 Smith 转账,思路大概是: 我们有一个本地方法,里面依次调用
- 首先在 Try 阶段,要先调用远程接口把 Smith 和 Bob 的钱给冻结起来。
- 在 Confirm 阶段,执行远程调用的转账的操作,转账成功进行解冻。
- 如果第2步执行成功,那么转账成功,如果第二步执行失败,则调用远程冻结接口对应的解冻方法 (Cancel)。
优点: 跟2PC比起来,实现以及流程相对简单了一些,但数据的一致性比2PC也要差一些
缺点: 缺点还是比较明显的,在2,3步中都有可能失败。TCC属于应用层的一种补偿方式,所以需要程序员在实现的时候多写很多补偿的代码,在一些场景中,一些业务流程可能用TCC不太好定义及处理。
3. 本地消息表(异步确保)
本地消息表与业务数据表处于同一个数据库中,这样就能利用本地事务来保证在对这两个表的操作满足事务特性,并且使用了消息队列来保证最终一致性。
- 在分布式事务操作的一方完成写业务数据的操作之后向本地消息表发送一个消息,本地事务能保证这个消息一定会被写入本地消息表中。
- 之后将本地消息表中的消息转发到 Kafka 等消息队列中,如果转发成功则将消息从本地消息表中删除,否则继续重新转发。
- 在分布式事务操作的另一方从消息队列中读取一个消息,并执行消息中的操作。
优点: 一种非常经典的实现,避免了分布式事务,实现了最终一致性。
缺点: 消息表会耦合到业务系统中,如果没有封装好的解决方案,会有很多杂活需要处理。
4. MQ 事务消息
有一些第三方的MQ是支持事务消息的,比如RocketMQ,他们支持事务消息的方式也是类似于采用的二阶段提交,但是市面上一些主流的MQ都是不支持事务消息的,比如 RabbitMQ 和 Kafka 都不支持。
以阿里的 RocketMQ 中间件为例(实际上其实是对本地消息表的一个封装,将本地消息表移动到了MQ内部),其思路大致为:
第一阶段Prepared消息,会拿到消息的地址
第二阶段执行本地事务
第三阶段通过第一阶段拿到的地址去访问消息,并修改状态。消息接受者就能使用这个消息。
也就是说在业务方法内要想消息队列提交两次请求,一次发送消息和一次确认消息。如果确认消息发送失败了RocketMQ会定期扫描消息集群中的事务消息,这时候发现了Prepared消息,它会向消息发送者确认,所以生产方需要实现一个check接口,RocketMQ会根据发送端设置的策略来决定是回滚还是继续发送确认消息。这样就保证了消息发送与本地事务同时成功或同时失败。
如果确认消息失败,在RocketMq Broker中提供了定时扫描没有更新状态的消息,如果有消息没有得到确认,会向消息发送者发送消息,来判断是否提交,在rocketmq中是以listener的形式给发送者,用来处理。
如果消费超时,则需要一直重试,消息接收端需要保证幂等。如果消息消费失败,这个就需要人工进行处理,因为这个概率较低,如果为了这种小概率时间而设计这个复杂的流程反而得不偿失
优点: 实现了最终一致性,不需要依赖本地数据库事务。
缺点: 实现难度大,主流MQ不支持,RocketMQ事务消息部分代码也未开源。
5. 分布式事务框TX-LCN
说了这么多理论知识,这里介绍一款真正应用在生产环境中的分布式事务框架TX-LCN的运行原理。(典型的分布式事务框架不止TX-LCN,比如还有阿里的GTS,不过GTS是收费的,TX-LCN是开源的)
官网:https://www.txlcn.org/zh-cn/index.html
官网操作文档:
https://www.txlcn.org/zh-cn/docs/preface.html
由于官网的下载地址打不开,我们去GitHub上面下载例子:https://github.com/codingapi/txlcn-demo
1.简介
TX-LCN分布式事务框架,LCN并不生产事务,LCN只是本地事务的协调工,LCN是一个高性能的分布式事务框架,兼容dubbo、springcloud框架,支持RPC框架拓展,支持各种ORM框架、NoSQL、负载均衡、事务补偿。
2. 特性
1、一致性,通过TxManager协调控制与事务补偿机制确保数据一致性
2、易用性,仅需要在业务方法上添加@TxTransaction注解即可
3、高可用,项目模块不仅可高可用部署,事务协调器也可集群化部署
4、扩展性,支持各种RPC框架扩展,支持通讯协议与事务模式扩展
3.核心处理流程
创建事务组:在事务发起方开始执行业务代码之前先调用TxManager创建事务组对象,然后拿到事务表示GroupId的过程。简单来说就是对这次下订单的操作在事务管理中心里创建一个对象,拿到一个id。
加入事务组:参与方在执行完业务方法后,将该模块的事务信息通知给TxManager的操作。也就是指各个数据源(各个服务)完成操作后,和事务管理中心说一声,注册一下自己。
通知事务组:发起方执行业务代码后,将发起方执行结果状态通知给TxManager,TxManager将根据事务最终状态和事务组的信息来通知相应的参与模块提交或回滚事务,并返回结果给事务发起方。和客户打交道的下订单服务会收到减库存和加订单是否成功消息,它会把这两个消息通知给事务管理者,事务管理者根据情况通知两个库存服务提交事务或回滚事务。
精彩源码:
1 | public Object runTransaction(DTXInfo dtxInfo, BusinessCallback business) throws Throwable { |
这段代码保证了每个模块下只会有一个TxContext,换个说法就是假设一个业务逻辑不是操作不同的数据源,而是对同一个数据源执行多次相同的操作,那么该数据源对应的模块在DTX下会只有一个TxContext。
LCN的事务协调机制:它不生产事务,那么它是怎么控制各个模块在完成事务的逻辑操作之后不马上提交,而是等到TxManager最后一起通知各模块提交的呢?
因为每个模块都是一个TxClient,每个TxClient下都有一个连接池,是框架自定义的连接池,对Connection使用静态代理的方式进行包装。
1 | public class LcnConnectionProxy implements Connection { |
连接池在没有接收到通知事务之前会一直占有着这次分布式事务的连接资源。等到最后TxManager通知TxClient时,TxClient才会去执行相应的提交或回滚。所以LCN的事务协调机制相当于是拦截了一下连接池,控制了连接的事务提交。
LCN事务补偿机制:
由于我们不能保证事务每次都正常执行,如果在执行某个业务方法时,本应该执行成功的操作却因为服务器挂机或网络抖动等问题导致事务没有正常提交,这种场景就需要通过补偿来完成事务。
在这种情况下TxManager会做一个标识;然后返回给发起方。告诉他本次事务有存在没有通知到的情况,然后TxClient再次执行该次请求事务。
3. 总结
分布式事务本身是一个技术难题,是没有一种完美的方案应对所有场景的,具体还是要根据业务场景去抉择。
参考文章:
程序员小灰–https://blog.csdn.net/bjweimengshu/article/details/79607522
https://www.cnblogs.com/bluemiaomiao/p/11216380.html
https://www.cnblogs.com/huanzi-qch/p/11057974.html
极客时间丁奇Mysql