Seata AT模式学习

官方文档

Seata是目前国内最流行的一个分布式事务的组件,支持以下4种模式

AT模式:对业务代码无侵入,只要在业务的数据库加上一个UNDO_LOG表,在配置文件中配置好Seata的服务端,在需要开启全局事务的地方加上注解就行

TCC模式:即Try-Commit-Cancel,自定义prepare逻辑、commit逻辑及回滚的逻辑,代码侵入性大、灵活、对开发要求高

SAGA模式:主要用于分布式长事务

XA模式:即XA协议的实现,经典的二阶段提交

 

这里我主要学习一下最常用的AT模式

 

大致工作流程:

由两阶段提交协议演化而来,也是分为两个阶段,如下

一阶段:解析update SQL和执行业务的UPDATE语句,将回滚的补偿放入undo_log,直接提交本地事务

二阶段(成功):清理undo_log相关的补偿信息

二阶段(失败):根据undo_log种的补偿信息对数据进行反向补偿

 

三种角色:

TC(Transaction Manager):事务协调者,维护全局和分支事务的状态,驱动全局事务提交或回滚。

TM(Transaction Manager): 事务管理器,定义全局事务的范围,开始全局事务、提交或回滚全局事务。相当于加了@GlobalTransaction注解那个才是TM。

RM(Resource Manager ) :资源管理器,管理分支事务处理的资源( Resource ),与 TC 交谈以注册分支事务和报告分支事务的状态,并驱动分支事务提交或回滚。被TM调用的服务会有RM。

其中,TC工作在Seata服务端,TM和RM工作在Seata的客户端即业务的微服务端

 

 

 

 

使用方式:

1. 部署Seata服务端,单机用于学习,生产上一般是要以集群的形式,并配置一个数据库(保存全局的事务id),并将自身注册到服务注册中心,被其他微服务使用

2. 业务端改造,

    在每个相关的业务端的数据库中加上一个undo_log的表,

    

使用方式:只需要将注解@GlobalTransactional加在需要开启全局事务的那个方法上,而被调用的微服务的方法上只需要加上本地事务注解 @Transactional

public class BusinessServiceImpl implements BusinessService {

    private StorageService storageService;

    private OrderService orderService;

@GlobalTransactional public void purchase(String userId, String commodityCode, int orderCount) { storageService.deduct(commodityCode, orderCount); orderService.create(userId, commodityCode, orderCount); } }

 

@GlobalTransactional这个直接会标识开启全局事务,这个应该就是TM,开启全局事务、提交全局事务和回滚全局事务

那为什么被调用的微服务的方法不需要加全局事务的注解呢?

    我大胆猜测一下,因为被调用的微服务方法是具体的干活的,是RM,会将DataSource进行一层代理,在执行UPDATE SQL的前后会进行解析UPDATE SQL和生成补偿信息到undo_log中

那RM怎么知道某个方法被全局事务的进行管理了呢?

    我再来猜测一下,微服务间的调用肯定也做了手脚,会在HTTP调用或者RPC调用的时候将全局事务的事务xid作为参数或者header传过来      

 

关于数据源的代理做了什么事参考官方文档

关于如何传递全局事务id,需要使用改造过的feign/rpc

 

两个主要的注解:

@GlobalTransactional   声明全局事务(会隐式的获取、持有、释放全局锁)

@GlobalLock                 检查是否有全局锁(配合select for update 能进行多次尝试获取全局锁,否则监测到有全局锁就抛出异常)

 

以下是一阶段和二阶段的详细过程

一阶段

  1. 解析 SQL:得到 SQL 的类型(UPDATE),表(product),条件(where name = 'TXC')等相关的信息。
  2. 查询前镜像:根据解析得到的条件信息,生成查询语句,定位数据。
  3. 执行业务 SQL:更新这条记录的 name 为 'GTS'。
  4. 查询后镜像:根据前镜像的结果,通过 主键 定位数据。
  5. 插入回滚日志:把前后镜像数据以及业务 SQL 相关的信息组成一条回滚日志记录,插入到 UNDO_LOG 表中。
  6. 提交前,向 TC 注册分支:申请 product 表中,主键值等于 1 的记录的 全局锁 。
  7. 本地事务提交:业务数据的更新和前面步骤中生成的 UNDO LOG 一并提交。
  8. 将本地事务提交的结果上报给 TC。

二阶段-提交

  1. 收到 TC 的分支提交请求,把请求放入一个异步任务的队列中,马上返回提交成功的结果给 TC。
  2. 异步任务阶段的分支提交请求将异步和批量地删除相应 UNDO LOG 记录。

