分布式事务seata之AT与TCC模型

1. seata分布式事务简介

seata是一款开源的分布式事务解决方案,致力于提供高性能和简单易用的分布式事务服务。

Seata提供了AT、TCC、SAGA和XA事务模型,为用户打造一站式的分布式解决方案。

简单来说,Seata就是针对主流事务解决方案的封装,我们直接使用Seata这个框架就可以选择性的使用不同的事务模型来解决分布式事务问题。

2. seata中的AT事务模型

AT模式实际上是2PC协议的一种演变方式,也是通过两个阶段的提交或者回滚来保证多节点事务的一致性,它的工作模型如下图所示。

具体工作流程说明如下:

 1. 第一阶段, 应用系统会把一个业务数据的事务操作和回滚日志记录在同一个本地事务中提交,在提交之前,会向TC(seata server)注册事务分支,并申请针对本次事务操作的表的全局锁。 接着提交本地事务,本地事务会提交业务数据的事务操作以及UNDO LOG,放在一个事务中提交。

2. 第二个阶段,这一个阶段会根据参与到同一个XID下所有事务分支在第一个阶段的执行结果来决定事务的提交或者回滚,这个回滚或者提交是TC来决定的,它会告诉当前XID下的所有事务分支,提交或者回滚。
  • 如果是提交, 则把提交请求放入到一个异步任务队列,并且马上返回提交成功给到TC,这样可以避免阻塞问题。而这个异步任务,只 需要删除UNDO LOG就行,因为原本的事务已经提交了。
  • 如果是回滚,则开启一个本地事务,执行以下操作
    • 通过XID和Branch ID查找到响应的UNDO LOG记录
    • 数据校验,拿到UNDO LOG中after image(修改之后的数据)和当前数据进行比较,如果有不同,说明数据被当前全局 事务之外的动作做了修改,这种情况需要根据配置策略来做处理。
    • 根据UNDO LOG中的before image和业务SQL的相关信息生成并执行回滚语句
    • 提交本地事务,并把本地事务的执行结果上报给TC

 2.1 AT模式原理详解

假设存在一个业务表product,表结构如下:

FieldTypeKey
idbigint(20)PRI
namevarchar(100)
sincevarchar(100)

这个表中,有一条对应的数据:

idnamesince
1TXC2024

 假设AT模式下的其中一个分支事务的执行业务逻辑对应的sql语句如下:

update product set name = 'GTS' where name = 'TXC';

 在Seata的AT模式下,执行的过程如下:

AT模式第一阶段

这个阶段主要分为两个过程。

  1. 执行数据的修改
  2. 记录回滚日志

具体过程如下:

1. 解析前面的 Update 语句,得到SQL类型(UPDATE)、表(Product)、条件(where name ='TXC')等相关信息。
2. 根据解析到的条件信息,生成一条查询语句用来查询修改之前的数据状态
select id , name , since from product where name = 'TXC'
3. 执行上述的业务SQL,也就是更新 name GTS
4. 在根据第二个步骤查询的主键 id 定位修改后的数据
select id, name, since from product where id = 1;
5. 有了更新前后的数据,以及业务SQL有关信息,组成一条回滚日志记
录,插入到 UNDO_LOG 表中。
{
"    branchId": 641789253,
    "undoItems": [{
        "afterImage": {
            "rows": [{
                "fields": [{
                    "name": "id",
                    "type": 4,
                    "value": 1
                }, {
                    "name": "name",
                    "type": 12,
                    "value": "GTS"
                }, {
                    "name": "since",
                    "type": 12,
                    "value": "2014"
                }]
            }],
        "tableName": "product"
        },
        "beforeImage": {
            "rows": [{
                "fields": [{
                    "name": "id",
                    "type": 4,
                    "value": 1
                }, {
                    "name": "name",
                    "type": 12,
                    "value": "TXC"
                }, {
                    "name": "since",
                    "type": 12,
                    "value": "2014"
                }]
            }],
            "tableName": "product"
        },
    "sqlType": "UPDATE"
    }],
    "xid": "xid:xxx"
}
6. UNDO日志提交之前,会向TC(SEATA-SERVER)注册分支事务,并申请 product 表中,主键值为1记录的全局锁。
7. 业务数据的更新语句(UPDATE)和UNDO LOG一起提交,并将本地事
务的提交结果上报到TC。

 AT模式第二阶段

