1.XA 模式的优缺点
我们前一章节演示了怎么用XA模式,也就是配置 data-source-proxy-mode: XA,XA实现的原理是基于两阶段提交 XA模式的优点是事务的强一致性,满足ACID原则,常用数据库都支持。实现简单,并且没有代码侵入。但是缺点也很明显,因为一阶段需要锁定数据库资源,等待二阶段结束才释放,性能是比较差的,并且依赖关系型数据库实现事务。 我们今天要讲的 AT 模式弥补了 XA 模式中资源锁定周期过长的缺点,相对于 XA 来说,性能更好一些,但缺点就是数据不是强一致,因为它的数据会真实的提交到数据库的,而如果后面做分支事务有问题的话,回滚靠的是日志来实现最终一致。
2.AT模式
2.1.Seata的AT模式
AT模式,是seata的默认/独有模式,也是实际项目中比较常用的一种模式,他采用的也是两阶段提交,不过弥补了XA模式中资源锁定周期过长的缺点,相对于XA来说,性能更好一些,但缺点就是数据不是强一致,因为它的数据会真实的提交到数据库的,而如果后面做分支事务有问题的话,回滚靠的是日志来实现最终一致。
基本原理流程图:
阶段一RM的工作:
1.先会注册一个分支事务到事务协调者TC中
2.记录一个SQL更新前的快照和一个更新后的快照到undo_log日志表中
3.执行SQL并提交数据库事务
4.报告事务状态
阶段二RM的工作:
1.如果此时所有微服务都执行完,并且没有出现异常情况,事务协调者TC通知RM删除undo-log记录。
2.如果此时中途有微服务出现异常情况,则TC会通知RM根据undo-log记录的对应快照恢复数据到更新前。
2.2.AT案例演示
AT模式只需要将配置中的data-source-proxy-mode: XA改为AT,或者是直接去除这一行配置即可。
AT模式会使用到我们前面执行过脚本的几个数据库表:
镜像日志表:undo_log
分支事务表:branch_table
全局事务表:global_table
全局锁表:lock_table
全局事务表跟分支事务表是一对多的关系,一个全局事务对应多个分支事务。
我们测试一下,观察数据库表数据的变现,看是不是跟上面原理图中描述的一致。
2.2.1.测试正常流程
我们这里把余额表的数据修改回原来的money=1000、库存表的数据修改回 count = 10。
我们在这里打个断点
发起请求:http://localhost:8082/order?userId=user112233&commodityCode=storage112233&count=1&money=200
当断点停这里的时候,我们看db_account和db_order两个数据库的undo_log表,在这两个表中各自生成了一条日志记录,xid都为:192.168.3.1:8091:5215564685333463148,这说明他们确实是在同一个全局事务当中
再看订单表和余额表,可以看到数据库中都是提交了的。
我们再把断点放开,可以看到日志表的两条记录都被删除了,这也印证了我们上面的原理图
2.2.2.测试回滚流程
我们再次把余额表的数据修改回原来的money=1000、库存表的数据修改回 count = 10。
用样打个断点
发起请求:http://localhost:8082/order?userId=user112233&commodityCode=storage112233&count=11&money=200
这回请求的库存 count 为11,而库存为10,明显是不够的,所以肯定会报错
当上图断点到扣减库存这里的时候,我们看订单和余额对应的快照日志表,新增了各一条日志,可以看到他们的全局事务 xid 是相同的
我们看一下余额的快照信息 rollback_info:
这里主要记录了前置镜像beforeImage和后置镜像afterImage,也就是我们说的快照,可以看到他的valuew值,前置的(扣减前的余额)为1000,后置的(扣减后的余额)为800,它恢复的时候就是先查询后置的value是否跟现在的value相等,如果相等,则恢复成前置镜像的value值。如果不相等,默认他会在一定的时间内重试5次
json
{
"@class": "io.seata.rm.datasource.undo.BranchUndoLog",
"xid": "192.168.3.1:8091:5215564685333463152",
"branchId": 5215564685333463154,
"sqlUndoLogs": [
"java.util.ArrayList",
[
{
"@class": "io.seata.rm.datasource.undo.SQLUndoLog",
"sqlType": "UPDATE",
"tableName": "account_tbl",
"beforeImage": {
"@class": "io.seata.rm.datasource.sql.struct.TableRecords",
"tableName": "account_tbl",
"rows": [
"java.util.ArrayList",
[
{
"@class": "io.seata.rm.datasource.sql.struct.Row",
"fields": [
"java.util.ArrayList",
[
{
"@class": "io.seata.rm.datasource.sql.struct.Field",
"name": "id",
"keyType": "PRIMARY_KEY",
"type": 4,
"value": 1
},
{
"@class": "io.seata.rm.datasource.sql.struct.Field",
"name": "money",
"keyType": "NULL",
"type": 4,
"value": 1000
}
]
]
}
]
]
},
"afterImage": {
"@class": "io.seata.rm.datasource.sql.struct.TableRecords",
"tableName": "account_tbl",
"rows": [
"java.util.ArrayList",
[
{
"@class": "io.seata.rm.datasource.sql.struct.Row",
"fields": [
"java.util.ArrayList",
[
{
"@class": "io.seata.rm.datasource.sql.struct.Field",
"name": "id",
"keyType": "PRIMARY_KEY",
"type": 4,
"value": 1
},
{
"@class": "io.seata.rm.datasource.sql.struct.Field",
"name": "money",
"keyType": "NULL",
"type": 4,
"value": 800
}
]
]
}
]
]
}
}
]
]
}
再看订单表和余额表,订单已经创建完成、余额也已经扣减成功。
我们放开断点,可以看到库存的的服务报错了,那么前面的 db_order 的订单表和 db_account 的余额表就会回滚,而回滚就是根据 undo_log 表的镜像回滚
再看回滚后的数据
2.2.3.官网示例说明工作原理机制
以一个示例来说明整个 AT 分支的工作过程。
业务表:product
Field | Type | Key |
---|---|---|
id | bigint(20) | PRI |
name | varchar(100) | |
since | varchar(100) |
AT 分支事务的业务逻辑:
sql
update product set name = 'GTS' where name = 'TXC';
一阶段
过程:
- 解析 SQL:得到 SQL 的类型(UPDATE),表(product),条件(where name = 'TXC')等相关的信息。
- 查询前镜像:根据解析得到的条件信息,生成查询语句,定位数据。
sql
select id, name, since from product where name = 'TXC';
得到前镜像:
id | name | since |
---|---|---|
1 | TXC | 2014 |
- 执行业务 SQL:更新这条记录的 name 为 'GTS'。
- 查询后镜像:根据前镜像的结果,通过 主键 定位数据。
sql
select id, name, since from product where id = 1;
得到后镜像:
id | name | since |
---|---|---|
1 | GTS | 2014 |
- 插入回滚日志:把前后镜像数据以及业务 SQL 相关的信息组成一条回滚日志记录,插入到 UNDO_LOG 表中。
json
{
"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"
}
- 提交前,向 TC 注册分支:申请 product 表中,主键值等于 1 的记录的 全局锁 。
- 本地事务提交:业务数据的更新和前面步骤中生成的 UNDO LOG 一并提交。
- 将本地事务提交的结果上报给 TC。
二阶段-回滚
- 收到 TC 的分支回滚请求,开启一个本地事务,执行如下操作。
- 通过 XID 和 Branch ID 查找到相应的 UNDO LOG 记录。
- 数据校验:拿 UNDO LOG 中的后镜与当前数据进行比较,如果有不同,说明数据被当前全局事务之外的动作做了修改。这种情况,需要根据配置策略来做处理,默认会有重试机制。
- 根据 UNDO LOG 中的前镜像和业务 SQL 的相关信息生成并执行回滚的语句:
sql
update product set name = 'TXC' where id = 1;
- 提交本地事务。并把本地事务的执行结果(即分支事务回滚的结果)上报给 TC。
二阶段-提交
- 收到 TC 的分支提交请求,把请求放入一个异步任务的队列中,马上返回提交成功的结果给 TC。
- 异步任务阶段的分支提交请求将异步和批量地删除相应 UNDO LOG 记录。
那么小伙伴们到这里是否有疑问,要是以上2.2.1的示例中存在并发请求,当线程A刚完成扣减余额1000-200=800,但库存还没扣减,这时候线程B来了,线程B读到的余额为800,它也进行了扣减800-200=600,而这时候线程A扣库存出现了异常,线程A回滚了,那这里线程B是不是就脏读了?
2.4.AT模式脏读问题解决方案
我们看看官网开发者指南里AT模式的读隔离描述:
在数据库本地事务隔离级别 读已提交(Read Committed) 或以上的基础上,Seata(AT 模式)的默认全局隔离级别是 读未提交(Read Uncommitted) 。
如果应用在特定场景下,必需要求全局的 读已提交 ,目前 Seata 的方式是通过 SELECT FOR UPDATE 语句的代理。
SELECT FOR UPDATE 语句的执行会申请 全局锁 ,如果 全局锁 被其他事务持有,则释放本地锁(回滚 SELECT FOR UPDATE 语句的本地执行)并重试。这个过程中,查询是被 block 住的,直到 全局锁 拿到,即读取的相关数据是 已提交 的,才返回。
出于总体性能上的考虑,Seata 目前的方案并没有对所有 SELECT 语句都进行代理,仅针对 FOR UPDATE 的 SELECT 语句。
以上是官网的描述。
其实官网的描述已经说的很清楚了,如果你还是不太清楚,我给你解析一下官网提供的这个图,你应该就明白了。
首先它有两个线程tx1和tx2,初始的的业务有一条数据为id=1、m=1000。
1.tx1线程开始执行,获取本地锁,更新id=1的m数据为m-100=900。
2.tx线程获取全局锁,提交本地事务并释放本地锁。
3.这时候线程tx2来了,它去查询m并在查询的时候加上了 for update 也就是尝试申请一个全局锁,但这时候tx1还没有执行完,所以tx2拿不到全局锁。
4.tx2释放本地锁并再次重试执行查询,重请期间tx2一直会处于阻塞状态,直到获取到tx1释放的全局锁并查询到tx1已回滚或已提交的数据m。也就是读已提交。
在分布式事务中实现读已提交的代价是很高的,效率比起读未提交差别很大,所以 Seata 默认并没有开启,当只有你业务上确实需要数据强一致时才有开启的必要。
2.5.AT模式脏写问题
seata 有没有脏写的问题? 这个问题我们可以直接明确的回答没有,那 seata 是怎么解决AT模式脏写问题的呢? 这个在官网上也有解答,他使用的是全局锁,我们一起来看一下。
官网开发者指南里AT模式的写隔离描述:
- 一阶段本地事务提交前,需要确保先拿到 全局锁 。
- 拿不到 全局锁 ,不能提交本地事务。
- 拿 全局锁 的尝试被限制在一定范围内,超出范围将放弃,并回滚本地事务,释放本地锁。
以一个示例来说明:
两个全局事务 tx1 和 tx2,分别对 a 表的 m 字段进行更新操作,m 的初始值 1000。
tx1 先开始,开启本地事务,拿到本地锁,更新操作 m = 1000 - 100 = 900。本地事务提交前,先拿到该记录的 全局锁 ,本地提交释放本地锁。 tx2 后开始,开启本地事务,拿到本地锁,更新操作 m = 900 - 100 = 800。本地事务提交前,尝试拿该记录的 全局锁 ,tx1 全局提交前,该记录的全局锁被 tx1 持有,tx2 需要重试等待 全局锁 。
tx1 二阶段全局提交,释放 全局锁 。tx2 拿到 全局锁 提交本地事务。
如果 tx1 的二阶段全局回滚,则 tx1 需要重新获取该数据的本地锁,进行反向补偿的更新操作,实现分支的回滚。
此时,如果 tx2 仍在等待该数据的 全局锁 ,同时持有本地锁,则 tx1 的分支回滚会失败。分支的回滚会一直重试,直到 tx2 获取 全局锁 等待超时,放弃 全局锁 并回滚本地事务释放本地锁,tx1 的分支回滚最终成功。
因为整个过程 全局锁 在 tx1 结束前一直是被 tx1 持有的,所以不会发生 脏写 的问题。
TCC模式下节讲,喜欢的话记得三连哟!!!关注不迷路,你的支持就是我的动力~~~