二阶段-回滚

  1. 收到 TC 的分支回滚请求,开启一个本地事务,执行如下操作。
  2. 通过 XID 和 Branch ID 查找到相应的 UNDO LOG 记录。
  3. 数据校验:拿 UNDO LOG 中的后镜与当前数据进行比较,如果有不同,说明数据被当前全局事务之外的动作做了修改。这种情况,需要根据配置策略来做处理,详细的说明在另外的文档中介绍。
  4. 根据 UNDO LOG 中的前镜像和业务 SQL 的相关信息生成并执行回滚的语句:
  5. 提交本地事务。并把本地事务的执行结果(即分支事务回滚的结果)上报给 TC。

 

 

 

 

关于脏读问题

Seata中的分布式事务,都有各自的 XID,每个 XID 都会把 “行锁”(也叫全局锁)注册到 TC 里面

 

问题原因:由于AT模式是一阶段就直接提交,所以如果另一个不相关的方法去查询对应的那行数据,是有可能读到脏数据(即还未完成全局事务的数据)

解决办法:使用select for update和@GlobalLock注解,如下图,

select for update会等待全局事务中的一阶段结束后才能拿到本地锁,然后去获取全局锁,此时全局锁被全局事务给占用了(TC处有记录),导致全局锁获取不到,从而无法继续下去进行读取操作,直到全局事务二阶段提交或者回滚

 

 

关于脏写问题

问题原因:同样的,由于AT模式是一阶段就直接提交,所以如果另一个不相关的方法(只带了@Transactional,甚至不带)去修改对应的那行数据,是可以在一阶段结束后,二阶段提交回滚前将数据改掉,导致如果全局事务失败,无法正确回滚(补偿)

解决办法一:在其他的update请求的方法上也加上@GlobalTransactional,同样开启全局事务,确保修改的时候能够因为全局锁而被挡住

 

解决办法二:在其他的update请求的方法上加上@GlobalLock+@Transactional,并在update前使用selectForUpdate(这步可以不做,下面解释)。   会先去尝试拿本地锁(直到拿到),然后做修改,再去获取全局锁,此时另一个全局事务还未提交,则会霸占着全局锁,这里取不到全局锁,会释放本地锁,然后抛出获取全局锁失败的异常。

 

这里不使用select for update也能防止脏写,但是加了能带来以下的好处:

  • 锁冲突更“温柔”些。如果只有@GlobalLock,检查到全局锁,则立刻抛出异常,也许再“坚持”那么一下,全局锁就释放了,抛出异常岂不可惜了。
  • updateA()中可以通过select for update获得最新的A,接着再做更新。

 

关于脏读脏写的原因以及解决办法

 

 

写隔离以及是否会死锁

不会死锁,虽然是可能形成两个全局事务相互持有锁(比如一个事务持有本地锁并尝试获取全局锁,一个事务持有全局锁并尝试获取本地锁),不过拿全局锁是会进行有限次数的尝试,拿不到的话会放弃并释放另一个锁,所以并不会形成真正的死锁

如下图官网的例子:

两个全局事务tx1和tx2,分别对a表的一个字段m进行更新,

tx1先开始 -》 开启本地事务 -》 拿到本地锁 -》 更新操作 -》 拿到全局锁 -》 提交并释放本地锁 -》 等待二阶段的提交或者回滚

tx2后开始 -》 开启本地事务 -》 拿到本地锁(如果在tx1释放本地锁前尝试拿的话会等待)-》 更新操作 -》 尝试多次去拿全局锁(全局锁被tx1持有)

如果tx1最后执行的是二阶段提交,则tx1释放全局锁,tx2获取到全局锁,tx2也能完成一阶段的工作,并继续往下执行

 

 

 

如果tx1最后执行的是二阶段回滚,(tx1仍然持有全局锁)tx1还需要重新获取本地锁,但是本地锁已经被tx2持有了,这时候就是相互持有对方的锁(tx1持有全局锁并尝试获取本地锁,tx2持有本地锁并尝试获取全局锁),不过由于尝试获取全局锁是有尝试次数限制的(默认最多10次),所以tx2最终会获取全局锁超时失败,并释放本地锁,然后tx1得到本地锁从而完成二阶段的回滚(补偿)

 

 

读隔离

Seata(AT 模式)的默认全局隔离级别是 读未提交(Read Uncommitted)

如果业务确实需要读已提交,就需要使用@GlobalLock注解并使用select for update,  Seata的AT模式对当前读(select for update)进行了代理,如果加了@GlobalLock注解和使用select for update, 会进行获取全局锁的获取的重试。

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

 

 

 

 

 下面有待补充具体的实操

 

 

 

posted @ 2024-05-02 13:50  坏男银  阅读(121)  评论(0编辑  收藏  举报