这个阶段,主要是TC会根据各个分支事务的执行结果,来决定事务的提交或者回滚。

1. 如果所有分支事务的执行结果都正常,则提交事务。由于实际上各个本地事务在第一阶段已经提交了,所以只需要异步去删除当前事务分支对应UNDO LOG表中的记录即可。
2. 如果存在部分事务分支执行异常的情况,则需要对事务进行回滚,回滚步骤如下

  • 收到TC的分支回滚请求,事务参与者开启一个本地事务。
  • 通过XID和BranchID查找到UNDO LOG中对应的记录
  • 拿到数据后,先对数据进行校验,使用UNDO LOG中 afterImages(修改后的数据) 和当前 product 表中的数据进行比较,如果发现数据不相同,说明数据被当前全局事务之外的程序修改过,这种情况需要根据配置的策略来进行处理
  • 根据UNDO LOG中的 beforeImages(修改之前的数据) 和业务SQL相关信息生成回滚语句并执行。
update product set name = 'TXC' where id = 1;
3. 提交本地事务,并把本地事务的执行结果(分支事务的回滚结果)上报给TC。

总结: 从seata的整体思路上,类似于把数据库的事务在作用范围内作了一层升华,核心步骤和SQL类似:

  • 加锁
  • 写事务日志
  • 提交事务或回滚事务

思考: 那么在这种事务模型下,它的事务隔离级别是如何实现的呢?

2.2 AT模式的事务隔离级别

2.2.1 写隔离

所谓的写隔离,就是多个事务对同一个表的同一条数据做修改的时候,需要保证对于这个数据更新操作的隔离性,在传统事务模型中,我们一般是采用锁的方式来实现。

那么在分布式事务中,如果存在多个全局事务对于同一个数据进行修改,为了保证写操作的隔离,也需要通过一种方式来实现隔离性,自然也是用到锁的方法,具体来说

  • 在第一阶段本地事务提交之前,需要确保先拿到全局锁,如果拿不到全局锁,则不能提交本地事务
  • 拿到全局锁的尝试会被限制在一定范围内,超出范围会被放弃并回滚本地事务并释放本地锁。

 举一个具体的例子,假设有两个全局事务tx1和tx2,分别对a表的m字段进行数据更新操作,m的初始值是1000.

1. tx1先开始执行,按照AT模式的流程,先开启本地事务,然后更新m=1000-100=900。在本地事务更新之前,需要拿到这个记录的全局锁。
2. 如果tx1拿到了全局锁,则提交本地事务并释放本地锁。
3. 接着tx2后开始执行,同样先开启本地事务拿到本地锁,并执行m=900-100的更新操作。在本地事务提交之前,先尝试去获取这个记录的全局锁。而此时tx1全局事务还没提交之前,全局锁的持有者是tx1,所以tx2拿不到全局锁,需要等待
4.   接着, tx1在第二阶段完成事务提交或者回滚,并释放全局锁。此时tx2就可以 拿到全局锁来提交本地事务。当然这里需要注意的是,如果tx1的第二阶段是 全局回滚,则tx1需要重新获取这个数据的本地锁,然后进行反向补偿更新实 现事务分支的回滚。
5.   此时,如果tx2仍然在等待这个数据的全局锁并且同时持有本地锁,那么tx1 的分支事务回滚会失败,分支的回滚会一直重试直到tx2的全局锁等待超 时,放弃全局锁并回滚本地事务并释放本地锁之后,tx1的分支事务才能最终 回滚成功.

tx2等待全局锁图示:

 tx2超时放弃全局锁,tx1拿到本地锁后回滚图示:

