Seata AT 模式
- 上一节中我们提到AT模式是基于XA事务模型演变过来的,所以他的整体机制也是一个改进版本的两阶段提交协议。
- 第一阶段:业务数据和回滚日志记录在同一个本地事务中提交,释放本地锁和链接资源
- 第二阶段:提交异步化,非常快速完成。回滚通过第一阶段的回滚日志进行反向补偿
- 接下来会解析整个执行流程中每一个阶段的具体实现 原来。同时为了更好的理解AT模式的工作机制,我们以产品表za_product来描述整个流程,表结构如下:
CREATE TABLE `za_product` (`id` bigint(20) NOT NULL AUTO_INCREMENT,`product_code` varchar(100) NOT NULL,`name` varchar(100) NOT NULL,`count` varchar(128) NOT NULL,PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8;
AT模式第一阶段
- 业务流程中执行库存扣减操作的数据库操作时候,Seata会基于数据源代理对原执行的SQL进行解析,代理的配置代码如下。
@Beanpublic DataSourceProxy dataSourceProxy(DruidDataSource druidDataSource){return new DataSourceProxy(druidDataSource);}
-
然后将业务数据在更新前后保存到undo_log 日志表中,利用本地事务的ACID特性,把业务数据的更新和回滚日志写入同一个本地事务中进行提交,完整的执行流程如图
-
日志表结构如下:
-- 注意此处0.7.0+ 增加字段 context
CREATE TABLE `undo_log` (`id` bigint(20) NOT NULL AUTO_INCREMENT,`branch_id` bigint(20) NOT NULL,`xid` varchar(100) NOT NULL,`context` varchar(128) NOT NULL,`rollback_info` longblob NOT NULL,`log_status` int(11) NOT NULL,`log_created` datetime NOT NULL,`log_modified` datetime NOT NULL,PRIMARY KEY (`id`),UNIQUE KEY `ux_undo_log` (`xid`,`branch_id`)
) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8;
- 假设AT分支事务的业务逻辑是如下SQl:
update za_product set count = count-1 where product_code = "GP202103221029"
- 如上则第一阶段的执行逻辑如下
- 通过DataSourceProxy对业务SQL进行解析,得到SQl的类型(UPDATE),表(za_product),条件(where product_code = “GP202103221029”)等相关信息
- 查询修改之前的数据镜像,根据解析得到条件信息生成查询语句,定位数据
select id,product_code,count name from za_product where product_code = "GP202103221029"
- 如上SQl得到产品的对应库存数据1000
- 执行业务SQL:更新这条记录的count= count-1
- 查询修改之后的数据镜像,根据镜像杰哥通过主键定位数据
select id,product_code,count name from za_product where id= 1
- 得到修改之后的镜像数据,此时count= 999
- 插入回滚日志:把前,后镜像数据以及业务SQl相关的信息组成一条回滚日志记录,插入undo_log表中,可以在对应数据库的undo_log表中获得数据,如下图:
- rollback_info字段是一个文件类型,包含了回滚的数据beforeImage和afterImage,使用官网数据,如下所示:
{"branchId": 2034562134,"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"
}
- 提交前,向TC注册分支事务:申请za_product表中主机值等于1 的记录的全局锁
- 本地事务提交:业务数据的更新和签名步骤中生成的undo_log一起提交
- 将本地事务提交的结果上报给TC
- 从AT的第一阶段实现原理看,分支的本地事务可以在第一阶段提交完成后马上释放本地事务所定的资源,这个是AT事务和XA最大的不同点,XA事务的两阶段提交,一般锁定资源后持续到第二阶段的提交或者回滚后才释放资源,所以实际上 AT模式降低了锁定的范围 ,从而大幅度提升了分布式事务处理的效率,之所以这样实现,是因为Seata记录了回滚日志,即使第二阶段发生异常,只需要更具undo_log记录的数据进行回滚即可。
AT模式第二阶段
- TC接受到所有事务分支的事务状态汇报后,决定对全局事务进行提交或者回滚。
事务提交
- 如果决定是全局提交,说明此事所有分支事务已经完成了提交,只需要清理undo_log日志即可,这也是和XA最大的不同点,因为在第一阶段各个分支的本地事务就已经提交了,所以这里并不需要TC来触发所有分支事务的提交,如下图:
- 如上图中事务提交流程:
- 分支事务收到TC的提交请求后吧请求放入一个异步队列,并马上返回提交成功的结果给TC
- 从异步队列中执行分支,提交请求,批量删除对应的undo_log日志。
- 第一个步骤中TC并不需要同步知道分支事务的处理结果,所以分支事务才会采用异步的方式来执行,因为对于提交操作来说,分支事务只需要清理undo_log中的日志即可,而即使清理失败,也不会对整个分布式事务产生任何影响。
事务回滚
- 整个全局事务链中,任何一个事务分支执行失败,全局事务都会进入事务回滚流程。此处回滚根据undo_log中记录的镜像数据进行补偿,如,回滚成功,则数据的一致性得到保持。如下流程:
- 所有分支事务接受到TC的事务回滚请求后,分支事务参与者开启一个本地事务,执行流程如下:
- 通过XID和branch ID查找到相应的undo_log记录
- 数据校验:拿undo_log中afterImage镜像数据与当前业务表中数据镜像比较(乐观锁),如果不同,说明数据被当前全局事务之外的动作修改了,那么事务不会回滚。
- 如果afterImage中数据和当前业务表中对应的数据相同,则根据undo_log中的beforeImage镜像数据和业务SQL的相关信息生成回滚语句并执行:
update za_product set count = 1000 where id = 1
- 提交本地事务,并把本地事务的执行结果(即分支事务回滚的结果)提交给TC。
关于事务隔离性保证
- ACID在事务特种,有一个隔离性,指多个用户并发的访问数据库的时候,数据库为每一个用户开启的事务不能被其他事务的操作所干扰,多个并发事务之间要相互隔离。
- 在AT模式中,当多个全局事务操作同一张表的时候,他的事务隔离的保证是基于全局锁来实现的,那么我们针对读隔离,写隔离进行说明
写隔离
-
写隔离是为了在多个全局事务针对同一个表的同一个字段进行更新操作的时候,避免全局事务在没有被提交之前被其他全局事务修改。写隔离的主要实现是,在第一阶段本地事务提交之前,确保拿到了全局锁。如果拿不到全局锁,则不能提交本地事务。并且获取全局锁的尝试会有一个范围控制,如果超出范围将会放弃全局锁的获取,并且回滚事务,释放本地锁。
-
我们用实际案例解析,假设两个全局事务tx1,tx2,分别对za_product表的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 结束之前一直被tx1 持有,所以不会发生脏写问题。全局事务回滚时序图如下:
读隔离
- 我们在数据库学习中指定有4种隔离级别:
- Read Uncommitted:读取为提交内容
- Read Committed:读取已经提交内容
- Repeatable read:可重复读
- Serializable:可串行化
- 在数据库本地事务隔离级别为Read Committed或者以上时候,Seata AT事务模式的默认全局隔离级别是Read Uncommitted,在该隔离级别,所有事务都可以看到其他未提交事务的执行结果,产生脏读。这在最终一致性事务模型中是允许存在的,并且在大部分分布式事务场景中可以接受脏读。
- 在某些特定场景中要求事务隔离级别必须Read Committed,目前Seata是通过SelectForUpdateExecutor执行器对SELECT FOR UPDATE语句进行代理的,SELECT FOR UPDATE 语句在执行时候回申请全局锁。如果全局锁已经被其他分支事务持有,则释放本地锁(回滚SELECT FOR UPDATE 语句的本地执行)并重试。在这个过程中,查询请求会被“BLOCKING”,直到全局锁被拿到,也就是读取的相关数据已经提交时候才返回。
上一篇:分布式事务框架seata