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 能进行多次尝试获取全局锁,否则监测到有全局锁就抛出异常)
以下是一阶段和二阶段的详细过程
一阶段
- 解析 SQL:得到 SQL 的类型(UPDATE),表(product),条件(where name = 'TXC')等相关的信息。
- 查询前镜像:根据解析得到的条件信息,生成查询语句,定位数据。
- 执行业务 SQL:更新这条记录的 name 为 'GTS'。
- 查询后镜像:根据前镜像的结果,通过 主键 定位数据。
- 插入回滚日志:把前后镜像数据以及业务 SQL 相关的信息组成一条回滚日志记录,插入到 UNDO_LOG 表中。
- 提交前,向 TC 注册分支:申请 product 表中,主键值等于 1 的记录的 全局锁 。
- 本地事务提交:业务数据的更新和前面步骤中生成的 UNDO LOG 一并提交。
- 将本地事务提交的结果上报给 TC。
二阶段-提交
- 收到 TC 的分支提交请求,把请求放入一个异步任务的队列中,马上返回提交成功的结果给 TC。
- 异步任务阶段的分支提交请求将异步和批量地删除相应 UNDO LOG 记录。
二阶段-回滚
- 收到 TC 的分支回滚请求,开启一个本地事务,执行如下操作。
- 通过 XID 和 Branch ID 查找到相应的 UNDO LOG 记录。
- 数据校验:拿 UNDO LOG 中的后镜与当前数据进行比较,如果有不同,说明数据被当前全局事务之外的动作做了修改。这种情况,需要根据配置策略来做处理,详细的说明在另外的文档中介绍。
- 根据 UNDO LOG 中的前镜像和业务 SQL 的相关信息生成并执行回滚的语句:
- 提交本地事务。并把本地事务的执行结果(即分支事务回滚的结果)上报给 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 语句。
下面有待补充具体的实操