在整个过程中,全局锁在tx1结束之前一直被tx1持有,所以并不会发生脏写问题

2.2.2 读隔离

seata(AT模式)的默认全局隔离级别是读未提交。如果在特定场景下,必须要求全局的读已提交,目前seata的方式只能通过SELECT FOR UPDATE语句来实现。

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

3. seata TCC模式

seata 的TCC事务和2pc的思想类似, 但并不是2pc的实现,TCC不再是两阶段提交,而只是它对事务的提交/回滚是通过执行一段confirm/cncel业务逻辑来实现,并且也没有全局事务来把控整个事务逻辑。

它的本质上是一种补偿的思路,它把事务运行过程分成Try、confirm/cancel两个阶段,每个阶段由业务代码控制,这样事务的锁力度可以完全自由控制。

  • 一阶段 prepare 行为, 通过业务代码编排来调用try接口进行资源预留
  • 二阶段 commit 或 rollback 行为.
    • 当所有事务参与者的try接口都成功了,那么意味着事务管理器可以 提交事务,于是调用每个事务参与者的confirm接口实现真正的业务 提交操作
    • 如果事务参与者中任意一个参与者出现异常,则调用每个参与者的 cancel接口进行数据回滚。

seata框架会把每组TCC接口当作一个Resource,成为Tcc Resource. 这组TCC接口可以是RPC、也可以是服务内的JVM调用。

当业务服务启动时,Seata框架会自动扫描并且识别到TCC接口的调用方和发布方。

1. 如果是TCC接口发布方,那么在业务启动的时候会向TC注册TCC Resource,每个资源会带一个资源ID
2. 如果是TCC接口调用方,Seata框架会给调用方增加一个切面,在运行的时候,这个切面会拦截所有对TCC接口的调用。每调用一次 Try 接口,切面会先向TC注册一个分支事务,然后再去执行原来的RPC调用。
当请求链路调用完成后,TC通过分支事务的资源ID回调到正确的参与者
去执行对应TCC资源的Confirm或者Cancel方法。

3.1 TCC接口设计与异常控制

TCC模式下,需要我们根据自己的业务场景分别实现Try、Confirm和cancel三个操作

TCC三个方法描述:

  • Try: 资源的检测和预留
  • Confirm: 执行的业务操作提交;要求Try成功 Confirm一定要能成功;
  • Cancel: 预留资源释放

所以如果我们要接入到TCC,最重要的是考虑如何把自己的业务拆分成两个阶段来实现。

比如以资金扣减这类场景为例,在接入TCC之前,对某个账户扣款,只需要 一条更新账户余额的SQL就可以完成。但是在接入到TCC之后,我们就需要考虑把这样一个步骤拆分成两个阶段,实现三个方法,并且如果一阶段 Try 成功的情况下,要保证二阶段 Confirm 一定能成功。

如下图所示,针对TCC的事务设计,我们把扣减的逻辑拆分成两个步骤

 1. try,需要先检查账户余额以及预留本次要扣减的资金,所以预留的方式就是通过冻结该账户的转账资金,被冻结的资金无法被其他事务使用!

2. confirm,真正执行扣钱的操作,这个阶段使用 try 阶段冻结的资金进行扣款,总余额真正发生了变化。
3. cancel,如果是回滚,则把 try 冻结的资金加回来,回到初始状态。

 因此,TCC相比AT模式来说,对代码侵入性较大,但是不需要像AT模式那样获取全局行锁,所以性能方面会比AT模式高一些

3.2 TCC模式允许空回滚

所谓空回滚,就是在TCC分布式事务中,如果没有调用TCC资源的try方法的情况下,直接调用了第二阶段的cancel方法。因此在设计cancel方法的时候,需要识别当前是一个空回滚,然后直接返回。

那么什么情况下会出现空回滚呢?看下面这个图。

Try未执行、cancel执行了

 在seata中,注册分支事务是在调用RCP的时候进行的,也就是seata的切面会拦截这次请求,先向TC注册一个分支事务,然后再去执行RPC调用逻辑。

