一、Seata 分布式事务解决方案
1.1、XA 模式
1.1.1、XA模式理论
XA 模式是 X/Open 组织定义的一个分布式事务处理的标准,可以认为是分布式事务领域最早的标准了,所以几乎所有主流的数据库(mysql、oracle......)都是实现了这种标准. 换言之,这些数据库内部已经能够基于 XA 模式实现分布式事务了.
这种模式把分布式定义成了以下两个阶段
第一阶段:
这一阶段也叫做准备阶段,事务协调者 会向 资源管理器 发起一个准备的请求,也就是告诉他:“你可以去执行业务 sql 了啊,但是执行完了以后不可以提交!”. 随后,资源管理器 就执行数据库业务,然后将执行结果告诉 事务协调者(执行成功了,就是就绪状态;失败了,就是 fail 状态),这样 事务协调者 就可以根据结果判断下一个阶段要干什么.
第二阶段:
事务协调者收到 RM 的反馈之后,就会进行判断. 如果都执行成功了,就通知这些 RM:“你们可以提交了啊”,然后 RM 收到请求,就把事务提交了,整个事务也就结束了;如果有任意一个服务执行失败了,那么事务协调者就会通知这些RM,让他们全部回滚.
可以看出 XA 模式是一种强一致性的事务,软可用性,因为事务没有提交的时候,拿不到数据库锁,是不能进行其他操作的.
1.1.2、Seata 框架中的 XA 模式
Seata 的 XA 模式和上述讲的 XA 模式大体相似,只是多了一个 TM 的概念. 如下图
第一阶段:
Seata 实际上就是多了一个 TM 的概念, 也就是事务管理者. TM 一上来就是要去注册全局事务,作为分布式事务的入口,接下来就会去调用各个分支事务. 每个分支事务中又有一个 RM,RM 就回去 TC 里面注册分支事务,然后执行业务 sql,但是执行完后不能提交,只是去报告一下事务的状态. 到此,第一阶段结束.
第二阶段:
TM 这里的入口方法执行完了以后,就要去告诉 TC 说:“我这边执行完了,接下来就看你那边的情况了~”. 随后 TC 就会去检查各个分支事务报告的状态,然后向 RM 发送一个信号——如果第一阶段都执行成功了,那么就提交,反之,则进行回滚. RM 收到信号之后就可以进行 提交/回滚 事务了.
1.1.3、XA 模式的优缺点
优点:
1. 支持强一致:执行完业务 sql 以后,不进行提交事务,而是等到 TC 协调完后,在进行 提交/回**滚. **
2. 实现起来简单:因为数据库本身就支持 XA 模式,Seata 只是在数据库的 XA 模式上做了一层简单的封装. 如果只看核心部分,就没什么差别了.
缺点:
1. 弱可用性,性能差:第一阶段不提交,等待第二阶段才提交,这个过程中会占用数据库锁(相当于对系统资源的一种浪费). 一旦分支事务特别多的情况下,业务耗时就会很久.
2. 依赖数据库底层实现:虽然用起来简单了,但是如果我用的不是 mysql 这种关系型数据库,而是 redis 这种非关系型数据库就不行了.
1.2.4、实现Seata 的 XA 模式
a)修改 application 文件(每一个参与事务的微服务)
开启 seata 的 XA 模式
seata:
data-source-proxy-mode: XA # 开启数据源代理的XA模式
b)给发起全局事务中的入口方法添加 @GlobalTransactional 注解
当前案例的全局事务入口是 订单服务中的 创建订单的 create 方法(案例架构如下图).
这里只需要在 create 方法上添加 @GlobalTransactional 注解即可
@Override
@GlobalTransactional
public Long create(Order order) {
// 创建订单
orderMapper.insert(order);
try {
// 扣用户余额
accountClient.deduct(order.getUserId(), order.getMoney());
// 扣库存
storageClient.deduct(order.getCommodityCode(), order.getCount());
} catch (FeignException e) {
log.error("下单失败,原因:{}", e.contentUTF8(), e);
throw new RuntimeException(e.contentUTF8(), e);
}
return order.getId();
}
c)重启服务并测试
Ps:这里不要忘了先启动 Nacos 和 TC 服务.
以下分别为用户余额和库存数量.
- 这里使用 Postman 发送以下请求(减少 9 个库存数量,用户余额减少 200)
http://localhost:8082/order?userId=user202103032042012&commodityCode=100202003032041&count=9&money=200
- 随后可以看到订单微服务返回 500 错误.
在扣款微服务中,先进行了扣款业务,但是随后 rm 又执行了回滚
在库存微服务中,可以看到扣减失败.
最后观察数据库中数据,可以看到事务已经回滚如初.
1.2、AT 模式
1.2.1、AT 模式理论
AT 模模式也是分阶段性提交的事务模型. 他的出现正式为了解决 XA 模式中资源锁定周期过长的一个缺陷.
AT 模式也分为两个阶段
第一阶段:
TM 去开启全局事务,作为分布式事务的入口,接着又会调用每一个分支事务 ,然后每个分支事务里的 rm 都会去注册分支事务,并执行本地的业务 sql,然后直接提交(这里就和 XA 不一样了). 因此 AT 模式就不需要像 XA 那样锁定资源,性能上要优于 XA 模式.
这里直接提交,万一有人失败了呢,没办法回滚不就导致状态不一致了吗?
实际上 AT 模式在执行 sql 之前,rm 会给当前数据生成一个快照,这个快照的名字叫 “undo log”,这就像是 redis 中 RDB 持久化时也会生成快照,那么即使服务重启,也可以根据快照恢复数据. 那么这样就即使有分支事务执行 sql 的时候失败了,也可以根据快照恢复如初,大胆提交就对了.
第二阶段:
TM 看到业务结束了,就会去通知 TC,那么 TC 就会判断是提交还是回滚. 如果分支事务的状态都是成功的,那就可以把第一阶段准备的快照给删了(删快照这个动作是异步的,因为第一阶段都成功了,也提交了,后面的事情就可以用一个线程独立去做,提高了效率). 如果第一阶段有人失败了,就要基于 undo log 恢复数据,恢复以后这个 log 也就没用了,最后也会删除.
问题:XA模式 和 AT模式 的区别?
XA模式在第一阶段的时候不会进行事务的提交,而 AT 模式执行完业务 sql 之后会立即提交事务,不会锁定资源,因此性能会好一些.
XA 依赖于数据库的机制来做回滚,而 AT 模式因为已经提交了,就不能回滚,他是通过给自己生成快照的方式来实现数据的恢复.
XA 模式是强一致,而 AT 是最终一致,因为 AT 在业务 sql 执行之后直接提交了,有人失败,有人成功,那么这个时候状态肯定是不一致的,也就是一个软状态,只有在第二阶段,基于快照恢复了数据,才能到达一个最终一致的效果.
1.2.2、AT 模式脏写问题
AT 模式在第一阶段执行完业务 sql 以后会直接提交,那么资源锁定的周期就比较短,效率高,但也正因为他提前释放了锁,就导致在高并发的场景下,会出现安全问题~
例如我这里有一个用户余额表,里面记录了 money 字段,表示余额.
- 现在有一个线程开启了事务1,首先他会去获取数据库锁,接着生成快照(money = 100),然后执行业务 sql(把 money 值改成 90),最后提交事务,锁也就释放了.
- 此时,另一个线程开启了 事务2 ,拿到锁以后保存快照(money = 90),执行业务 sql(把 money 值改成 80),最后提交释放锁.
到了第二阶段,假如说 事务1 要进行回滚(通过快照恢复数据),但是 事务1 的快照数据 money = 100!这个时候就出问题了,恢复了以后相当于 事务2 这哥们啥也没做. 这就是所谓的脏写问题.
1.2.3、解决办法:写隔离
上述过程出现的问题,归根结底还是隔离性的原因,如果第一阶段和第二阶段整体都是一个锁定状态,别人根本无法插入进来,因此 AT 模式就引入了一个东西叫 “全局锁”.
Ps:全局锁由 TC 来控制,用来记录当前哪个事务在操作哪种表的哪一行数据. 这张表了主要有 事务id、表名字、pk主键(记录这张表的哪一行数据).
- 有了全局锁,那么在刚刚上述栗子的 事务1中,执行完业务 sql 就会去获取全局锁,就表明这张表的这行数据是我 事务1 才能拥有的,此时就可以大胆的提交事务.
- 当 事务2 执行完业务 sql 之后,也尝试去获取全局锁,但是由于 事务1 已经获取了,因此 事务2 就只能阻塞等待 事务1 释放锁. 事务1 想要释放全局锁得等到第二阶段结束,但是现在 事务2 又持有 DB 锁,事务1 也拿不到 DB 锁,因此事务就互相等待,直到 事务2 等待超时,就会进行回滚并释放 DB 锁.
这样也就实现了 事务1 在执行第一阶段和第二阶段的时候,任何人都不能来执行,起到了很好的 隔离效果.
问题一:有人可能就会说,那这不跟 XA 模式一样了?也锁定资源,这样别人也无法访问,效率不久下降了么?
这里实际上是有一个 锁粒度 上的差别. XA 模式中是数据库锁不释放,意味着任何人都不能访问你这数据库的数据,而 AT 中的全局锁只是不能任何人操作这个表中的某一行数据,也就是说,同样是 account 表,你可以修改这张表其他所有不是余额的字段. 因此效率上还是高很多的.
问题二:有人可能就会说,隔离不彻底啊,可能有一种极端情况,就是在修改 money 的过程中,有一个其他 非Seata 管理的事务 也来修改 money 字段,这时候人家又不用获取全局锁,也可能出现脏写的问题
确实有可能,但是可能性非常低,有以下两个原因:
- 全局事务大多数情况下都是成功的,比如我这边转账,大多数情况下我不能明知道钱不够还去转超额的钱吧,那么第二阶段几乎就不会回滚.
- 分布式事务耗时本来就长,或者说并发本来就比较低,像这种见缝插针的事务就更少见了.
但即使概论低,Seata 也还是考虑这种情况的. Seata 在管理的事务的时候,是保存了两个快照的,第一个是更新前的快照(money = 100),另一个是更新后的快照(money = 90). 在 Seata 管理的 事务1 执行到第二阶段的时候,他就会去对比数据库中的字段值 和 after-image(更新后的快照)是否一致,如果不一致,就知道其中有人动了手脚,此时他可能就会发一个短信、邮件、电话告诉你,需要人工介入了.
1.2.4、AT 模式的优缺点
优点:
高性能:第一阶段完成后直接提交,释放资源比较早,数据库的锁定时间短.
写隔离:利用全局锁机制,不仅实现了隔离,性能也依旧维持.
使用简单,无代码侵入:Seata 框架自动完成回滚和提交.
缺点:
软状态:由于第一阶段执行业务 sql 可能有人成功有人失败,并且已经提交,这种情况下,并没有达成一致,只有第二阶段快照恢复才最终一致.
影响性能:虽然相比 XA模式,性能要好很多,但是框架的快照功能多多少少都会影响一点性能.
1.2.5、实现 AT 模式
a)建表
AT 模式下会引入全局锁,因此需要 lock_table 表来记录(在 TC 服务关联的数据库中创建).
DROP TABLE IF EXISTS `lock_table`;
CREATE TABLE `lock_table` (
`row_key` varchar(128) CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL,
`xid` varchar(96) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL,
`transaction_id` bigint(20) NULL DEFAULT NULL,
`branch_id` bigint(20) NOT NULL,
`resource_id` varchar(256) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL,
`table_name` varchar(32) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL,
`pk` varchar(36) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL,
`gmt_create` datetime NULL DEFAULT NULL,
`gmt_modified` datetime NULL DEFAULT NULL,
PRIMARY KEY (`row_key`) USING BTREE,
INDEX `idx_branch_id`(`branch_id`) USING BTREE
) ENGINE = InnoDB CHARACTER SET = utf8 COLLATE = utf8_general_ci ROW_FORMAT = Compact;
AT 模式还需要保存快照,因此需要 undo_log 表来记录(在 微服务 关联的数据库中导入).
DROP TABLE IF EXISTS `undo_log`;
CREATE TABLE `undo_log` (
`branch_id` bigint(20) NOT NULL COMMENT 'branch transaction id',
`xid` varchar(100) CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL COMMENT 'global transaction id',
`context` varchar(128) CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL COMMENT 'undo_log context,such as serialization',
`rollback_info` longblob NOT NULL COMMENT 'rollback info',
`log_status` int(11) NOT NULL COMMENT '0:normal status,1:defense status',
`log_created` datetime(6) NOT NULL COMMENT 'create datetime',
`log_modified` datetime(6) NOT NULL COMMENT 'modify datetime',
UNIQUE INDEX `ux_undo_log`(`xid`, `branch_id`) USING BTREE
) ENGINE = InnoDB CHARACTER SET = utf8 COLLATE = utf8_general_ci COMMENT = 'AT transaction mode undo table' ROW_FORMAT = Compact;
b)修改 application.yml 文件
将事务模式修改为 AT 模式即可.
seata:
data-source-proxy-mode: AT # 开启数据源代理的AT模式
c)重启服务并测试
以下分别为用户余额和库存数量.
- 这里使用 Postman 发送以下请求(减少 9 个库存数量,用户余额减少 200)
- 随后可以看到订单微服务返回 500 错误.
- 扣款服务中进行扣款业务,最后回滚
- 检查数据库数据回滚成功~
版权归原作者 陈亦康 所有, 如有侵权,请联系我们删除。