一、分布式事务的问题
1. 本地事务
事务概念:即传统的单机事务,是数据库的概念,表示由一个或多个操作组成的一个业务。比如:银行转账
事务作用:组成事务的多个操作单元,在操作数据库时,要成功都成功,要失败都失败
事务特性:ACID
- 多个数据库操作如果想要属于同一个事务:必须使用同一个数据库连接
- 如果开启了事务,在数据库底层会对数据加锁:如果一个事务长时间不提交,一定会影响性能
2. 分布式事务
2.1 介绍
在分布式环境上同样需要事务来保证数据的一致性。而因为跨数据源或跨服务环境所导致的传统事务不可用,形成的新的事务需求,这样的事务叫分布式事务。
传统事务中,要想让多个操作属于同一事务,就需要使用同一个数据库连接Connection对象。但是在分布式环境下,通常是做不到这一点的,必须使用分布式事务。比如:
- 跨数据源的分布式事务:程序要操作不同服务器上的数据库
- 跨服务的分布式事务:程序要调用多个服务,每个服务都要操作数据库
- 综合情况
2.2 示例场景
电商行业中比较常见的下单付款案例,包括下面几个行为:
- 创建新订单
- 扣减商品库存
- 从用户账户余额扣除金额
要完成上面的操作,需要访问三个不同的微服务和三个不同的数据库,如图:
订单的创建、库存的扣减、账户扣款在每一个服务和数据库内是一个本地事务,可以保证ACID原则。
但是当我们把三件事情看做一个"业务",要满足保证“业务”的原子性,要么所有操作全部成功,要么全部失败,不允许出现部分成功部分失败的现象,这就是分布式系统下的事务要解决的问题了
二、CAP定理和BASE理论
分布式事务问题的处理,其实就是在数据的一致性与服务的可用性之间做一个权衡:
- 如果要保证所有子事务的数据一致性:就要舍弃一些服务的可用性。因为数据库事务会对数据行加锁
- 如果要保证所有服务的可用性:就要考虑一下数据的一致性如何处理
解决分布式事务问题,需要一些分布式系统的基础知识作为理论指导
1. CAP定理
1998年,加州大学的计算机科学家 Eric Brewer 提出,分布式系统有三个指标:一致性(Consistency)、可用性(Availability)、分区容错性(Partition tolerance)。 Eric Brewer 说,这三个指标不可能同时做到,这个结论就叫做 CAP 定理。
1.1 CAP三指标介绍
1.1.1 C一致性
一致性Consitency,即 用户访问分布式系统中的任意节点,得到的数据必须一致(业务上的一致)。这就要求节点之间必须要及时同步数据。
比如:集群中有两个节点,初始数据是一致的;当修改了其中一个节点的数据时,要把数据变更立即同步到另外一个节点,保证所有节点的数据是一致的。
1.1.2 A可用性
可用性Availability,即 用户访问集群中的任意健康节点,必须能立即得到响应,而不是超时或拒绝。
1.1.3 P分区容错性
分区Partition:因为网络故障或者其它原因,导致分布式系统中的部分节点与其它节点失联,形成独立分区
分区容错性Partition Tolerance,即 集群出现分区时,整个系统也要持续对外提供服务
1.2 CAP的矛盾
在分布式系统中,分区容错性(P)是必须要保证的。但C和A两个指标就互相矛盾
以上图为例:
-
因为网络原因形成了两个分区:node01和node02一个分区;node03一个分区
-
如果我们要修改node02上的数据:
-
如果要追求一致性:必须等到node02把数据同步到node01、node03,才返回响应。但因为分区了,等待时间不确定,可能要长时间等待==>追求一致性C,舍弃了可用性A
-
如果要追求可用性:修改了node02的数据就立即返回响应;不能保证数据同步到了node01和node03
追求了可用性A,舍弃了一致性C
-
所以CAP定理中,P必须保证,而C和A相互矛盾,只能保证一个。即:
- CP模式:舍弃可用性,追求一致性
- AP模式:舍弃一致性,追求可用性
但是,难道这个AC矛盾就不可调和的吗?并不是,BASE理论就提出了完善和弥补的方案
2. BASE理论
BASE理论,是对CAP定理中的CA矛盾进行权衡之后,提供的一种解决思路。这是指:
-
采用CP模式,追求一致性,舍弃一定的可用性:
-
BA (Basically Available),基本可用:分布式系统在出现故障时,允许损失部分可用性,要保证核心可用
响应时间的损失:比如原本要求0.5秒内响应,现在允许5秒内响应
系统功能的损失:出现某些故障时,核心功能保证可用,部分非核心功能允许不可用
-
-
采用AP模式,追求可用性,对一致性采用一些补偿措施
- S (Soft State),软状态:在一定时间内,允许出现中间状态,比如 数据临时不一致
- E (Eventually Consistent),最终一致性:虽然无法保证强一致性,但是在软状态之后,最终达到数据一致
3. 分布式事务的解决思路
3.1 解决思路
分布式事务最大的问题是各个子事务的一致性问题,因此可以借鉴CAP定理和BASE理论。有两种解决思路:
- AP模式:各子事务分别执行和提交,允许出现结果不一致,然后采用弥补措施恢复数据即可,实现最终一致。
- CP模式:各个子事务执行后互相等待,同时提交,同时回滚,达成强一致。但事务等待过程中,处于弱可用状态。
3.2 几个概念
-
分支事务RM:ResourceManager 在整个业务中,每一个子系统的事务,称为一个分支事务。
对应@Transactional
-
全局事务TM:TransactionManager 一个完整的业务,需要众多分支事务共同组成一个全局事务。
对应@GlobalTransactional
-
事务协调者TC:TransactionCoordinator 用于在整个全局事务里,管理、协调各个分支事务的状态。
对应Seata软件
三、Seata入门与整合
1. Seata简介
Seata是 2019 年 1 月份蚂蚁金服和阿里巴巴共同开源的分布式事务解决方案。致力于提供高性能和简单易用的分布式事务服务,为用户打造一站式的分布式解决方案。
2. Seata架构
Seata事务管理中有三个重要的角色:
-
TC (Transaction Coordinator) - 事务协调者: 维护全局和分支事务的状态,协调全局事务提交或回滚。
是Seata本身
-
TM (Transaction Manager) - 事务管理器: 定义全局事务的范围、开启全局事务、提交或回滚全局事务。
负责事务的边界。@GlobalTransactional
-
RM (Resource Manager) - 资源管理器: 处理分支事务的资源,与TC交谈以注册分支事务和报告分支事务的状态,并驱动分支事务提交或回滚。@Transactional
Seata基于上述架构提供了四种不同的分布式事务解决方案:
- XA模式:强一致性分阶段事务模式,牺牲了一定的可用性,无业务侵入
- AT模式:最终一致的分阶段事务模式,无业务侵入,也是Seata的默认模式
- TCC模式:最终一致的分阶段事务模式,有业务侵入
- SAGA模式:长事务模式,有业务侵入
无论哪种方案,都离不开TC,也就是事务的协调者。
3. Seata部署
seata-server中分布式事务中充当了TC的角色
3.1 下载与安装
3.1.1 下载
下载seata-server包,地址 seata.io/zh-cn/blog/…
3.1.2 安装
seata-server免安装,直接解压到一个不含中文、空格、特殊字符的目录里即可
3.2 准备数据库
tc服务在管理分布式事务时,需要记录事务相关数据到数据库中,包括全局事务、分支事务、全局锁等信息,因此要提前创建好这些表:
用Navicat或其它工具连接本机MySQL,执行脚本
/*
Navicat Premium Data Transfer
Source Server : local
Source Server Type : MySQL
Source Server Version : 50622
Source Host : localhost:3306
Source Schema : seata
Target Server Type : MySQL
Target Server Version : 50622
File Encoding : 65001
Date: 11/05/2024 12:38:37
*/
CREATE DATABASE IF NOT EXISTS seata CHARACTER SET utf8mb4;
USE seata;
SET NAMES utf8mb4;
SET FOREIGN_KEY_CHECKS = 0;
-- ----------------------------
-- Table structure for branch_table
-- ----------------------------
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;
-- ----------------------------
-- Records of branch_table
-- ----------------------------
-- ----------------------------
-- Table structure for global_table
-- ----------------------------
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;
-- ----------------------------
-- Records of global_table
-- ----------------------------
-- ----------------------------
-- Table structure for lock_table
-- ----------------------------
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;
-- ----------------------------
-- Records of lock_table
-- ----------------------------
SET FOREIGN_KEY_CHECKS = 1;
3.3 配置
3.3.1 在nacos里添加配置
注意,为了让tc服务的集群可以共享配置,我们选择了nacos作为统一配置中心。因此服务端配置文件seataServer.properties文件需要在nacos中配好
- 在nacos中新建配置:
-
配置的内容如下:
注意:其中的数据库地址、帐号、密码都要修改成自己的
# 数据存储方式,db代表数据库
store.mode=db
store.db.datasource=druid
store.db.dbType=mysql
store.db.driverClassName=com.mysql.cj.jdbc.Driver
store.db.url=jdbc:mysql://127.0.0.1:3306/seata?useUnicode=true&rewriteBatchedStatements=true&serverTimezone=UTC
store.db.user=root
store.db.password=root
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
3.3.2 seata拉取配置文件并注册服务
修改conf目录下的registry.conf文件,完整配置如下:
registry {
# tc服务的注册中心类型,使用nacos
type = "nacos"
# 将tc服务注册到nacos,要配置nacos的地址等信息。 ""@DEFAULT_GROUP@seata-tc-server@TJ
nacos {
# tc服务的应用名称,可以自定义
application = "seata-tc-server"
serverAddr = "127.0.0.1:8848"
group = "DEFAULT_GROUP"
namespace = ""
cluster = "TJ"
username = "nacos"
password = "nacos"
}
}
config {
# 读取tc配置文件的方式:从配置中心nacos里读取配置。这样的话,如果tc搭建集群,可以通过配置中心共享配置
type = "nacos"
# 要从nacos读取配置文件信息,要配置nacos的地址等信息
nacos {
serverAddr = "127.0.0.1:8848"
namespace = ""
group = "SEATA_GROUP"
username = "nacos"
password = "nacos"
dataId = "seataServer.properties"
}
}
3.4 启动TC服务
-
先启动nacos
-
启动seata:
进入seata的bin目录,运行其中的
seata-server.bat
-
验证是否启动:
如果启动成功了,seata-server应该已经注册到nacos注册中心了
我们打开nacos,看一下有没有seata服务
4. 微服务集成Seata
每个需要分布式事务的微服务,都要按照下面的步骤进行配置。
我们以订单服务order-service为例进行说明;其它微服务也要做相同配置
4.1 添加依赖
修改pom.xml,添加依赖
<!--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>
<exclusion>
<groupId>com.alibaba</groupId>
<artifactId>druid</artifactId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>io.seata</groupId>
<artifactId>seata-spring-boot-starter</artifactId>
<!--seata starter 采用1.4.2版本-->
<version>1.4.2</version>
</dependency>
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>druid</artifactId>
<version>1.2.9</version>
</dependency>
4.2 配置tc地址
修改application.yaml,配置tc地址。通过注册中心nacos,可以拉取tc服务的地址
seata:
# 要去注册中心nacos里,拉取tc服务的地址
registry:
type: nacos
# tc服务集群注册到了nacos的""@DEFAULT_GROUP@seata-tc-server@TJ
# 所以要从nacos中拉取 ""@DEFAULT_GROUP@seata-tc-server@TJ 服务集群
nacos:
server-addr: localhost:8848 #nacos地址
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: TJ
四、Seata的事务模式
1. XA模式
XA 规范 是 X/Open 组织定义的分布式事务处理(DTP,Distributed Transaction Processing)标准,XA 规范 描述了全局的TM与局部的RM之间的接口,几乎所有主流的数据库都对 XA 规范 提供了支持。
1.1 Seata的XA模型
两阶段提交过程:
一阶段:执行、汇报但不提交
开启全局事务,各分支事务注册到TC
各分支事务执行SQL但不提交,继续持有数据库锁
各分支事务向TC汇报状态
二阶段:最终的提交或回滚
全局事务要结束,通知TC做最终决策
如果所有分支事务都成功,就通知所有分支事务一起提交
如果任意分支事务失败了,就通知所有分支事务一起回滚
各分支事务:执行TC的最终决策,提交或回滚
如图:
Seata的XA基本架构如图:
1.2 XA模式的优缺点
XA模式的优点:
- 事务的强一致性,满足ACID原则。
- 多事务(即多分布式事务之间)之间是完全隔离。多事务并发完全不受影响
- 常用RDBMS数据库都支持,实现简单,并且没有代码侵入
XA模式的缺点:
- 性能较差,因为一阶段需要锁定数据库资源,等待二阶段结束才释放,所以性能较差
- 依赖关系型数据库实现事务。NoSQL参与不进来
1.3 使用示例
Seata的依赖启动器已经完成了XA模式的自动装配,使用起来非常简单,步骤:
修改配置文件,开启XA模式
修改全局事务入口方法,添加注解
@GlobalTransactional注解:TM全局事务每个分支事务的方法上,添加注解
@Transactional注解:RM分支事务
1) 开启XA模式
修改每个参与事务的微服务的配置文件,开启XA模式:
seata:
data-source-proxy-mode: XA
2) 添加注解@GlobalTransactional
在发起全局事务的入口方法上添加注解@GlobalTransactional
在本例中是OrderServiceImpl中的create方法
@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();
}
2. AT模式 ★★★
2.1 Seata的AT模型
AT的两阶段提交过程:
一阶段:
各分支事务注册到TC
各分支事务执行SQL 提交,并备份数据(数据变更前后的数据)
各分支事务向TC汇报状态
二阶段:
TC根据各分支事务状态做最终的决策,通知给所有分支事务
各分支事务根据TC的通知:
如果要提交:直接清除undo备份
如果要回滚:就拿undo备份的数据进行恢复,然后删除undo备份
如图:
Seata的AT基本架构如图:
2.2 AT与XA的区别
- XA模式一阶段不提交事务,锁定资源;AT模式一阶段直接提交,不锁定资源。
- XA模式依赖数据库机制实现回滚;AT模式利用数据快照实现数据回滚。
- XA模式强一致;AT模式最终一致
2.3 脏写问题
多线程并发访问AT模式的分布式事务时,可能出现脏写问题。如图:
解决思路就是引入了全局锁的概念。在释放DB锁之前,先拿到全局锁。避免同一时刻有另外一个事务来操作当前数据。
具体的过程是:
一阶段:全程持有数据的DB锁
开启事务:抢DB锁,锁定要修改的数据
执行SQL并备份:备份的是变更前后的数据。比如 余额之前100,之后90
加全局锁:对数据加全局锁===> 事务xx对xx数据持有全局锁
提交事务:释放DB锁
一和二阶段之间:持有数据的全局锁
在这个阶段,其它全局事务不可能抢到数据的全局锁,不可能对数据进行修改
但是其它 非Seata事务仍然可以修改数据
二阶段:如果要回滚,全程持有数据的DB锁
开启事务:抢DB锁,锁定要修改的数据
释放全局锁
恢复数据:拿备份的数据进行恢复
- 先判断:数据库里当前数据,和自己之前修改后的数据是否相同
- 如果不同:说明在我一阶段到二阶段之间,数据被其他人修改了,Seata会报错,等待人工干预
- 如果相同:说明在我一阶段一二阶段之间,数据没有被别人修改,直接恢复数据即可
提交事务:释放DB锁
2.4 AT模式的优缺点
AT模式的优点:
- 一阶段完成直接提交事务,释放数据库资源,性能比较好
- 利用全局锁实现事务之间的隔离
- 没有代码侵入,框架自动完成回滚和提交
AT模式的缺点:
- 两阶段之间属于软状态,属于最终一致,存在数据的临时不一致状态
- 框架的快照功能会影响性能,但比XA模式要好很多
2.5 使用示例
T模式中的快照生成、回滚等动作都是由框架自动完成,没有任何代码侵入,因此实现非常简单。
只不过,AT模式需要一个表来记录全局锁、另一张表来记录数据快照undo_log。
AT模式的使用步骤:
- 在微服务的库里准备一张undo_log表。给seata用的,我们的代码用不上
- 修改所有微服务的配置文件,设置事务模式为AT。 seata.data-source-proxy-mode=AT
- 全局事务入口方法上加 @GlobalTransactional;所有分支事务方法上加@Transactional
1) 准备数据库表
undo_log表创建到微服务的库。
这张表用于存储一阶段的undo日志,二阶段回滚时会使用这些日志进行数据恢复;二阶段提交时则直接清除日志
-- ----------------------------
-- Table structure for 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) 开启AT模式
修改参与分布式事务的所有微服务的配置文件,将事务模式修改为AT模式
可以不配置,因为Seata默认使用的就是AT模式
seata:
data-source-proxy-mode: AT
3) 添加注解
在全局事务的入口方法上添加注解@GlobalTransactional
我们这里在OrderServiceImpl的create方法上添加
3. TCC模式
TCC模式与AT模式非常相似,每阶段都是独立事务,不同的是TCC通过人工编码来实现数据恢复。需要实现三个方法:
- Try:尝试执行业务,并进行资源的冻结(预扣预留); 余额原始100,要扣掉10==> 余额90,冻结金额10
- Confirm:确认事务执行成功,相当于提交;把冻结的金额直接清除即可
- Cancel:取消事务的执行,相当于回滚;把冻结的金额,重新加回余额里:余额+10
3.1 TCC模式的实现流程
举例说明:减扣余额的业务。
- 假设帐户A原本余额是100,需要减扣30元。
- 要提前准备一个位置存储冻结金额,例如:创建一张数据库表,存储冻结的金额
一阶段(Try)
检查余额是否充足。如果余额充足,则扣除余额30元,在冻结金额里增加30元。
此时总金额 = 余额 + 冻结金额,总数仍然是100元不变,分支事务可以直接提交,无需等待其它事务
二阶段(Confirm)
如果TC通知要提交,则冻结金额-30,直接提交; 用户的余额不变
此时总金额 = 余额 + 冻结金额,总数是70
二阶段(Cancel)
如果TC通知要回滚,则释放冻结金额,恢复用户余额,即:冻结金额-30,用户余额+30
此时总金额 = 余额 + 冻结金额,总数是100
3.2 Seata的TCC模式
TCC模式两阶段提交:
一阶段:
各分支事务注册到TC
各分支事务执行Try方法,直接提交
向TC上报自己的状态
二阶段:
TC根据各分支事务的状态做最终的决策,然后通知给所有的分支事务
各分支事务根据TC决策:
如果要提交:就执行Confirm方法
如果要回滚:就执行Cancel方法
如图:
Seata的TCC基本架构如图:
3.3 TCC模式的优缺点
TCC的优点:
- 一阶段完成直接提交事务,释放数据库资源,性能好
- 基于资源预留实现数据隔离;相比AT模型,无需生成快照,无需使用全局锁,性能更强
- 不依赖数据库事务,而是依赖补偿操作,可以用于非关系型数据库
TCC的缺点:
- 有代码侵入,需要人为编写try、Confirm和Cancel接口,太麻烦
- 软状态,事务是最终一致
- 需要考虑Confirm和Cancel的失败情况,做好幂等处理
3.4 TCC的几个问题
3.4.1 空回滚
当某分支事务的try阶段阻塞时,可能导致全局事务超时而触发二阶段的cancel操作。在未执行try操作时先执行了cancel操作,这时就要允许空回滚。
3.4.2 业务悬挂
对于已经空回滚的业务,之前被阻塞的try操作恢复,继续执行try,就永远不可能confirm或cancel ,事务一直处于中间状态,这就是业务悬挂。
执行try操作时,应当判断cancel是否已经执行过了,如果已经执行,应当阻止空回滚后的try操作,避免悬挂
3.4.3 幂等性
当TC通知RM提交或回滚时,如果RM明明已经提交或回滚,但是因为某些原因(例如网络拥堵)导致没有给TC返回结果,TC会重复通知RM提交或回滚,直到收到结果为止。
为了避免Try或Confirm业务的重复执行,Try和Confirm需要实现幂等:判断一下事务的状态,如果已经处理过,就直接返回成功,结束即可。
3.5 使用示例
我们使用AT和TCC混合使用的方式进行演示:
- 用户余额处理适合使用TCC,就使用TCC模式
- 库存处理也适合使用TCC,但是我们TCC比较麻烦,就不处理了,仍然使用AT
- 创建订单不适合使用TCC,还使用AT模式
解决空回滚和业务悬挂问题,必须要记录当前事务状态,是在try、还是cancel。
步骤:
准备一张表,用于存储冻结的数据;准备这张表的实体类、Mapper等等
编写TCC接口:Service层的接口
类上加@LocalTCC
类里要编写3个抽象方法:
Try方法:方法名随意,执行数据的扣除与冻结
方法上加@TwoPhaseBusinessAction(name="唯一标识",commitMethod="confirm方法",rollbackMethod="cancel方法") 告诉Seata,哪个方法是Confirm,哪个方法是Cancel 方法的形参加@BusinessActionContextParameter(paramName="参数名") 告诉Seata,把这个形参的值维护起来。后续在Confirm和Cancel方法里通过BusinessActionContext对象可以获取到Confirm方法:方法名随意,相当于提交事务,清除冻结数据
Cancel方法:方法名随意,相当于回滚事务,把冻结的数据加回到原始数据里
编写TCC接口的实现类,实现Try、Confirm、Cancel方法
Try方法:
要防止业务悬挂:防止Try没成功,Cancel之后Try方法执行造成的业务悬挂
扣除资源
冻结资源
Confirm方法:
相当于提交事务,直接清除冻结的数据
Cancel方法:
相当于回滚事务
要允许空回滚:如果Try还没有执行成功,就执行Cancel了,要允许空回滚
要实现幂等性:如果Cancel方法被多次重复调用,不应该产生不良后果。如果已经回滚过,就直接结束
把冻结的数据进行恢复,设置状态为已回滚
1) 创建表存储事务状态和冻结数据
在微服务的数据库里创建表,如下:
CREATE TABLE `account_freeze_tbl` (
`xid` VARCHAR(128) CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL,
`user_id` VARCHAR(255) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL,
`freeze_money` INT(11) UNSIGNED NULL DEFAULT 0,
`state` INT(1) NULL DEFAULT NULL COMMENT '事务状态,0:try,1:confirm,2:cancel',
PRIMARY KEY (`xid`) USING BTREE
) ENGINE = INNODB CHARACTER SET = utf8 COLLATE = utf8_general_ci ROW_FORMAT = COMPACT;
其中:
- xid:全局事务的id
- user_id:用户id,即 哪个用户的数据
- freeze_money:冻结金额
- state:事务状态
2) 实现Try、Confirm和Cancel业务
Try业务:
- 先根据xid查询
account_freeze_tbl表数据,如果找到了说明Cancel已执行,拒绝执行Try业务 - 如果找不到:
- 把冻结金额和事务状态保存到
account_freeze_tbl表里 - 减扣帐户表的余额
- 把冻结金额和事务状态保存到
Confirm业务:
- 根据xid,删除记录(冻结金额就删除掉了)
Cancel业务:
- 根据xid先查询
account_freeze_tbl表数据,如果找不到说明try还没有做,需要空回滚 - 如果找到了:
- 修改
account_freeze_tbl表:冻结金额为0,state为2(cancel) - 修改帐户表,恢复余额
- 修改
1. 声明TCC接口
TCC的Try、Confirm、Cancel方法都需要在接口中基于注解进行声明
修改帐户服务account-service,利用TCC实现余额扣减功能:
- 在
com.sdf.order.service包里创建接口: - 注意在接口上添加
@LocalTCC注解
@LocalTCC
public interface AccountTCCService {
@TwoPhaseBusinessAction(name = "deduct", commitMethod = "confirm", rollbackMethod = "cancel")
void deduct(@BusinessActionContextParameter(paramName = "userId") String userId,
@BusinessActionContextParameter(paramName = "money") int money);
boolean confirm(BusinessActionContext ctx);
boolean cancel(BusinessActionContext ctx);
}
2. 编写实现业务
@Slf4j
@Service
public class AccountTCCServiceImpl implements AccountTCCService {
@Autowired
private AccountMapper accountMapper;
@Autowired
private AccountFreezeMapper accountFreezeMapper;
@Override
@Transactional
public void deduct(String userId, int money) {
//1. 获取全局事务id
String xid = RootContext.getXID();
//2. 防止业务悬挂:如果已经Cancel了,就拒绝执行Try
AccountFreeze oldFreeze = accountFreezeMapper.selectById(xid);
if (oldFreeze != null) {
return;
}
//3. 扣减余额
accountMapper.deduct(userId, money);
//4. 把冻结金额和事务状态存储起来
AccountFreeze freeze = new AccountFreeze();
freeze.setXid(xid);
freeze.setUserId(userId);
freeze.setFreezeMoney(money);
freeze.setState(AccountFreeze.State.TRY);
accountFreezeMapper.insert(freeze);
}
@Override
public boolean confirm(BusinessActionContext ctx) {
//获取全局事务id
String xid = ctx.getXid()();
//删除冻结信息
int count = accountFreezeMapper.deleteById(xid);
return count == 1;
}
@Override
@Transactional
public boolean cancel(BusinessActionContext ctx) {
//1. 获取全局事务id
String xid = ctx.getXid();
//2. 空回滚判断
AccountFreeze freeze = accountFreezeMapper.selectById(xid);
if (freeze == null) {
freeze = new AccountFreeze();
freeze.setXid(xid);
String userId = ctx.getActionContext("userId").toString();
freeze.setUserId(userId);
freeze.setFreezeMoney(0);
freeze.setState(AccountFreeze.State.CANCEL);
accountFreezeMapper.insert(freeze);
return true;
}
//3. 如果已经Cancel过了,就不需要重新Cancel
if (freeze.getState() == AccountFreeze.State.CANCEL) {
return true;
}
//4. 恢复用户余额
accountMapper.refund(freeze.getUserId(), freeze.getFreezeMoney());
//5. 清零冻结金额,修改事务状态
freeze.setFreezeMoney(0);
freeze.setState(AccountFreeze.State.CANCEL);
int count = accountFreezeMapper.updateById(freeze);
return count == 1;
}
}
3. 修改Controller
让Controller调用AccountTCCService的deduct方法
@RestController
@RequestMapping("account")
public class AccountController {
@Autowired
// private AccountService accountService;
private AccountTCCService accountService;
@PutMapping("/{userId}/{money}")
public ResponseEntity<Void> deduct(@PathVariable("userId") String userId, @PathVariable("money") Integer money) {
accountService.deduct(userId, money);
return ResponseEntity.noContent().build();
}
}
3) 在全局事务入口方法上加@GlobalTransactional
在OrderServiceImpl的create方法上添加注解@GlobalTransactional
@Override
@GlobalTransactional
public Long create(Order order) {
......;
}
4) 重启测试
使用Postman重新发请求进行下单,结果会下单失败;查看数据库里,订单、余额、库存数据都没变
4. SAGA模式
Saga 模式是 Seata 开源的长事务解决方案,将由蚂蚁金服主要贡献。
其理论基础是Hector & Kenneth 在1987年发表的论文Sagas。
Seata官网对于Saga的指南:seata.io/zh-cn/docs/…
4.1 原理说明
在 Saga 模式下,分布式事务内有多个参与者,每一个参与者都是一个冲正补偿服务,需要用户根据业务场景实现其正向操作和逆向回滚操作。
分布式事务执行过程中,依次执行各参与者的正向操作,如果所有正向操作均执行成功,那么分布式事务提交。如果任何一个正向操作执行失败,那么分布式事务会去退回去执行前面各参与者的逆向回滚操作,回滚已提交的参与者,使分布式事务回到初始状态。
Saga也分为两个阶段:
- 一阶段:直接提交本地事务
- 二阶段:成功则什么都不做;失败则通过编写补偿业务来回滚
4.2 优缺点
优点:
- 事务参与者可以基于事件驱动实现异步调用,吞吐高
- 一阶段直接提交事务,无锁,性能好
- 不用编写TCC中的三个阶段,实现简单
缺点:
-
软状态持续时间不确定,时效性差
-
没有锁,没有事务隔离,会有脏写。
某一个环节给你转账过去了,后边的环节出错要补偿撤消。但是转给你的钱,已经被你花掉了
做法:
- 宁可长款,不可短款:商家宁可多收你钱,如果出错最后退给你;也不能先给你钱,出错后找你要
- 具体实现:扣你钱的操作放到前边,给你加钱的操作放到最后的环节
适用场景:
- 业务流程长、业务流程多
- 参与者包含其它公司或遗留系统服务,无法提供 TCC 模式要求的三个接口
5. 四种模式对比
五、Seata高可用
Seata的TC服务作为分布式事务核心,一定要保证seata集群的高可用性。
1. Seata高可用架构模型
搭建TC服务集群非常简单,启动多个TC服务,注册到nacos即可,但集群并不能确保100%安全。例如集群所在机房故障了怎么办?
所以如果可用性要求较高,一般都会做异地多机房容灾。比如一个TC集群在上海,另一个TC集群在杭州:
微服务基于事务组(tx-service-group)与TC集群的映射关系,来查找当前应该使用哪个TC集群。当SH集群故障时,只需要将vgroup-mapping中的映射关系改成HZ。则所有微服务就会切换到HZ的TC集群了。