上一篇《数据库事务,spring事务 》我们讲到从数据库事务到spring事务的演变以及原理和使用,spring事务已经能够满足单连接情况下的事务要求,但是在多线程场景(不同线程使用不同的数据库连接),或者微服务场景(多个服务各自使用不同的数据库)时显得力不从心,这时候我们需要引入另外一个东西来处理这些场景-----分布式事务。
以下标准解决方案基础知识参考了两篇不错的帖子,感兴趣的朋友可以查看原贴
分布式事务六种解决方案
分布式事务解决方案汇总
术语说明在讲解每一种思路前我认为有必要给大家提一嘴常用的分布式事务中的常用角色:
TC (Transaction Coordinator) - 事务协调者
维护全局和分支事务的状态,驱动全局事务提交或回滚。
TM (Transaction Manager) - 事务管理器
定义全局事务的范围:开始全局事务、提交或回滚全局事务。
RM (Resource Manager) - 资源管理器
管理分支事务处理的资源,与TC交谈以注册分支事务和报告分支事务的状态,并驱动分支事务提交或回滚。
总结:简单来理解 ,例如在项目中使用seata来管理事务,那么Seata服务就是TC,加了事务注解的那个方法就是TM,TM内部调用的各个独立的需要保证事务的方法就是RM。
常见的分布式事务解决思路- 2PC(两阶段提交)
- 3PC(三阶段提交)
- TCC
- 可靠消息队列
- SAGA事务
- AT事务
2PC(Two-phase commit protocol)两阶段提交就是将事务提交拆成了准备阶段和提交阶段这两个阶段,为了管理者两个阶段,我们引入一个新的第三方TC (Transaction Coordinator) - 事务协调者(反正所谓架构不就是再抽一层嘛,哈哈),协调者就相当于一个总的事务管理器,它会根据事务的执行状态来判断事务最终是提交还是回滚。
准备阶段:协调者(TC)会给各参与者(RM)发送准备命令,你可以把准备命令理解成除了提交事务之外啥事都做完了,同时协调者会不断询问事务的所有参与者是否执行完sql(注意:这个时候没有真正提交事务),如果已准备好提交回复Prepared,否则回复Non-Prepared,这个阶段各参与者RM仍然会持有各自的数据库锁。
提交阶段,如果全部的事务参与者都返回给协调者的是Prepared,则协调者向所有的参与者发送Commit指令,所有事务参与者立即执行提交操作。 如果任意一个参与者回复了Non-Prepared消息,或任意一个参与者超时未回复,协调者向所有参与者发送 Abort 指令,参与者立即执行回滚操作。
两阶段提交存在以下缺点:
- 单点故障问题:若协调者(TC)在发送准备命令前宕机,那么相当于事务没有开启(可以接受),若TC在发送准备命令后宕机或者在第二阶段发送回滚或提交命令前,那么问题就来了,RM会一直持有数据库锁(不能接受)--可能有同学会想为什么我们不引入一个超时机制呢?这个接下来会讲,若在第二阶段发送提交或者回滚命令之后挂掉(勉强可以接受),一般来说只要不出现网络故障,锁会得到释放。
- 性能问题:要等待所有的参与者处理完毕为止,性能较差。
- 一致性问题:协调者如果因为网络问题或者宕机了,向一部分参与者发送了commit,另一部分还没来得及发就宕机了,就会造成数据不一致。
3PC 的出现是为了解决 2PC 的一些问题,相比于 2PC 它在参与者中也引入了超时机制,并且新增了一个阶段使得参与者可以利用这一个阶段统一各自的状态。
3PC 包含了三个阶段,分别是准备阶段(CanCommit)、预提交阶段(PreCommit)和提交阶段(DoCommit)
准备阶段:准备阶段的变更不会直接执行事务,而是会先去询问此时的参与者是否有条件接这个事务,因此不会一来就干活直接锁资源,使得在某些资源不可用的情况下所有参与者都阻塞着。作用就是先确保所有的参与者(RM)都可以执行再上锁。避免某个别参与者不能执行成功导致其他所有参与者都上锁。
预提交阶段与提交阶段与2PC对应的两阶段是一致的。
引入超时机制:避免了TC宕机,RM一直持有锁不放的问题。
总结:
性能上,如果在事务能够正常提交的场景中,因为3PC多了一个询问阶段,所以性能反而会更差。如果事务需要回滚的场景中,三阶段的性能通常要好一些。
一致性问题并没有得到解决,在事务需要回滚的场景下,如果因为网络问题参与者一直没有收到协调者的的Abort回滚指令,这些参与者将会错误的提交事务,仍有数据不一致的问题。
TCC(Try -Confirm -Cancel )通过前面的讲解可知,2PC和3PC底层还是得依赖RM的数据库事务,如果部分RM不支持数据库事务(没有事务,提交阶段提交个狗屁)那岂不是凉凉?此时TCC上场了。
TCC 是一种补偿性事务思想,适用的范围更广,在业务层面实现,因此对业务的侵入性较大,每一个操作都需要实现对应的三个方法如下。
Try : 尝试执行阶段,并预留好事务需要用到的所有资源(比如冻结可用库存)。
Confirm : 确认执行阶段,无需进行业务检查,直接使用Try阶段准备的资源。该阶段可能会重复执行,因此需要满足幂等性。
Cancel : 取消执行阶段,释放Try阶段预留的业务资源。该阶段也可能会重复执行,因此也需要满足幂等性。
如果将2PC的提交阶段的提交和回滚拆开,你会发现和TCC非常类似。但是TCC是位于用户代码层面,而不是基础设施层面,我们就需要投入更高的开发成本。
TCC需要开发者在每个参与者(RM)中自己定义try,confirm,Cancel三个动作,具体调用流程如下如图
优点:
隔离性强:在业务执行的时候,只操作预留资源,几乎不会涉及到资源的争用,所以性能较高
缺点:
业务侵入性高,开发成本高,技术不可控。如果你对接的有第三方服务(比如银行),别人不愿意按照TCC的方式来修改代码,那就推动不下去了。
针对第二种方式,我们可以考虑采用下面的柔性事务方案:SAGA事务。
SAGA 事务把大事务拆分成若干个子事务,每个子事务都可以被看作原子行为。我们需要对每个子事务设计对应的补偿动作。如果各个子事务都能提交成功,那么事务就可以顺利完成,否则我们就要采取以下两种恢复策略之一:
正向恢复:如果某个子事务提交失败,则一直对该事务进行重试,直至成功为止,这种适用于事务最终都要成功的场景。
反向恢复:如果某个子事务提交失败,则对该子事务及其之前所有的子事务进行补偿操作。
与TCC相比,SAGA不需要为资源设计冻结状态和撤销冻结的操作,补偿操作往往要比冻结操作容易实现的多。
缺点:
SAGA必须保证所有子事务都能提交或者补偿,但是SAGA系统本身也可能会崩溃,所以它必须设计成与数据库类似的日志机制。
需要保证正向、反向恢复过程能严谨执行。
SAGA是基于数据补偿来代替回滚的思路,也可以应用在其他事务方案上。比如阿里的 Seata 框架的 AT事务模式就是这样的一种应用。
AT事务也是参照两阶段提交协议来实现的,针对两阶段提交涉及到的资源必须都等到最慢的事务完成后才能统一释放的问题,AT事务也设计了对应的解决方案。
它在业务数据提交时,自动拦截所有的SQL,分别保存SQL对数据修改前后的快照,这就相当于记录了重做和回滚日志
如果分布式事务成功了,那我们后续只需清理每个数据源中对应的日志数据即可。如果分布式事务需要回滚,就要根据日志数据自动产生用于补偿的逆向SQL。
基于这种补偿方式,分布式事务中所涉及的每一个数据源都可以单独提交,然后立刻释放资源,相比两阶段提交,极大的提升了系统的吞吐量。但是它和我们使用的事务消息一样,牺牲了隔离性。
消息事务RocketMQ 就很好的支持了消息事务,让我们来看一下如何通过消息实现事务。
第一步先给 Broker 发送事务消息即半消息,半消息不是说一半消息,而是这个消息对消费者来说不可见,然后发送成功后发送方再执行本地事务。
再根据本地事务的结果向 Broker 发送 Commit 或者 RollBack 命令。
并且 RocketMQ 的发送方会提供一个反查事务状态接口,如果一段时间内半消息没有收到任何操作请求,那么 Broker 会通过反查接口得知发送方事务是否执行成功,然后执行 Commit 或者 RollBack 命令。
如果是 Commit 那么订阅方就能收到这条消息,然后再做对应的操作,做完了之后再消费这条消息即可。
如果是 RollBack 那么订阅方收不到这条消息,等于事务就没执行过。
可以看到通过 RocketMQ 还是比较容易实现的,RocketMQ 提供了事务消息的功能,我们只需要定义好事务反查接口即可。
可以看到消息事务实现的也是最终一致性。
最大努力通知其实我觉得本地消息表也可以算最大努力,事务消息也可以算最大努力。
就本地消息表来说会有后台任务定时去查看未完成的消息,然后去调用对应的服务,当一个消息多次调用都失败的时候可以记录下然后引入人工,或者直接舍弃。这其实算是最大努力了。
事务消息也是一样,当半消息被commit了之后确实就是普通消息了,如果订阅者一直不消费或者消费不了则会一直重试,到最后进入死信队列。其实这也算最大努力。
所以最大努力通知其实只是表明了一种柔性事务的思想:我已经尽力我最大的努力想达成事务的最终一致了。
适用于对时间不敏感的业务,例如短信通知。
上面讲了各种思路,具体采用哪总方案要根据实际的业务场景来决定。下面我们就一起来操练一下seata的使用吧。
分布式事务框架Seataseata官方文档已经写的很详细,具体了解可以直接查看如下链接
seata中文官方文档http://seata.io/zh-cn/docs/overview/what-is-seata.html
AT模式这我复述一下AT模式运行原理:
假设有A,B两个事务,我么先考虑两个事务修改的是同一张表同一个字段这种极端情况
A事务执操作字段 m = 1000 - 100 = 900,B事务操作字段 m = 900 - 100 = 800;
第一阶段:A事务先获取本地锁(数据库锁),获取后执行操作,将m改成900,然后获取全局锁(seata提供的锁),都成功后执行本地commit,并释放本地锁(注意此时没有释放全局锁)——此时数据库中m的状态是实实在在的900(给自己留个疑问-如果这时候被当前全局事务范围外其他线程获取到本地锁并进行修改会怎样呢?),然后执行B事务逻辑,因为A事务已经释放本地锁,因此B事务可以顺利拿到本地锁正常执行,B事务将m变成800 此时尝试获取全局锁,若A事务外层的业务逻辑还未执行完毕,没有释放全局锁,此时B事务等待并重试获取全局锁。
第二阶段: A事务释放全局锁前,存在两种情况。
第一种A事务执行成功了,释放全局锁B事务获取到全局锁,提交本地事务,释放本地锁,释放全局锁。整个事务完成。
第二种A事务执行失败了,不释放全局锁,且尝试获取本地锁。但此时本地锁一直被B事务持有 ,且B事务在等待获取全局锁。双方会僵持在这种状态,直到B事务发现获取全局锁等待超时,此时B事务会回滚本地事务,并释放本地锁,这是A事务就会顺利获取到本地锁,通过undo—log表中的记录回滚。回滚后释放全局锁。
读取隔离级别:通过以上流程可以发现,在数据库本地事务隔离级别 读已提交(Read Committed) 或以上的基础上,在整个过程中AT模式对于A事务对应的数据的读取隔离级别为读未提交(全局事务还未结束,但是A事务已释放本地锁,其他线程可读),对于B事务对应的数据而言是读已提交(全局事务未结束时,不可读)。
当然如果你的业务一定要实现读已提交,seata也提供了对应的处理方法,在查询的时候使用select for update ,SELECT FOR UPDATE 语句的执行会申请 全局锁 ,如果 全局锁 被其他事务持有,则释放本地锁(回滚 SELECT FOR UPDATE 语句的本地执行)并重试。这个过程中,查询是被 block 住的,直到 全局锁 拿到,即读取的相关数据是 已提交 的,才返回。
出于总体性能上的考虑,Seata 目前的方案并没有对所有 SELECT 语句都进行代理,仅针对 FOR UPDATE 的 SELECT 语句。
写入隔离级别:因为全局锁一直被A事务持有,因此后面的事务都不能提交本地事务,因此不存在脏写问题。(此处留一个疑问,如果是被当前全局事务范围外的线程修改会则导致脏写吗?)
考察事务,隔离级别一定是我们最关心的,因为隔离级别决定着业务极端情况下会不会出错,我们拿到一个业务时候,选择某个分布式事务框架,或者某个框架的某个模式时候,首先要摸清该模式的隔离等级是否满足我们的业务要求。
此处我只做一些简单的验证和使用示例
- 首先安装seata服务和mysql服务,具体安装步骤不再赘述,安装完成如下。
因为AT模式需要使用数据库存放前后镜像,因此我们去github上找到sql脚本,如下图
执行mysql.sql,在我们创建的数据库中创建需要用到表格。
接着创建我们的测试项目,重点是在项目中引入seata客户端依赖
io.seata seata-spring-boot-starter与你项目springCloud相对应的seata版本号
在项目中我们创建两个RM(接下来的代码只为了演示使用seata的AT模式,因此不要太纠结项目分层以及代码风格问题)
package com.moky.service; import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; import com.moky.dao.StockDao; import com.moky.entity.StockEntity; import org.springframework.stereotype.Service; @Service public class StockService extends ServiceImpl{ public void createStock(){ StockEntity stockEntity = new StockEntity(); stockEntity.setGoodsId(123456L); stockEntity.setNum(100); save(stockEntity); } }
package com.moky.service; import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; import com.moky.dao.OrdersDao; import com.moky.entity.OrdersEntiey; import org.springframework.stereotype.Service; @Service public class OrderService extends ServiceImpl{ public void createOrder(){ int i = 0; i = 1/0; } }
注意第二个RM会抛出异常,这样我们可以模拟需要回滚时的场景。
然后我们再创建一个TM
package com.moky.service;
import io.seata.spring.annotation.GlobalTransactional;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
@Service
public class TMService {
@Autowired
private OrderService orderService;
@Autowired
private StockService stockService;
@GlobalTransactional
public void testAt(){
stockService.createStock();
orderService.createOrder();
}
}
根据seataAT模式原理,作如下推断:
我们在orderService.createOrder()方法前打一个断点,我们来看看数据库中是否能够查到刚刚新增的数据,注意,此时全局事务还未全部执行完毕。
此时我们放开断点让代码执行完毕,再次查看数据库
此时,数据库已恢复,undo-log也在恢复后自动删除。
这里我回答一下之前留的两个疑问,其实两个疑问是同一个问题,如果第一个事务执行结果是正常执行,那么其实不用处理,数据是没有问题的。但是如果是要回滚,这时就会导致回滚时就会导致后镜像与数据库的数据对应不上,此时就需要我们手动配置策略。
TCC模式:
未完待续。。。



