Seata AT模式的全局锁GlobalLock
Seata全局锁
Seata
中的分布式事务,都有各自的 XID
,每个 XID
都会把 “行锁”(也叫全局锁)注册到 TC 里面
注意加了引号,它不是数据库的那个行锁,它是把分支事务数据库中的数据的主键的某个值注册到 TC,它是全局的
这是 Seata 自己实现的,保证了先拿到全局锁的全局事务做完了所有事之后,其它全局事务才能提交本地事务
并且,高并发下它也不会出现死锁,只是会有等待,性能有点衰减
那么新问题来了:比如商品增加库存,它不是一个分布式事务,既然没有分布式事务去管理它,那就不会被全局锁锁住
于是 AT 还支持管理这种单次操作(加一个注解@GlobalLock
),让它也注册到 AT(虽然不是分布式事务,但可以使用里面的锁)
所以它在操作库存时,也会到 TC
里找所要操作的记录是否被锁住,这就搞定了隔离性
不会出现下单操作还没回滚呢,库存就被修改了,这就保证了不会脏写
写隔离
- 一阶段本地事务提交前,需要确保先拿到 全局锁 。
- 拿不到 全局锁 ,不能提交本地事务。
- 拿 全局锁 的尝试被限制在一定范围内,超出范围将放弃,并回滚本地事务,释放本地锁。
以一个示例来说明:
两个全局事务 tx1 和 tx2,分别对 a 表的 m 字段进行更新操作,m 的初始值 1000。
tx1 先开始,开启本地事务,拿到本地锁,更新操作 m = 1000 - 100 = 900。本地事务提交前,先拿到该记录的 全局锁 ,本地提交释放本地锁。 tx2 后开始,开启本地事务,拿到本地锁,更新操作 m = 900 - 100 = 800。本地事务提交前,尝试拿该记录的 全局锁 ,tx1 全局提交前,该记录的全局锁被 tx1 持有,tx2 需要重试等待 全局锁 。
tx1 二阶段全局提交,释放 全局锁 。tx2 拿到 全局锁 提交本地事务。
如果 tx1 的二阶段全局回滚,则 tx1 需要重新获取该数据的本地锁,进行反向补偿的更新操作,实现分支的回滚。
此时,如果 tx2 仍在等待该数据的 全局锁,同时持有本地锁,则 tx1 的分支回滚会失败。分支的回滚会一直重试,直到 tx2 的 全局锁 等锁超时,放弃 全局锁 并回滚本地事务释放本地锁,tx1 的分支回滚最终成功。
因为整个过程 全局锁 在 tx1 结束前一直是被 tx1 持有的,所以不会发生 脏写 的问题。
读隔离
在数据库本地事务隔离级别 读已提交(Read Committed) 或以上的基础上,Seata(AT 模式)的默认全局隔离级别是 读未提交(Read Uncommitted) 。
如果应用在特定场景下,必需要求全局的 读已提交 ,目前 Seata 的方式是通过 SELECT FOR UPDATE 语句的代理。
SELECT FOR UPDATE 语句的执行会申请 全局锁 ,如果 全局锁 被其他事务持有,则释放本地锁(回滚 SELECT FOR UPDATE 语句的本地执行)并重试。这个过程中,查询是被 block 住的,直到 全局锁 拿到,即读取的相关数据是 已提交 的,才返回。
出于总体性能上的考虑,Seata 目前的方案并没有对所有 SELECT 语句都进行代理,仅针对 FOR UPDATE 的 SELECT 语句。
分布式事务-脏写
假设你的业务代码是这样的:
updateAll()
用来同时更新A和B表记录,updateA()
updateB()
则分别更新A、B表记录updateAll()
已经加上了@GlobalTransactional
回滚时,发现A记录已被修改,会造成无法全局回滚,产生如下信息:
Seata防止脏写方式
全局事务
updateAll()
先被调用(未完成),updateA()
后被调用
全局锁
updateAll()
先被调用(未完成),updateA()
后被调用- 那如果是
updateA()
先被调用(未完成),updateAll()
后被调用呢?
由于2个业务都是要先获得本地锁,因此同样不会发生脏写
Seata如何防止脏读
某业务先调用updateAll()
,updateAll()
未执行完成,另一业务后调用queryA()
Seata获取锁的流程
分支事务1-开始
|
V 获取 本地锁
|
V 获取 全局锁 分支事务2-开始
| |
V 释放 本地锁 V 获取 本地锁
| |
V 释放 全局锁 V 获取 全局锁
|
V 释放 本地锁
|
V 释放 全局锁
如上所示,一个分布式事务的锁获取流程是这样的
1)先获取到本地锁,这样你已经可以修改本地数据了,只是还不能本地事务提交
2)而后,能否提交就是看能否获得全局锁
3)获得了全局锁,意味着可以修改了,那么提交本地事务,释放本地锁
4)当分布式事务提交,释放全局锁。这样就可以让其它事务获取全局锁,并提交它们对本地数据的修改了。
可以看到,这里有两个关键点
1)本地锁获取之前,不会去争抢全局锁
2)全局锁获取之前,不会提交本地锁
这就意味着,数据的修改将被互斥开来。也就不会造成写入脏数据。全局锁可以让分布式修改中的写数据隔离。
Seata事务隔离级别
Seata(AT 模式)的默认全局隔离级别是 读未提交(Read Uncommitted)
但是配合使用@GlobalLock + select for update
,就可以形成读已提交隔离级别.
GlobalLock使用限制条件
目前使用全局锁@GlobalLock时,限制条件过于苛刻——必须要添加@transaction 注解、同时必须是for update语句。
如果不使用for update
,那么不会触发全局锁检查.
GlobalLock与for update 必须一起使用?
“这里为什么要加上select for update
? 只用@GlobalLock
能不能防止脏写?” 能。但请再回看下上面的图,select for update
能带来这么几个好处:
- 锁冲突更“温柔”些。如果只有
@GlobalLock
,检查到全局锁,则立刻抛出异常,也许再“坚持”那么一下,全局锁就释放了,抛出异常岂不可惜了。 - 在
updateA()
中可以通过select for update
获得最新的A,接着再做更新。
GlobalLock遇到的场景
执行方法中,就会进行全局锁的获取,这个时候会遇到以下几种情况:
- 获取到全局锁,则正常执行,因为加了排它锁,其他事务都会被隔离,得等待当前事务执行完成
- 被全局事务占有全局锁和排它锁,则会等待全局一阶段事务提交释放本地锁,GlobalLock获取到本地锁后,等待全局事务提交,释放全局锁后,再执行,
- 如果全局失败,回滚时需要排它锁,这个时候,GlobalLock因为没有获取到全局锁抛出异常,会在异常中进行事务回滚,休眠一定时间,这个时候会让出排它锁,全局获取到排它锁后再进行全局回滚成功释放全局锁,GlobalLock在重试过程中,获取到全局锁,则成功执行,做到了很好的事务隔离性。
参考:
Seata入门系列(22)-@GlobalLock注解使用场景及源码分析