1、分布式事务问题
1.1、本地事务
本地事务,也就是传统的单机事务。在传统数据库事务中,必须要满足四个原则:
1.2、分布式事务
在分布式系统下,一个业务跨越多个服务或数据源,每个服务都是一个分支事务,要保证所有分支事务最终状态一致,这样的事务就是分布式事务。
分布式事务,就是指不是在单个服务或单个数据库架构下产生的事务,例如:
- 跨数据源的分布式事务
- 跨服务的分布式事务
- 综合情况
在数据库水平拆分、服务垂直拆分之后,一个业务操作通常要跨多个数据库、多个服务才能完成。例如电商行业中比较常见的下单付款案例,包括下面几个行为:
1、创建新订单
2、扣减商品库存
3、从用户账户余额扣除金额
完成下面的操作需要访问三个不同的微服务和三个不同的数据库:
订单的创建、库存的扣减、账户扣款在每一个服务和数据库内是一个本地事务,可以保证 ACID 原则。
但是当我们把这三件事情看作一个"业务",要满足保证“业务”的原子性,要么所有操作全部成功,要么全部失败,不允许出现部分成功部分失败的现象,这就是分布式系统下的事务。
此时 ACID 难以满足,这就是分布式事务要解决的问题。
1.3、演示分布式事务问题
如下图有一个微服务项目:
其中:seata-demo 是父工程,负责管理项目依赖
- order-service:订单服务,负责管理订单。创建订单时,需要调用 account-service 和 storage-service
- account-service:账户服务,负责管理用户的资金账户。提供扣减余额的接口
- storage-service:库存服务,负责管理商品库存。提供扣减库存的接口
我们去创建订单,发送 POST 请求:
http://localhost:8082/order?userId=user202103032042012&commodityCode=100202003032041&count=20&money=200
测试发现,当库存不足时,此时账户余额已经扣减,并不会回滚,出现了分布式事务问题。
2、理论基础
要解决分布式事务问题,需要一些分布式系统的基础知识作为理论指导。
2.1、CAP定理
1998年,加州大学的计算机科学家 Eric Brewer 提出,分布式系统有三个指标:
- Consistency(一致性)
- Availability(可用性)
- Partition tolerance (分区容错性)
它们的第一个字母分别是 C、A、P。
Eric Brewer 说,分布式系统无法同时满足这三个指标。这个结论就叫做 CAP 定理。
Consistency 一致性
Consistency(一致性):用户访问分布式系统中的任意节点,得到的数据必须一致。
比如现在包含两个节点,其中的初始数据是一致的:
当我们修改其中一个节点的数据时,两者的数据产生了差异:
要想保证一致性,就必须实现 node01 到 node02 的数据同步:
Availability 可用性
Availability (可用性):用户访问集群中的任意健康节点,必须能得到响应,而不是超时或拒绝。
如图,有三个节点的集群,访问任何一个都可以及时得到响应:
当有部分节点因为网络故障或其它原因无法访问时,代表节点不可用:
Partition Tolerance 分区容错性
Partition(分区):因为网络故障或其它原因导致分布式系统中的部分节点与其它节点失去连接,形成独立分区。
Tolerance(容错):在集群出现分区时,整个系统也要持续对外提供服务。
矛盾
在分布式系统中,系统间的网络不能 100% 保证健康,一定会有故障的时候,而系统又必须保证对外提供服务。因此 Partition Tolerance 不可避免。
当节点接收到新的数据变更时,就会出现问题:
如果此时要保证一致性,就必须等待网络恢复,完成数据同步后,整个集群才对外提供服务,服务处于阻塞状态,不可用。
如果此时要保证可用性,就不能等待网络恢复,那 node01、node02 与 node03 之间就会出现数据不一致。
也就是说,在 P 一定会出现的情况下,A 和 C 之间只能实现一个。
总结
简述CAP定理内容?
- 分布式系统节点通过网络连接,一定会出现分区问题(P)
- 当分区出现时,系统的一致性(C)和可用性(A)就无法同时满足
思考:elasticsearch集群是 CP 还是 AP 呢?
ES集群出现分区时,故障节点会被剔除出集群,数据分片会重新分配到其它节点,保证数据一致。因此是低可用性,高一致性,属于 CP
2.2、BASE理论
BASE 理论是对 CAP 的一种解决思路,包含三个思想:
- Basically Available (基本可用):分布式系统在出现故障时,允许损失部分可用性,即保证核心可用。
- Soft State(软状态):在一定时间内,允许出现中间状态,比如临时的不一致状态。
- Eventually Consistent(最终一致性):虽然无法保证强一致性,但是在软状态结束后,最终达到数据一致。
2.3、解决分布式事务的思路
分布式事务最大的问题是各个子事务的一致性问题,因此可以借鉴 CAP定理 和 BASE理论,有两种解决思路:
- AP 模式:各子事务分别执行和提交,允许出现结果不一致,然后采用弥补措施恢复数据即可,实现最终一致。
- CP 模式:各个子事务执行后互相等待,同时提交,同时回滚,达成强一致。但事务等待过程中,处于弱可用状态。
前面我们所学的 Elasticsearch 集群就是 CP 模式,保证了数据的一致性。
解决分布式事务,各个子系统之间必须能感知彼此的事务状态,才能保证状态一致,因此需要一个事务协调者来协调每一个事务的参与者(子系统事务)。
这里的子系统事务,称为分支事务;有关联的各个分支事务在一起称为全局事务。
总结
简述 BASE 理论三个思想:
- 基本可用
- 软状态
- 最终一致
解决分布式事务的思想和模型:
- 全局事务:整个分布式事务
- 分支事务:分布式事务中包含的每个子系统的事务
- 最终一致思想:各分支事务分别执行并提交,如果有不一致的情况,再想办法恢复数据
- 强一致思想:各分支事务执行完业务不要提交,等待彼此结果。而后统一提交或回滚
3、初识Seata
Seata 是 2019 年 1 月份由蚂蚁金服和阿里巴巴共同开源的分布式事务解决方案。致力于提供高性能和简单易用的分布式事务服务,为用户打造一站式的分布式解决方案。
官网地址:http://seata.io/,其中的文档、播客中提供了大量的使用说明、源码分析。
3.1、Seata的架构
Seata 事务管理中有三个重要的角色:
- TC (Transaction Coordinator) - 事务协调者:维护全局和分支事务的状态,协调全局事务提交或回滚。
- TM (Transaction Manager) - 事务管理器:定义全局事务的范围、开启全局事务、提交全局事务或回滚全局事务。
- RM (Resource Manager) - 资源管理器:管理分支事务处理的资源(数据库),与 TC 交谈以注册分支事务和报告分支事务的状态,并驱动分支事务提交或回滚。
整体的架构如图:
Seata 基于上述架构提供了四种不同的分布式事务解决方案:
- XA 模式:强一致性分阶段事务模式,牺牲了一定的可用性,无业务侵入
- TCC 模式:最终一致的分阶段事务模式,有业务侵入
- AT 模式:最终一致的分阶段事务模式,无业务侵入,也是 Seata 的默认模式
- SAGA 模式:长事务模式,有业务侵入
无论哪种方案,都离不开 TC,也就是事务的协调者。
3.2、部署TC服务
1、下载
下载 seata-server 包,地址在:https://seata.io/zh-cn/blog/download.html
在非中文目录解压缩,其目录结构如下:
2、修改配置
修改 conf 目录下的 registry.conf 文件
# 注册中心
registry {
# tc服务的注册中心类型,这里选择nacos,也可以是eureka、zookeeper等
type = "nacos"
nacos {
# seata tc 服务注册到 nacos 的服务名称,可以自定义
application = "seata-tc-server"
# nacos服务地址
serverAddr = "*.*.*.*:8848"
# 当前服务所在的组
group = "DEFAULT_GROUP"
namespace = ""
# 集群名称
cluster = "SH"
# nacos客户端的用户名和密码
username = "nacos"
password = "nacos"
}
}
# 配置中心
config {
# 读取tc服务端的配置文件的方式,这里是从nacos配置中心读取,这样如果tc是集群,可以共享配置
type = "nacos"
# 配置nacos地址等信息
nacos {
# nacos服务地址
serverAddr = "*.*.*.*:8848"
namespace = ""
# 当前服务所在的组
group = "DEFAULT_GROUP"
# nacos客户端的用户名和密码
username = "nacos"
password = "nacos"
dataId = "seataServer.properties"
}
}
3、在 nacos 添加配置
为了让 TC 服务的集群可以共享配置,我们选择了 Nacos 作为统一配置中心。因此服务端配置文件 seataServer.properties
文件需要在 Nacos 中配好。
配置内容如下:
# 数据存储方式,db代表数据库
store.mode=db
store.db.datasource=druid
store.db.dbType=mysql
# 这是mysql8.0的驱动,mysql5使用的是com.mysql.jdbc.Driver
store.db.driverClassName=com.mysql.cj.jdbc.Driver
# 数据库地址、用户名、密码都需要修改成你自己的数据库信息
store.db.url=jdbc:mysql://1.12.65.61:3306/seata?useSSL=false&useUnicode=true&characterEncoding=utf-8&serverTimezone=GMT%2B8
store.db.user=root
store.db.password=283619
store.db.minConn=5
store.db.maxConn=30
store.db.globalTable=global_table
store.db.branchTable=branch_table
store.db.queryLimit=100
store.db.lockTable=lock_table
store.db.maxWait=5000
# 事务、日志等配置
server.recovery.committingRetryPeriod=1000
server.recovery.asynCommittingRetryPeriod=1000
server.recovery.rollbackingRetryPeriod=1000
server.recovery.timeoutRetryPeriod=1000
server.maxCommitRetryTimeout=-1
server.maxRollbackRetryTimeout=-1
server.rollbackRetryTimeoutUnlockEnable=false
server.undo.logSaveDays=7
server.undo.logDeletePeriod=86400000
# 客户端与服务端传输方式
transport.serialization=seata
transport.compressor=none
# 关闭metrics功能,提高性能
metrics.enabled=false
metrics.registryType=compact
metrics.exporterList=prometheus
metrics.exporterPrometheusPort=9898
4、创建数据库表
TC 服务在管理分布式事务时,需要记录事务相关数据到数据库中,你需要提前创建好这些表。
新建一个名为 seata
的数据库,运行 SQL:
SET NAMES utf8mb4;
SET FOREIGN_KEY_CHECKS = 0;
-- ----------------------------
-- 分支事务表
-- ----------------------------
DROP TABLE IF EXISTS `branch_table`;
CREATE TABLE `branch_table` (
`branch_id` bigint(20) NOT NULL,
`xid` varchar(128) CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL,
`transaction_id` bigint(20) NULL DEFAULT NULL,
`resource_group_id` varchar(32) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL,
`resource_id` varchar(256) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL,
`branch_type` varchar(8) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL,
`status` tinyint(4) NULL DEFAULT NULL,
`client_id` varchar(64) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL,
`application_data` varchar(2000) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL,
`gmt_create` datetime(6) NULL DEFAULT NULL,
`gmt_modified` datetime(6) NULL DEFAULT NULL,
PRIMARY KEY (`branch_id`) USING BTREE,
INDEX `idx_xid`(`xid`) USING BTREE
) ENGINE = InnoDB CHARACTER SET = utf8 COLLATE = utf8_general_ci ROW_FORMAT = Compact;
-- ----------------------------
-- 全局事务表
-- ----------------------------
DROP TABLE IF EXISTS `global_table`;
CREATE TABLE `global_table` (
`xid` varchar(128) CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL,
`transaction_id` bigint(20) NULL DEFAULT NULL,
`status` tinyint(4) NOT NULL,
`application_id` varchar(32) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL,
`transaction_service_group` varchar(32) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL,
`transaction_name` varchar(128) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL,
`timeout` int(11) NULL DEFAULT NULL,
`begin_time` bigint(20) NULL DEFAULT NULL,
`application_data` varchar(2000) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL,
`gmt_create` datetime NULL DEFAULT NULL,
`gmt_modified` datetime NULL DEFAULT NULL,
PRIMARY KEY (`xid`) USING BTREE,
INDEX `idx_gmt_modified_status`(`gmt_modified`, `status`) USING BTREE,
INDEX `idx_transaction_id`(`transaction_id`) USING BTREE
) ENGINE = InnoDB CHARACTER SET = utf8 COLLATE = utf8_general_ci ROW_FORMAT = Compact;
SET FOREIGN_KEY_CHECKS = 1;
这些表主要记录全局事务、分支事务、全局锁信息。
5、启动 TC 服务
进入 bin 目录,运行其中的 seata-server.sh 即可:
sh seata-server.sh
启动成功后,打开浏览器访问 Nacos 地址,然后进入服务列表页面,可以看到 seata-tc-server 的信息。
3.3、微服务集成Seata
引入依赖
首先,我们需要在微服务中引入 Seata 依赖:
<!-- seata依赖 -->
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-seata</artifactId>
<exclusions>
<!--版本较低,1.3.0,因此排除-->
<exclusion>
<artifactId>seata-spring-boot-starter</artifactId>
<groupId>io.seata</groupId>
</exclusion>
</exclusions>
</dependency>
<!--seata starter 采用1.4.2版本-->
<dependency>
<groupId>io.seata</groupId>
<artifactId>seata-spring-boot-starter</artifactId>
<version>${seata.version}</version>
</dependency>
修改配置文件
需要修改每个服务的 application.yml 文件,添加一些配置,例如在 order-service 服务中的 application.yml,配置 TC 服务信息,通过注册中心 Nacos,结合服务名称获取 TC 地址:
seata:
registry: # TC服务注册中心的配置,微服务根据这些信息去注册中心获取tc服务地址
type: nacos # 注册中心类型 nacos
nacos:
server-addr: 1.117.74.26:8848 # nacos服务地址
namespace: "" # namespace,默认为空
group: DEFAULT_GROUP # 分组,默认是DEFAULT_GROUP
application: seata-tc-server # seata服务名称
username: nacos
password: nacos
tx-service-group: seata-demo # 事务组名称
service:
vgroup-mapping: # 事务组与cluster的映射关系
seata-demo: SH
微服务如何根据这些配置寻找TC的地址呢?
我们知道注册到 Nacos 中的微服务,确定一个具体实例需要四个信息:namespace + group + serviceName + cluster
- namespace:命名空间,为空就是默认的 public
- group:分组
- serviceName:服务名称
- cluster:集群名称
以上四个信息,都可以在上面的 application.yml 文件中找到:
seata 客户端如何获取 TC 的 cluster 名称? 以 tx-service-group
的值为 key 到 vgroup-mapping
中查找。
结合起来,TC 服务的信息就是:public@DEFAULT_GROUP@seata-tc-server@SH
,这样就能确定 TC 服务集群了。然后就可以去 Nacos 拉取对应的实例信息了。
启动微服务后,Seata 控制台会显示连接上的服务:
4、XA模式
XA 规范 是 X/Open 组织定义的分布式事务处理(DTP,Distributed Transaction Processing)标准,XA 规范描述了全局的 TM 与局部的 RM 之间的接口,几乎所有主流的数据库都对 XA 规范提供了支持。
4.1、两阶段提交
XA 是规范,目前主流数据库都实现了这种规范,实现的原理都是基于两阶段提交。
正常情况:
异常情况:
一阶段:
- 事务协调者通知每个事物参与者执行本地事务
- 本地事务执行完成后报告事务执行状态给事务协调者,此时事务不提交,继续持有数据库锁
二阶段:
事务协调者基于一阶段的报告来判断下一步操作:
- 如果一阶段都成功,则通知所有事务参与者,提交事务
- 如果一阶段任意一个参与者失败,则通知所有事务参与者回滚事务
4.2、Seata的XA模型
Seata 对原始的 XA 模式做了简单的封装和改造,以适应自己的事务模型,基本架构如下图:
RM 一阶段的工作:
1、注册分支事务到TC
2、执行分支业务 SQL,但不提交
3、报告执行状态到 TC
TC 二阶段的工作:
TC 检测各分支事务的执行状态
- 如果都成功,通知所有 RM 提交事务
- 如果有失败,通知所有 RM 回滚事务
RM 二阶段的工作:
- 接收 TC 指令,提交或回滚事务
4.3、优缺点
XA 模式的优点是什么?
- 事务的强一致性,满足 ACID 原则。
- 常用数据库都支持,实现简单,并且没有代码侵入。
XA 模式的缺点是什么?
- 因为第一阶段需要锁定数据库资源,等待第二阶段结束才释放,性能较差。
- 依赖关系型数据库实现事务。
4.4、实现XA模式
Seata 的 starter 已经完成了对 XA 模式的自动装配,实现非常简单,步骤如下:
1、修改每一个微服务的 application.yml 文件(每个参与事务的微服务),开启XA模式:
seata:
data-source-proxy-mode: XA # 开启数据源代理的XA模式
2、给发起全局事务的入口方法添加 @GlobalTransactional
注解,本例中是 OrderServiceImpl 中的 create 方法
3、重启服务并测试
重启 order-service,再次测试,发现无论怎样异常情况,三个微服务都能成功回滚。
5、AT模式
AT 模式同样是分阶段提交的事务模型,不过却弥补了 XA 模型中资源锁定周期过长的缺陷。
5.1、Seata的AT模型
基本流程图:
阶段一 RM 的工作:
- 注册分支事务
- 记录 undo-log(数据快照)
- 执行业务 SQL 并提交事务
- 报告事务状态
阶段二提交时 RM 的工作:
- 删除 undo-log 即可
阶段二回滚时 RM 的工作:
- 根据 undo-log 恢复数据到更新前
5.2、流程梳理
我们用一个真实的业务来梳理下 AT 模式的原理。
比如,现在有一个数据库表,记录用户余额
id | money |
---|---|
1 | 100 |
其中一个分支业务要执行的 SQL 为:
update tb_account set money = money - 10 where id = 1
AT模式下,当前分支事务执行流程如下:
一阶段:
1、TM 发起并注册全局事务到 TC
2、TM 调用分支事务
3、分支事务准备执行业务 SQL
4、RM 拦截业务 SQL,根据 where 条件查询原始数据,形成快照。
{
"id": 1, "money": 100
}
5、RM 执行业务 SQL,提交本地事务,释放数据库锁。此时 money = 90
6、RM 报告本地事务状态给 TC
二阶段:
1、TM 通知 TC 事务结束
2、TC 检查分支事务状态:
- 如果都成功,则立即删除快照;
- 如果有分支事务失败,需要回滚。读取快照数据
{"id": 1, "money": 100}
,将快照恢复到数据库。此时数据库再次恢复为 100。
流程图:
5.3、AT 与 XA 的区别
简述 AT 模式与 XA 模式最大的区别是什么?
- XA 模式一阶段不提交事务,锁定资源;AT 模式一阶段直接提交,不锁定资源。
- XA 模式依赖数据库机制实现回滚;AT 模式利用数据快照实现数据回滚。
- XA 模式强一致;AT 模式最终一致。
5.4、脏写问题
注意:此处脏写是在多线程环境下的问题
在多线程并发访问 AT 模式的分布式事务时,有可能出现脏写问题。如图,当事务 1 因为某些原因要恢复快照时,另一个线程的事务 2 就会丢失一次更新,出现了脏写问题。
解决思路就是引入了全局锁的概念。在提交事务之前,先拿到全局锁,避免同一时刻有另外一个事务在操作当前数据,拿不到全局锁超过一定时间则回滚。如图,这样一来事务 2 就更新失败了,此时事务 1 恢复数据就不会给另一个线程事务 2 造成丢失更新的问题了。
极端情况
DB 锁:锁的是表或者这张表的某一行数据。
全局锁:锁的是同一张表的同一行的同一个字段的所有 seata 管理的相关事务。
事务1在保存快照时,会保存两份快照,一份是更新前的快照数据(before-image),另一份是更新后的快照数据(after-image)。如果事务1在提交事务后,此时有一个非 seata 管理的事务2进行更新,因为事务2不是 seata 管理的,所以提交事务之前不需要获取全局锁,那么事务2会更新成功。
事务1因为某些原因在二阶段需要进行回滚,此时它会先看看更新后的快照数据(after-image),是否和当前数据库的最新数据一致,如果一致则可以正常回滚到更新前的快照数据(before-image),如果不一致则说明在事务1的一阶段和二阶段之间有人做了手脚,需要记录异常并发送警告,进行人工介入。
5.5、优缺点
AT 模式的优点:
- 一阶段完成直接提交事务,释放数据库资源,性能比较好
- 利用全局锁实现读写隔离
- 没有代码侵入,框架自动完成回滚和提交
AT 模式的缺点:
- 两阶段之间属于软状态,属于最终一致
- 框架的快照功能会影响性能,但比 XA 模式要好很多
5.6、实现AT模式
AT 模式中的快照生成、回滚等动作都是由框架自动完成,没有任何代码侵入,因此实现非常简单。
只不过,AT 模式需要一张表来记录全局锁、另一张表来记录数据快照 undo_log。
1、导入数据库表,记录全局锁
lock_table 表导入到 TC 服务关联的数据库,我这里的 TC 服务数据库是 seata
-- ----------------------------
-- 全局锁表
-- ----------------------------
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;
undo_log 表导入到微服务关联的数据库,我这里的微服务数据库是 seata_demo
-- ----------------------------
-- 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;
2、修改 application.yml 文件,将事务模式修改为 AT 模式即可:
seata:
data-source-proxy-mode: AT # 默认就是AT模式
3、重启服务并测试。
6、TCC模式
TCC 模式与 AT 模式非常相似,每阶段都是独立事务,不同的是 TCC 模式是通过人工编码来实现数据恢复。需要实现三个方法:
- Try:资源的检测和预留;
- Confirm:完成资源操作业务;要求 Try 成功 Confirm 一定要能成功。
- Cancel:预留资源释放,可以理解为 try 的反向操作。
6.1、流程分析
举例,一个扣减用户余额的业务。假设账户 A 原来余额是 100,需要余额扣减 30 元。
阶段一( Try ):检查余额是否充足,如果充足则冻结金额增加 30 元,可用余额扣除 30 元
初始余额:
余额充足,可以冻结:
此时,总金额 = 冻结金额 + 可用余额,数量依然是 100 不变。事务直接提交无需等待其它事务。
阶段二(Confirm):假如要提交(Confirm),则冻结金额扣减 30 元
确认可以提交,不过之前可用余额已经扣减过了,这里只需要清除冻结金额就好了
此时,总金额 = 冻结金额 + 可用余额 = 0 + 70 = 70元
阶段二(Canncel):如果要回滚(Cancel),则冻结金额扣减 30,可用余额增加 30
需要回滚,那么就要释放冻结金额,恢复可用余额:
6.2、Seata的TCC模型
Seata 中的 TCC 模型依然延续之前的事务架构,如图:
6.3、优缺点
TCC 模式的每个阶段是做什么的?
- Try:资源检查和预留
- Confirm:业务执行和提交
- Cancel:预留资源的释放
TCC 的优点是什么?
- 一阶段完成直接提交事务,释放数据库资源,性能好
- 相比 AT 模型,无需生成快照,无需使用全局锁,性能最强
- 不依赖数据库事务,而是依赖补偿操作,可以用于非事务型数据库
TCC 的缺点是什么?
- 有代码侵入,需要人为编写 try、Confirm 和 Cancel 接口,太麻烦
- 软状态,事务是最终一致
- 需要考虑 Confirm 和 Cancel 的失败情况,做好幂等处理
6.4、TCC的空回滚和业务悬挂
空回滚
当某分支事务的 try 阶段阻塞时,可能导致全局事务超时而触发二阶段的 cancel 操作。在未执行 try 操作时不能先执行 cancel 操作,这时 cancel 不能做回滚,只能做空回滚。
如图:
执行 cancel 操作时,应当判断 try 是否已经执行,如果尚未执行,则应该空回滚。
业务悬挂
空回滚后出现的一个新问题:对于已经空回滚的业务,之前被阻塞的 try 操作恢复,继续执行 try,可此时整个业务都已经结束了,难道我们可以让它再去走 confirm 或 cancel 吗,显然不行。因此事务一直处于中间状态,这就是业务悬挂,我们应当去避免这种情况。
所以在执行 try 操作时,应该先判断 cancel 是否已经执行过了,如果已经执行,应当阻止空回滚后的 try 操作,避免悬挂。
6.5、实现TCC模式
思路分析
为了实现空回滚、防止业务悬挂,以及幂等性要求。我们必须在数据库记录冻结金额的同时,记录当前事务id和执行状态,为此我们设计了一张表。
CREATE TABLE `account_freeze_tbl` (
`xid` varchar(128) NOT NULL COMMENT '全局事务id',
`user_id` varchar(255) DEFAULT NULL COMMENT '用户id',
`freeze_money` int(11) unsigned DEFAULT '0' COMMENT '冻结金额',
`state` int(1) DEFAULT NULL COMMENT '事务状态,0:try,1:confirm,2:cancel',
PRIMARY KEY (`xid`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8 ROW_FORMAT=COMPACT;
- xid:全局事务 id
- freeze_money:用来记录用户冻结金额
- state:用来记录事务状态
我们的业务该怎么做呢?
- Try 业务
- 记录冻结金额和事务状态到 account_freeze 表
- 扣减 account 表的可用金额
- Confirm 业务
- 根据 xid 删除 account_freeze 表的冻结记录
- Cancel 业务
- 修改 account_freeze 表,冻结金额为 0,state 状态改为 cancel
- 修改 account 表,恢复可用金额
- 如何判断是否空回滚
- cancel 业务中,根据 xid 查询 account_freeze,如果为 null 则说明 try 还没做,需要空回滚
- 如何避免业务悬挂
- try 业务中,根据 xid 查询 account_freeze,如果已经存在则证明 Cancel 已经执行,拒绝执行 try 业务
接下来,我们根据实际业务修改 account-service,利用 TCC 实现余额扣减功能。
声明TCC接口
TCC 的 Try、Confirm、Cancel 方法都需要在接口中基于注解来声明,语法如下:
我们在 account-service 项目中的 service 包中新建一个接口,声明 TCC 三个接口:
@LocalTCC
public interface AccountTCCService {
/**
* 从用户账户中扣款
* Try逻辑,@TwoPhaseBusinessAction中的name属性要与当前方法名一致,用于指定Try逻辑对应的方法,
* commitMethod属性用于指定confirm的方法名,rollbackMethod属性用于指定cancel的方法名
* 方法参数加上@BusinessActionContextParameter,便于confirm和cancel方法进行获取
*
* @param userId
* @param money
*/
@TwoPhaseBusinessAction(name = "deduct", commitMethod = "confirm", rollbackMethod = "cancel")
void deduct(@BusinessActionContextParameter(paramName = "userId") String userId, @BusinessActionContextParameter(paramName = "money") int money);
/**
* @param context 上下文对象,可以获取try方法的参数
* @return
*/
boolean confirm(BusinessActionContext context);
/**
* @param context 上下文对象,可以获取try方法的参数
* @return
*/
boolean cancel(BusinessActionContext context);
}
编写实体类
Seata 全局事务的 id 可以通过
RootContext.getXID();
获取,也可以通过 BusinessActionContext 参数的getXid()
方法获取。
在 account-service 服务中的 service.impl 包下新建一个类,实现 TCC 业务:
@Slf4j
@Service
public class AccountTCCServiceImpl implements AccountTCCService {
@Autowired
private AccountMapper accountMapper;
@Autowired
private AccountFreezeMapper freezeMapper;
/**
* try方法:
* - 扣减 account 表的可用金额
* - 记录冻结金额和事务状态到 account_freeze 表
* 因为在account_tbl表的money字段加上了unsigned属性,代表无符号数,也就是money字段不可能为负数,所以这里不用再做余额检测了。
* 注意:在try方法内做业务悬挂判断
*
* @param userId
* @param money
*/
@Override
@Transactional
public void deduct(String userId, int money) {
// 0.获取全局事务id
String xid = RootContext.getXID();
/**
* 处理业务悬挂的逻辑:
* 判断freeze中是否有冻结记录,如果有,则一定是cancel执行过了,我要拒绝业务
*/
AccountFreeze oldFreeze = freezeMapper.selectById(xid);
if (oldFreeze != null) {
return;
}
// 1、扣减可用余额
accountMapper.deduct(userId, money);
// 2、记录冻结金额和事务状态
AccountFreeze freeze = new AccountFreeze();
freeze.setUserId(userId);
freeze.setFreezeMoney(money);
freeze.setState(AccountFreeze.State.TRY);
freeze.setXid(xid);
freezeMapper.insert(freeze);
}
/**
* confirm方法:根据 xid 删除 account_freeze 表的冻结记录
*
* @param context 上下文对象,可以获取try方法的参数
* @return
*/
@Override
public boolean confirm(BusinessActionContext context) {
// 1、获取事务id
String xid = context.getXid();
// 2、根据事务id删除冻结记录
int count = freezeMapper.deleteById(xid);
return count == 1;
}
/**
* cancel方法:
* - 修改 account_freeze 表,冻结金额为 0,state 改为 cancel
* - 修改 account 表,恢复可用金额
* 注意:在cancel方法内做空回滚判断
*
* @param context 上下文对象,可以获取try方法的参数
* @return
*/
@Override
public boolean cancel(BusinessActionContext context) {
// 获取全局事务id
String xid = context.getXid();
// 根据事务id查询冻结记录
AccountFreeze freeze = freezeMapper.selectById(xid);
/**
* 处理空回滚的逻辑
*/
// 1、空回滚的判断,判断freeze是否为null,为null表示try没有执行过,需要进行空回滚
if (freeze == null) {
// 表示try没有执行,需要进行空回滚
freeze = new AccountFreeze();
String userId = context.getActionContext("userId").toString();
freeze.setUserId(userId);
freeze.setFreezeMoney(0);
// 将状态置为回滚
freeze.setState(AccountFreeze.State.CANCEL);
freeze.setXid(xid);
freezeMapper.insert(freeze);
return true;
}
// 幂等判断,防止重复回滚
if (freeze.getState() == AccountFreeze.State.CANCEL) {
// 已经处理过一次cancel了,无需重复处理
return true;
}
// 1、恢复可用余额
accountMapper.refund(freeze.getUserId(), freeze.getFreezeMoney());
// 2、将冻结金额清零,状态改为CANCEL
freeze.setFreezeMoney(0);
freeze.setState(AccountFreeze.State.CANCEL);
int count = freezeMapper.updateById(freeze);
return count == 1;
}
}
7、SAGA模式
Saga 模式是 Seata 即将开源的长事务解决方案,将由蚂蚁金服主要贡献。
Seata 官网对于 Saga 的指南:https://seata.io/zh-cn/docs/user/saga.html
7.1、原理
在 Saga 模式下,分布式事务内有多个参与者,每一个参与者都是一个冲正补偿服务,需要用户根据业务场景实现其正向操作和逆向回滚操作。
分布式事务执行过程中,依次执行各参与者的正向操作,如果所有正向操作均执行成功,那么分布式事务提交。如果任何一个正向操作执行失败,那么分布式事务会退回去执行前面各参与者的逆向回滚操作,回滚已提交的参与者,使分布式事务回到初始状态。
Saga 也分为两个阶段:
- 一阶段:直接提交本地事务
- 二阶段:成功则什么都不做;失败则通过编写补偿业务来回滚
7.2、优缺点
优点:
- 事务参与者可以基于事件驱动实现异步调用,吞吐量高
- 一阶段直接提交事务,无锁,性能好
- 不用编写 TCC 中的三个阶段,实现简单
缺点:
- 软状态持续时间不确定,时效性差
- 没有锁,没有事务隔离,会有脏写问题
8、四种模式对比
我们从以下几个方面来对比四种实现:
- 一致性:能否保证事务的一致性?强一致还是最终一致?
- 隔离性:事务之间的隔离性如何?
- 代码侵入:是否需要对业务代码进行改造?
- 性能:有无性能损耗?
- 场景:常见的业务场景