假设RPC调用逻辑有问题,比如出现网络异常、或者目标服务宕机的情况下,会导致RPC调用失败。也就是没有执行Try方法,但是由于分布式事务已经开启了,Seata需要把事务推进到最终状态,所以 ,TC会回调参与者第二阶段的cancel接口,形成空回滚问题。

思考: 这个问题需要在设计的时候考虑到吗?

我们可以想办法在cancel方法中识别到空回滚,然后不做任何操作直接 返回成功就行。而关键在于如何识别这是一次空回滚

实现思路是需要直到第一阶段是否执行,如果执行了就正常回滚,没执行就是空回滚。因此我们可以用一张额外的事务控制表,记录分布式事务的ID和分支事务ID

然后在第一个阶段Try里面插入一条记录,表示第一个阶段执行了。

当cancel方法调用时,再根据全局事务和分支事务id去匹配是否存在该记录,如果存在说明执行过可以正常回滚。

3.3 TCC模式下的幂等控制

幂等性在TCC模式下也需要重点考虑,因为TCC要确保第二阶段的执行成功,会去重试请求confirm或cancel接口,比如下面这个场景

 从图中可以看到,提交或者回滚是TC到事务分支的网络调用,因此如果出现网络故障,游客了嗯会出现参与者已经提交了,但是TC没有收到返回结果的情况。

这个时候TC就会重复调用Confirm或Cancel接口,直到成功!

所以,在这种情况下需要考虑Confirm和Cancel这两个接口的幂等性,那么如何设计幂等性解决方案呢?

其实这里面比较好的方案是通过状态机的方式来实现,我们可以创建一个事 务控制表,然后保存当前这个分支事务的执行状态,比如初始化、已提交、 已回滚等。
  • 当执行try方法的时候,意味着这个事务的状态是初始化。
  • 在第二阶段中执行confirm或者cancel时,修改成已提交或者已回滚。
因为一个数据的状态只会出现一次,并且状态是不可逆的,所以当指定状态已经出发一次修改之后,再通过该状态修改同一条数据时,就无法修改成功 了

3.4 TCC模式下的防悬挂问题

在TCC事务模式中,还有一种可能的情况

第二阶段的Cancel方法可能比Try方法先执行,因为允许空回滚的存在,所以Cancel方法会认为Try方法还没 有被执行,因此空回滚直接返回成功。
对于Seata来说,它认为TCC中第二阶段的Cancel已经执行成功了,所以认为整个分布式事务也就结束了。
但是有可能出现等到Seata认为分布式事务结束并且也执行了Cancel空回滚之后,Try方法才正式开始执行,去执行资源的预留。
当出现这种情况的时候,分布式事务第一阶段预留的业务资源就再也没有人能够处理,从而导致这个资源一直被挂着,我们称这种现象为悬挂!

对于这种情况,我们需要考虑到: 允许空回滚,但是不允许空回滚之后再执行Try操作。

 为解决这个问题,我们需要考虑到try执行时,要判断cancel方法是否已经执行过了,如果已经执行了,那么try方法就不应该执行。因此,问题的关键,在于,try怎么知道cancel执行过了?

其实还是可以通过前面提到的事务控制表来解决幂等性的方法来解决

当执行try方法时,会去判断事务控制表里面是否有存在已回滚状态的事务记录,如果有,则不执行try。

4. 总结

分布式事务seata主要就是为了解决多服务下的数据一致性问题,提供了AT、TCC、SAGA、XA四种事务模型。本文主要讲述了AT模式和TCC模式,其思路都是借鉴的2PC模式,AT模式下采用的是全局事务锁来保证各分支事务的数据一致性,TCC模型,则是采用3个核心方法,通过业务代码来控制分布式事务。需要注意的是,在分布式架构中,一定要考虑到由于网络通信导致的问题,从而避免隐藏bug。

  • 25
    点赞
  • 15
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值