分布式事务(六):Seata之AT模式原理
1、整体机制
Seata AT模型,基于本地ACID事务的关系型数据库实现。
两阶段提交协议机制如下:
一阶段:业务数据和回滚日志在同一个本地事务中提交,释放本地锁和连接资源
二阶段:提交异步化,非常快速地完成;回滚通过一阶段的回滚日志进行反向补偿
2、事务状态
global_table是seata的全局事务表,可以通过global_table表中status字段知悉全局事务处于哪个状态。
2.1、全局事务状态表
状态
|
码值
|
备注
|
全局事务开始(Begin)
|
1
|
此状态可以接受新的分支事务注册
|
全局事务提交中(Committing)
|
2
|
这个状态会随时改变
|
全局事务提交重试(CommitRetry)
|
3
|
在提交异常被解决后尝试重试提交
|
全局事务回滚中(Rollbacking)
|
4
|
正在重新回滚全局事务
|
全局事务回滚重试中(RollbackRetrying)
|
5
|
在全局回滚异常被解决后尝试事务重试回滚中
|
全局事务超时回滚中(TimeoutRollbacking)
|
6
|
全局事务超时回滚中
|
全局事务超时回滚重试中(TimeoutRollbackRetrying)
|
7
|
全局事务超时回滚重试中
|
异步提交中(AsyncCommitting)
|
8
|
异步提交中
|
二阶段已提交(Committed)
|
9
|
二阶段已提交,此状态后全局事务状态不会再改变
|
二阶段提交失败(CommitFailed)
|
10
|
二阶段提交失败
|
二阶段决议全局回滚(Rollbacked)
|
11
|
二阶段决议全局回滚
|
二阶段全局回滚失败(RollbackFailed)
|
12
|
二阶段全局回滚失败
|
二阶段超时回滚(TimeoutRollbacked)
|
13
|
二阶段超时回滚
|
二阶段超时回滚失败(TimeoutRollbackFailed)
|
14
|
二阶段超时回滚失败
|
全局事务结束(Finished)
|
15
|
全局事务结束
|
二阶段提交超时(CommitRetryTimeout)
|
16
|
二阶段提交因超过重试时间限制导致失败
|
二阶段回滚超时(RollbackRetryTimeout)
|
17
|
二阶段回滚因超过重试时间限制导致失败
|
未知状态(UnKnown)
|
0
|
未知状态
|
2.2、分支事务状态表
状态
|
码值
|
备注
|
分支事务注册(Registered)
|
1
|
向TC注册分支事务
|
分支事务一阶段完成(PhaseOne_Done)
|
2
|
分支事务一阶段业务逻辑完成
|
分支事务一阶段失败(PhaseOne_Failed)
|
3
|
分支事务一阶段业务逻辑失败
|
分支事务一阶段超时(PhaseOne_Timeout)
|
4
|
分支事务一阶段处理超时
|
分支事务二阶段已提交(PhaseTwo_Committed)
|
5
|
分支事务二阶段提交
|
分支事务二阶段提交失败重试(PhaseTwo_CommitFailed_Retryable)
|
6
|
分支事务二阶段提交失败重试
|
分支事务二阶段提交失败不重试(PhaseTwo_CommitFailed_Unretryable)
|
7
|
分支事务二阶段提交失败不重试
|
分支事务二阶段已回滚(PhaseTwo_Rollbacked)
|
8
|
分支事务二阶段已回滚
|
分支事务二阶段回滚失败重试(PhaseTwo_RollbackFailed_Retryable)
|
9
|
分支事务二阶段回滚失败重试
|
分支事务二阶段回滚失败不重试(PhaseTwo_RollbackFailed_Unretryable)
|
10
|
二阶段提交失败
|
未知状态(UnKnown)
|
0
|
未知状态
|
2.3、全局事务超时回滚中(TimeoutRollbacking)
当某个seata全局事务执行过程中,无法完成业务。
TC中的一个定时任务(专门用来寻找已超时的全局事务),发现该全局事务未回滚完成,就会将此全局事务改为全局事务超时回滚中(TimeoutRollbacking),开始回滚,直到回滚完毕后删除global_table数据。
当全局事务处于该状态,请排查为何业务无法在限定时间内完成事务。若确实无法完成,应调大全局事务超时时间。(如排查一切正常,请检查tc集群时区与数据库是否一致,若不一致请改为一致)。
3、写隔离
3.1、写隔离原理
一阶段本地事务提交前,需要确保先拿到全局锁;
拿不到全局锁,不能提交本地事务;
拿全局锁的尝试被限制在一定范围内,超出范围将放弃,并回滚本地事务,释放本地锁。
3.2、提交回滚原理
两个全局事务tx1和tx2,分别对 a 表的 m 字段进行更新操作,m的初始值1000。
3.2.1、事务提交原理
tx1先开始,开启本地事务,拿到本地锁,更新操作 m = 1000 - 100 = 900。本地事务提交前先拿到该记录的全局所,本地提交释放本地锁。
tx2后开始,开启本地事务,拿到本地锁,更新操作 m = 900 - 100 = 800。本地事务提交前,尝试拿该记录的全局锁,tx1全局提交前,该记录的全局锁被tx1持有,tx2需要重试等待 全局锁。
tx1二阶段全局提交,释放全局锁;tx2 拿到全局锁 提交本地事务。
3.2.2、事务回滚原理
若tx1的二阶段全局回滚,则tx1需要重新获取该数据的本地锁,进行反向补偿的更新操作,实现分支的回滚。此时,如果tx2仍在等待该数据的 全局锁,同时持有本地锁,则tx1的分支回滚会失败。tx1分支的回滚会一直重试,直到tx2的全局锁 等锁超时,放弃全局锁并回滚本地事务释放本地锁,tx1的分支回滚最终成功。
整个过程全局锁在tx1结束前一直是被tx1持有,所以不会发生脏写问题。
4、读隔离
数据库本地事务隔离级别 读已提交(Read Committed) 或 以上的基础上,Seata (AT模式)的默认全局隔离级别是 读未提交(Read Uncommitted)。
若应用在特定场景下,必须要求全局的读已提交,Seata2.0的方式是通过 SELECT FOR UPDATE 语句的代理。
SELECT FOR UPDATE 语句的执行会申请 全局锁,若全局锁被其他事务持有,则释放本地锁(回滚 SELECT FOR UPDATE语句的本地执行)并重试。这个过程中,查询会被block住的,直到全局锁拿到,即读取的相关数据是已提交的才返回。
Seata2.0目前的并没有对所有的SELECT语句进行代理,仅针对 FOR UPDATE 的 SELECT 语句。
5、工作机制
用如下示例表示AT分支的工作过程:
业务表 product:
Field
|
Type
|
Key
|
id
|
bigint(20)
|
PRI
|
name
|
varchar(100)
|
|
since
|
varchar(100)
|
AT 分支事务的业务逻辑:
update product set name = 'GTS' where name = 'TXC';
5.1、一阶段
1、解析SQL:得到SQL的类型(UPDATE)、表(product)、条件(where name = 'TXC')等相关的信息;
2、查询前镜像:根据解析得到的条件信息,生成查询语句,定位数据;
select id, name, since from product where name = 'TXC';
得到前镜像:
id
|
name
|
since
|
1
|
TXC
|
2014
|
3、执行业务SQL:更新这条记录的name为'GTS';
4、查询后镜像:根据前镜像的结果,通过 主键 定位数据;
select id, name, since from product where id = 1;
得到后镜像:
id
|
name
|
since
|
1
|
GTS
|
2014
|
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、提交前,向 TC 注册分支:申请 product 表中,主键值等于1的记录的全局锁;
7、本地事务提交:业务数据的更新和前面步骤中生成的UNDO LOG一并提交;
8、将本地事务提交的结果上报给TC。
5.2、二阶段
5.2.1、回滚
1、收到 TC 的分支回滚请求,开启一个本地事务,执行如下操作;
2、通过 XID 和 Branch ID查找到相应的 UNDO LOG 记录;
3、数据校验:拿 UNDO LOG 中的后镜与当前数据进行比较,如有不同,说明数据被当前全局事务之外的动作做了修改。
4、根据 UNDO LOG 中的前镜像和业务SQL的相关信息生成并执行回滚的语句:
update product set name = 'TXC' where id = 1;
5、提交本地事务,并把本地事务的执行结果(即分支事务回滚的结果)上报给TC。
5.2.2、提交
1、收到 TC 的分支提交请求,把请求放入一个异步任务的队列中,马上返回提交成功的结果给TC;
2、异步任务阶段的分支提交请求将异步和批量地删除相应 UNDO LOG 记录。
5.3、回滚日志表
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;