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 需要重试等待 全局锁

Write-Isolation: Commit

tx1 二阶段全局提交,释放 全局锁 。tx2 拿到 全局锁 提交本地事务。

Write-Isolation: Rollback

如果 tx1 的二阶段全局回滚,则 tx1 需要重新获取该数据的本地锁,进行反向补偿的更新操作,实现分支的回滚。

此时,如果 tx2 仍在等待该数据的 全局锁,同时持有本地锁,则 tx1 的分支回滚会失败。分支的回滚会一直重试,直到 tx2 的 全局锁 等锁超时,放弃 全局锁 并回滚本地事务释放本地锁,tx1 的分支回滚最终成功。

因为整个过程 全局锁 在 tx1 结束前一直是被 tx1 持有的,所以不会发生 脏写 的问题。

读隔离

在数据库本地事务隔离级别 读已提交(Read Committed) 或以上的基础上,Seata(AT 模式)的默认全局隔离级别是 读未提交(Read Uncommitted)

如果应用在特定场景下,必需要求全局的 读已提交 ,目前 Seata 的方式是通过 SELECT FOR UPDATE 语句的代理。

Read Isolation: SELECT FOR UPDATE

SELECT FOR UPDATE 语句的执行会申请 全局锁 ,如果 全局锁 被其他事务持有,则释放本地锁(回滚 SELECT FOR UPDATE 语句的本地执行)并重试。这个过程中,查询是被 block 住的,直到 全局锁 拿到,即读取的相关数据是 已提交 的,才返回。

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

分布式事务-脏写

假设你的业务代码是这样的:

  • updateAll()用来同时更新A和B表记录,updateA() updateB()则分别更新A、B表记录
  • updateAll()已经加上了@GlobalTransactional

dirty-write

回滚时,发现A记录已被修改,会造成无法全局回滚,产生如下信息:

在这里插入图片描述

Seata防止脏写方式

全局事务

  • updateAll()先被调用(未完成),updateA()后被调用

dirty-write

全局锁

  • updateAll()先被调用(未完成),updateA()后被调用 dirty-write
  • 那如果是updateA()先被调用(未完成),updateAll()后被调用呢?
    由于2个业务都是要先获得本地锁,因此同样不会发生脏写

Seata如何防止脏读

某业务先调用updateAll()updateAll()未执行完成,另一业务后调用queryA()

dirty-write

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 全局锁

Seata AT 模式分布式事务源码分析

Seata事务隔离

Seata入门系列(22)-@GlobalLock注解使用场景及源码分析

Spring Cloud Alibaba Seata:分布式事务

Seata AT 模式

posted @ 2022-10-16 18:09  hongdada  阅读(5355)  评论(0编辑  收藏  举报