AT模式是基于XA事务模型演进而来的,所以它的整体机制也是一个改进版的两阶段提交协议。
- 第一阶段:业务数据和回滚日志记录在同一个本地事务中提交,释放本地锁和连接资源。
- 第二阶段:提交异步化,非常快速地完成。回滚通过第一阶段的回滚日志进行反向补偿。
下面我们详细分析在整个执行流程中,每一个阶段的具体实现原理。同时,为了更好地理解AT模式的工作机制,我们以库存表tbl_repo来描述整个工作过程,表结构及数据如图所示。
(一)AT模式第一阶段的实现原理
在业务流程中执行库存扣减操作的数据库操作时,Seata会基于数据源代理对原执行的SQL进行解析,代理的配置代码如下( Seata在0.9.0版本之后支持自动代理)。
然后将业务数据在更新前后保存到undo_log日志表中,利用本地事务的ACID特性,把业务数据的更新和回滚日志写入同一个本地事务中进行提交,完整的执行流程图如图所示。
假设AT分支事务的业务逻辑是
那么第一阶段的执行逻辑为:
1:通过DataSourceProxy对业务SQL进行解析,得到SQL的类型(UPDATE)、表(tbl_repo)、条件(where product_code=“GP20200202001”)等相关的信息。
2:查询修改之前的数据镜像,根据解析得到的条件信息生成查询语句,定位数据。
得到该产品代码对应的库存数量为1000。
1:执行业务SQL:更新这条记录的count=count-1。
2:查询修改之后的数据镜像,根据前镜像的结果,通过主键定位数据。
得到修改之后的镜像数据,此时count=999。
3:插入回滚日志:把前、后镜像数据及业务SQL相关的信息组成一条回滚日志记录,插入UNDO_LOG表中。可以在对应库的UNDO_LOG表中获得数据,如图所示。
其中,rollback_info表示回滚的数据包含beforeImage和afterImage。
1:提交前,向TC注册分支事务:申请tbl_repo表中主键值等于1的记录的全局锁。
2:本地事务提交:业务数据的更新和前面步骤中生成的UNDO_LOG一并提交。
3:将本地事务提交的结果上报给TC。
从AT模式第一阶段的实现原理来看,分支的本地事务可以在第一阶段提交完成后马上释放本地事务锁定的资源。这是AT模式和XA最大的不同点,XA事务的两阶段提交,一般锁定资源后持续到第二阶段的提交或者回滚后才释放资源。所以实际上AT模式降低了锁的范围,从而提升了分布式事务的处理效率。之所以能够实现这样的优化,是因为Seata记录了回滚日志,即便第二阶段发生异常,只需要根据UNDO_LOG中记录的数据进行回滚即可。
(二)AT模式第二阶段的原理分析
TC接收到所有事务分支的事务状态汇报之后,决定对全局事务进行提交或者回滚。
1. 事务提交
如果决定是全局提交,说明此时所有分支事务已经完成了提交,只需要清理UNDO_LOG日志即可。这也是和XA最大的不同点,其实在第一阶段各个分支事务的本地事务已经提交了,所以这里并不需要TC来触发所有分支事务的提交,如图所示。
图中事务提交的执行流程是:
1:分支事务收到TC的提交请求后把请求放入一个异步任务队列中,并马上返回提交成功的结果给TC。
2:从异步队列中执行分支,提交请求,批量删除相应UNDO_LOG日志。
在第一步中,TC并不需要同步知道分支事务的处理结果,所以分支事务才会采用异步的方式来执行。因为对于提交操作来说,分支事务只需要清除UNDO_LOG日志即可,而即便日志清除失败,也不会对整个分布式事务产生任何影响。
2.事务回滚
在整个全局事务链中,任何一个事务分支执行失败,全局事务都会进入事务回滚流程。所谓的回滚无非就是根据UNDO_LOG中记录的数据镜像进行补偿。如果全局事务回滚成功,数据的一致性就得到了保证。全局事务回滚流程如图所示。
所有分支事务接收到TC的事务回滚请求后,分支事务参与者开启一个本地事务
执行如下操作。
1:通过XID和branch ID查找到相应的UNDO_LOG记录。
2:数据校验:拿UNDO_LOG中的afterImage镜像数据与当前业务表中的数据进行比较,如果不同,说明数据被当前全局事务之外的动作做了修改,那么事务将不会回滚。
3:如果afterImage中的数据和当前业务表中对应的数据相同,则根据UNDO_LOG中的beforeImage镜像数据和业务SQL的相关信息生成回滚语句并执行:
4:提交本地事务,并把本地事务的执行结果(即分支事务回滚的结果)上报给TC
(三)事务的隔离性保证
我们知道,在ACID事务特性中,有一个隔离性,所谓的隔离性是指多个用户并发访问数据库时,数据库为每个用户开启的事务不能被其他事务的操作所干扰,多个并发事务之间要相互隔离。
在AT模式中,当多个全局事务操作同一张表时,它的事务隔离性保证是基于全局锁来实现的,下面分别针对写隔离与读隔离进行分析。
1. 写隔离
写隔离是为了在多个全局事务针对同一张表的同一个字段进行更新操作时,避免全局事务在没有被提交之前被其他全局事务修改。写隔离的主要实现是,在第一阶段本地事务提交之前,确保拿到全局锁。如果拿不到全局锁,则不能提交本地事务。并且获取全局锁的尝试会有一个范围限制,如果超出范围将会放弃全局锁的获取,并且回滚事务,释放本地锁。
以一个具体的案例来分析,假设有两个全局事务tx1和tx2,分别对tbl_repo表的count字段进行更新操作count的初始值为100。
tx1先执行,开启本地事务,拿到本地锁(数据库级别的锁),更新count=count-1=99。在本地事务提交之前,需要拿到该记录的全局锁,然后提交本地事务并释放本地锁。
tx2接着执行,同样先开启本地事务,拿到本地锁,更新count=count-1=98。本地事务提交之前,也尝试获取该记录的全局锁(全局锁由TC控制),由于该全局锁已经被tx1获取了,所以tx2需要等待以重新获取全局锁。如果全局事务执行整体提交,那么提交时序图如图所示。
如果tx1在第二阶段执行全局回滚,那么tx1需要重新获得该数据的本地锁,然后根据UNDO_LOG进行事务回滚。此时,如果tx2仍然在等待该记录的全局锁,同时持有本地锁,那么tx1分支事务的回滚会失败。tx1分支事务的回滚过程会一直重试,直到tx2的全局锁获取超时,放弃全局锁并回滚本地事务、释放本地锁,之后tx1的分支事务才会回滚成功。而在整个过程中,全局锁在tx1结束之前一直被txl持有,所以不会发生脏写的问题。
2. 读隔离
数据库有四种隔离级别
1:Read Uncommitted:读取未提交内容。
2:Read Committed:读取提交内容。
3:Repeatable Read:可重读。
4:Serializable:可串行化
在数据库本地事务隔离级别为Read Committed或者以上时,Seata AT事务模式的默认全局事务隔离级别是Read Uncommitted。在该隔离级别,所有事务都可以看到其他未提交事务的执行结果,产生脏读。这在最终一致性事务模型中是允许存在的,并且在大部分分布式事务场景中都可以接受脏读。
在某些特定场景中要求事务隔离级别必须为Read Committed,目前Seata是通过SelectForUpdateExecutor执行器对SELECT FOR UPDATE语句进行代理的,SELECT FOR UPDATE语句在执行时会申请全局锁。如图所示,如果全局锁已经被其他分支事务持有,则释放本地锁(回滚SELECT FORUPDATE语句的本地执行)并重试。在这个过程中,查询请求会被“BLOCKING”,直到全局锁被拿到,也就是读取的相关数据已提交时才返回。
参考
《Spring Cloud Alibaba 微服务原理与实战-第八章分布式事务》