前言
- 学习资料链接
- 写这篇博客旨在制作笔记,巩固知识。同时方便个人在线阅览,回顾知识。
- 博客的内容主要来自视频内容和资料中提供的学习笔记。
微服务技术栈导学
6.分布式事务问题
6.1.本地事务
本地事务,也就是传统的单机事务。
在传统数据库事务中,必须要满足以下四个原则:原子性、一致性、隔离性、持久性
事务的 ACID 原则
6.2.分布式事务
分布式事务
- 在分布式系统下,一个业务跨越多个服务或数据源,每个服务都是一个分支事务,
要保证所有分支事务最终状态一致,这样的事务就是分布式事务 - 即不是在单个服务或单个数据库架构下产生的事务
例如
- 跨数据源的分布式事务
- 跨服务的分布式事务
- 综合情况
在数据库水平拆分、服务垂直拆分之后,一个业务操作通常要跨多个数据库、服务才能完成。
例如电商行业中比较常见的下单付款案例,包括下面几个行为:
- 创建新订单
- 扣减商品库存
- 从用户账户余额扣除金额
完成上面的操作需要访问三个不同的微服务和三个不同的数据库。
订单的创建、库存的扣减、账户扣款在每一个服务和数据库内是一个本地事务,可以保证 ACID 原则。
但是当我们把三件事情看做一个 “业务”,要满足保证 “业务” 的原子性
- 要么所有操作全部成功,要么全部失败,不允许出现部分成功部分失败的现象,这就是 分布式系统下的事务 了。
此时 ACID 难以满足,这是分布式事务要解决的问题
6.3.演示分布式问题
我们通过一个案例来演示分布式事务的问题
- 创建数据库,名为
seata_demo
,然后导入课前资料提供的 SQL 文件
数据库中有三张表,表结构和表中内容如下
此处只是演示,所以没有创建三个数据库,但三个表之间也没有外键联系,这里演示用还是行的通的。
- 导入课前资料提供的微服务
导入项目 seata-demo
的项目结构如下
seata-demo
:父工程,负责管理项目依赖
-
account-service
:账户服务,负责管理用户的资金账户。提供扣减余额的接口 -
storage-service
:库存服务,负责管理商品库存。提供扣减库存的接口 -
order-service
:订单服务,负责管理订单。创建订单时,需要调用account-service
和storage-service
- 先启动 nacos、在这之后启动所有微服务
在 ~\nacos\bin
目录下启动 nacos 的单机模式
startup.cmd -m standalone
- 测试下单功能,发出 Post 请求
请求如下
curl --location --request POST 'http://localhost:8082/order?userId=user202103032042012&commodityCode=100202003032041&count=20&money=200'
这里是借助了 Postman 工具来测试
经过测试发现:当库存不足时,如果余额已经扣减,并不会回滚,这就是出现了分布式事务问题。
6.4.学习目标
7.理论基础
解决分布式事务问题,需要一些分布式系统的基础知识作为理论指导。
7.1.CAP 定理
7.1.1.概述
1998年,加州大学的计算机科学家 Eric Brewer 提出,分布式系统有三个指标。
- Consistency(一致性)
- Availability(可用性)
- Partition tolerance (分区容错性)
Eric Brewer 说,分布式系统无法同时满足这三个指标。这个结论就叫做 CAP 定理。
7.1.2.一致性
Consistency(一致性):用户访问分布式系统中的任意节点,得到的数据必须一致。
比如现在包含两个节点,其中的初始数据是一致的
当我们修改其中一个节点的数据时,两者的数据产生了差异
要想保住一致性,就必须实现 node01 到 node02 的数据 同步
7.1.3.可用性
Availability (可用性):用户访问集群中的任意健康节点,必须能得到响应,而不是超时或拒绝。
如图,有三个节点的集群,访问任何一个都可以及时得到响应
当有部分节点因为网络故障或其它原因无法访问时,代表节点不可用
7.1.4.分区容错
Partition(分区):因为网络故障或其它原因导致分布式系统中的部分节点与其它节点失去连接,形成独立分区。
Tolerance(容错):在集群出现分区时,整个系统也要持续对外提供服务
7.1.5.矛盾
在分布式系统中,系统间的网络不能 100% 保证健康,一定会有故障的时候,而服务有必须对外保证服务。
因此 Partition Tolerance 不可避免。
当节点接收到新的数据变更时,就会出现问题了
如果此时要保证一致性,就必须等待网络恢复,完成数据同步后,整个集群才对外提供服务,服务处于阻塞状态,不可用。
如果此时要保证可用性,就不能等待网络恢复,那 node01、node02 与node03 之间就会出现数据不一致。
也就是说,在 P 一定会出现的情况下,A 和 C 之间只能实现一个。
7.2.BASE 理论
BASE 理论是对 CAP 的一种解决思路,包含三个思想
- Basically Available (基本可用):分布式系统在出现故障时,允许损失部分可用性,即保证核心可用。
- Soft State(软状态):在一定时间内,允许出现中间状态,比如临时的不一致状态。
- Eventually Consistent(最终一致性):虽然无法保证强一致性,但是在软状态结束后,最终达到数据一致。
7.3.解决分布式事务的思路
分布式事务最大的问题是各个子事务的一致性问题,因此可以借鉴 CAP 定理和 BASE 理论,有两种解决思路
- AP 模式:各子事务分别执行和提交,允许出现结果不一致,然后采用弥补措施恢复数据即可,实现 最终一致。
- CP 模式:各个子事务执行后互相等待,同时提交,同时回滚,达成 强一致。但事务等待过程中,处于弱可用状态。
但不管是哪一种模式,都需要在子系统事务之间互相通讯,协调事务状态,也就是需要一个事务协调者(TC)
举例:分布式事务模型图
这里的子系统事务,称为分支事务;有关联的各个分支事务在一起称为 全局事务。
7.4.小结
简述 CAP 定理内容
- 分布式系统节点通过网络连接,一定会出现分区问题(P)
- 当分区出现时,系统的一致性(C)和可用性(A)就无法同时满足
思考:elasticsearch 集群是 CP 还是 AP?
- ES 集群出现分区时,故障节点会被剔除集群,数据分片会重新分配到其它节点,保证数据一致。
- 因此是低可用性,高一致性,属于 CP
简述 BASE 理论三个思想
- 基本可用、软状态、最终一致
解决分布式事务的思想和模型
- 全局事务:整个分布式事务
- 分支事务:分布式事务中包含的每个子系统的事务
- 最终一致思想:各分支事务分别执行并提交,如果有不一致的情况,再想办法恢复数据
- 强一致思想:各分支事务执行完业务不要提交,等待彼此结果。而后统一提交或回滚
8.初始 Seta
8.1.简述 Seta
Seata 是 2019 年 1 月份蚂蚁金服和阿里巴巴共同开源的分布式事务解决方案。
致力于提供高性能和简单易用的分布式事务服务,为用户打造一站式的分布式解决方案。
官网地址:http://seata.io/
该网站的文档、播客中提供了大量的使用说明、源码分析。
8.2.Seta 架构
Seata 事务管理中有三个重要的角色
- TC (Transaction Coordinator)- 事务协调者
- 维护全局和分支事务的状态,协调全局事务提交或回滚。
- TM (Transaction Manager)- 事务管理器
- 定义全局事务的范围、开始全局事务、提交或回滚全局事务。
- RM (Resource Manager)-资源管理器
- 管理分支事务处理的资源,与 TC 交谈以注册分支事务和报告分支事务的状态,并驱动分支事务提交或回滚。
整体架构图
Seata 基于上述架构提供了四种不同的分布式事务解决方案:
- XA 模式:强一致性分阶段事务模式,牺牲了一定的可用性,无业务侵入
- TCC 模式:最终一致的分阶段事务模式,有业务侵入
- AT 模式:最终一致的分阶段事务模式,无业务侵入,也是 Seata 的默认模式
- SAGA 模式:长事务模式,有业务侵入
无论哪种方案,都离不开 TC,也就是事务的协调者。
8.3.部署 TC 服务
参考课前资料提供的文档 seata的部署和集成.md
部署 Seata 的 tc-server |
8.3.1.下载
首先我们要下载 seata-server 包
当然,课前资料也准备好了
8.3.2.解压查看基本目录
在非中文目录解压缩这个 zip 包,其目录结构如下
8.3.3.修改配置文件信息
修改 conf
目录下的 registry.conf
文件
内容如下
registry {
# tc 服务的注册中心类,这里选择 nacos,也可以是 eureka、zookeeper 等
type = "nacos"
nacos {
# seata tc 服务注册到 nacos 的服务名称,可以自定义
application = "seata-tc-server"
serverAddr = "127.0.0.1:8848"
group = "DEFAULT_GROUP" # 要和项目中开的三个服务的组名相同
namespace = ""
cluster = "SH"
username = "nacos"
password = "nacos"
}
}
config {
# 读取 tc 服务端的配置文件的方式,这里是从 nacos 配置中心读取,这样如果 tc 是集群,可以共享配置
type = "nacos"
# 配置 nacos 地址等信息
nacos {
serverAddr = "127.0.0.1:8848"
namespace = ""
group = "SEATA_GROUP" # 这是配置管理的一个组,可以和上面的不一样
username = "nacos"
password = "nacos"
dataId = "seataServer.properties"
}
}
8.3.4.在 Nacos 页面中添加配置
特别注意:为了让 tc 服务的集群可以共享配置,我们选择了 nacos 作为统一配置中心。
因此服务端配置文件 seataServer.properties
文件需要在 nacos 中配好。
GROUP 是 DEFAULT_GROUP
格式如下:
上面的界面中,需要填写的配置内容如下
# 数据存储方式,db 代表数据库
store.mode=db
store.db.datasource=druid
store.db.dbType=mysql
store.db.driverClassName=com.mysql.jdbc.Driver
store.db.url=jdbc:mysql://127.0.0.1:3306/seata?useUnicode=true&rewriteBatchedStatements=true
store.db.user=root
store.db.password=123
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
上面的配置中的数据库地址、用户名、密码都需要修改成你自己的数据库信息。
视频中用的是 MySQL5,而我用的是 MySQL8,所以我这里还需要改两个地方
store.db.driverClassName=com.mysql.cj.jdbc.Driver
store.db.url=jdbc:mysql://127.0.0.1:3306/seata?serverTimezone=Asia/Shanghai&useUnicode=true&rewriteBatchedStatements=true
store.db.user=username # 你自己设置的用户名
store.db.password=password # 你自己设置的密码
8.3.5.创建数据库表
特别注意: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;
8.3.6.启动 TC 服务
进入 bin
目录,运行其中的 seata-server.bat
即可
启动成功后,seata-server
应该已经注册到 nacos 注册中心了。
打开浏览器,访问 nacos 地址:http://localhost:8848
,然后进入服务列表页面,可以看到 seata-tc-server
的信息
8.4.微服务集成 Seata
我们以 order-service
为例来演示。
8.4.1.引入依赖
首先,在 order-service
中引入依赖
<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>
8.4.2.配置 TC 地址
在 order-service
中的 application.yml
中,配置 TC 服务信息,通过注册中心 nacos,结合服务名称获取 TC 地址
seata:
registry: # TC 服务注册中心的配置,微服务根据这些信息去注册中心获取 tc 服务地址
type: nacos # 注册中心类型 nacos
nacos:
server-addr: 127.0.0.1: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
:分组 -
application
:服务名 -
cluster
:集群名
以上四个信息,在刚才的 yaml 文件中都能找到
namespace 为空,就是默认的 public
结合起来,TC 服务的信息就是:public@DEFAULT_GROUP@seata-tc-server@SH
这样就能确定 TC 服务集群了。然后就可以去 Nacos 拉取对应的实例信息了。
当正在运行的 seata-server.bat
的终端出现如下信息时,就说明微服务集成 Seata 成功
RM register success,\
message:RegisterRMRequest{resourceIds='jdbc:mysql://localhost:3306/seata_demo', \
applicationId='account-service', \
transactionServiceGroup='seata-demo'},\
channel:[id: 0xbc148488, \
L:/IPd地址:8091 - R:/IP地址:15452],\
client version:1.4.2
8.5.小结
nacos 服务名称组成包括?
- namespace + group + serviceName + cluster
seata 客户端获取 tc 的 cluster 名称方式?
- 以
tx-group-service
的值为 key 到 vgroupMapping
9.动手实践
下面学习下 Seata 中的四种不同的事务模式。
9.1.XA 模式
9.1.1.简单介绍
XA 规范 是 X/Open 组织定义的分布式事务处理(DTP,Distributed Transaction Processing)标准
XA 规范 描述了全局的 TM 与局部的 RM 之间的接口
几乎所有主流的数据库都对 XA 规范 提供了支持。
9.1.2.两阶段提交
XA 是规范,目前主流数据库都实现了这种规范,实现的原理都是基于两阶段提交。
正常情况
异常情况
9.1.3.Seata 的 XA 模型
Seata 对原始的 XA 模式做了简单的封装和改造,以适应自己的事务模型
其基本架构如下图所示
RM 一阶段的工作
- 注册分支事务到 TC
- 执行分支业务 sql 但不提交
- 报告执行状态到 TC
TC 二阶段的工作
- TC 检测各分支事务执行状态
- a.如果都成功,通知所有 RM 提交事务
- b.如果有失败,通知所有 RM 回滚事务
RM 二阶段的工作
- 接收 TC 指令,提交或回滚事务
9.1.4.优缺点
XA 模式的优点是什么?
- 事务的强一致性,满足 ACID 原则。
- 常用数据库都支持,实现简单,并且没有代码侵入
XA 模式的缺点是什么?
- 因为一阶段需要锁定数据库资源,等待二阶段结束才释放,性能较差
- 依赖关系型数据库实现事务
9.1.5.实现 XA 模式
Seata 的 starter 已经完成了 XA 模式的自动装配,实现非常简单,步骤如下
- 修改
application.yml
文件(每个参与事务的微服务),开启 XA 模式
seata:
data-source-proxy-mode: XA # 开启数据源代理的 XA 模式
- 给发起全局事务的入口方法添加
@GlobalTransactional
注解
本例中是 OrderServiceImpl 中的 create 方法
@Override
//@Transactional
@GlobalTransactional
public Long create(Order order) {
//省略......
}
- 重启服务并测试
重启 order-service
,再次测试。
在库存不足的情况下,三个微服务都能成功回滚(回滚之后,订单和库存的表的数据都会恢复原样)
9.1.6.Bug 记录
java.lang.NoSuchMethodException: com.mysql.cj.conf.PropertySet.getBooleanReadableProperty(java.lang.String)
at java.lang.Class.getMethod(Class.java:1786)
... ...
java.lang.NullPointerException
... ...
... ...
java.sql.SQLFeatureNotSupportedException: null
... ...
核心在于 NoSuchMethodException
,找不到方法,亦即 @GlobalTransactional
注解失效
解决办法是作 MySQL 降级处理(我用的是 8.0.17,更改 maven 配置中的 MySQL 版本为 8.0.11)
<properties>
<mysql.version>8.0.11</mysql.version>
</properties>
现在再次重启服务,结果发现服务启动不了了…
java.sql.SQLException:
The connection property 'zeroDateTimeBehavior' acceptable values are: 'CONVERT_TO_NULL', 'EXCEPTION' or 'ROUND'.
The value 'convertToNull' is not acceptable.
不过通过输出信息就可以得知问题出在 zeroDateTimeBehavior
这里
查看服务中的 application.yml
中关于 MySQL 的配置,发现 zeroDateTimeBehavior=convertToNull
url: jdbc:mysql://localhost:3306/seata_demo?serverTimezone=Asia/Shanghai&zeroDateTimeBehavior=convertToNull&useUnicode=true&characterEncoding=utf-8&useSSL=false&allowPublicKeyRetrieval=true&allowMultiQueries=true
解决办法就是删除掉 zeroDateTimeBehavior=convertToNull
url: jdbc:mysql://localhost:3306/seata_demo?serverTimezone=Asia/Shanghai&useUnicode=true&characterEncoding=utf-8&useSSL=false&allowPublicKeyRetrieval=true&allowMultiQueries=true
然后服务就可以正常启动了,@GlobalTransactional
注解也生效了
9.2.AT 模式
AT 模式同样是分阶段提交的事务模型,不过缺弥补了 XA 模型中资源锁定周期过长的缺陷。
9.2.1.Seata 的 AT 模型
基本流程图
阶段一 RM 的工作
- 注册分支事务
- 记录 undo-log(数据快照)
- 执行业务 sql 并提交
- 报告事务状态
阶段二提交时 RM 的工作
- 删除 undo-log 即可
阶段二回滚时 RM 的工作
- 根据 undo-log 恢复数据到更新前
9.2.2.流程梳理
我们用一个真实的业务来梳理下 AT 模式的原理。
比如,现在又一个数据库表,记录用户余额
id | money |
1 | 100 |
其中一个分支业务要执行的 SQL 为
update tb_account set money = money - 10 where id = 1
AT 模式下,当前分支事务执行流程如下
一阶段
- TM 发起并注册全局事务到 TC
- TM 调用分支事务
- 分支事务准备执行业务 SQL
- RM 拦截业务 SQL,根据 where 条件查询原始数据,形成快照。
{
"id": 1,
"money": 100
}
- RM 执行业务 SQL,提交本地事务,释放数据库锁。此时
money = 90
- RM 报告本地事务状态给 TC
二阶段
- TM 通知 TC 事务结束
- TC 检查分支事务状态
- a.如果都成功,则立即删除快照
- b.如果有分支事务失败,需要回滚。
读取快照数据({"id": 1, "money": 100}
),将快照恢复到数据库。此时数据库再次恢复为 100
流程图
9.2.3.AT 与 XA 的区别
简述 AT 模式与 XA 模式最大的区别是什么?
- XA 模式一阶段不提交事务,锁定资源;AT 模式一阶段直接提交,不锁定资源。
- XA 模式依赖数据库机制实现回滚;AT 模式利用数据快照实现数据回滚。
- XA 模式强一致;AT模式最终一致
9.2.4.脏写问题
在 多线程 并发 访问 AT 模式的分布式事务时,有可能出现脏写问题,如图
解决思路就是引入 全局锁 的概念。
在释放 DB 锁之前,先拿到全局锁。避免同一时刻有另外一个事务来操作当前数据。
情景1:事务1 和 事务2 都处于 seata 管理下
情景2:事务1 归 seata 管理,事务2 不归 seata 管理
9.2.5.优缺点
AT 模式的优点
- 一阶段完成直接提交事务,释放数据库资源,性能比较好
- 利用全局锁实现读写隔离
- 没有代码侵入,框架自动完成回滚和提交
AT 模式的缺点
- 两阶段之间属于软状态,属于最终一致
- 框架的快照功能会影响性能,但比 XA 模式要好很多
9.2.6.实现 AT 模式
AT 模式中的快照生成、回滚等动作都是由框架自动完成,没有任何代码侵入,因此实现非常简单。
只不过,AT 模式需要一个表来记录全局锁、另一张表来记录数据快照 undo_log。
- 导入数据库表,记录全局锁
SQL 文件(seata-at.sql
)中创建了两张表:lock_table、undo_log
将 lock_table 表导入到 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
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;
- 修改
application.yml
文件,将事务模式修改为 AT 模式即可
seata:
data-source-proxy-mode: AT # 默认就是 AT
- 重启服务并测试
9.3.TCC 模式
9.3.1.简述
TCC 模式与 AT 模式非常相似,每阶段都是独立事务。
不同的是 TCC 通过人工编码来实现数据恢复。
需要实现三个方法
-
Try
:资源的检测和预留; -
Confirm
:完成资源操作业务;要求 Try 成功 Confirm 一定要能成功。 -
Cancel
:预留资源释放,可以理解为 try 的反向操作。
9.3.2.流程分析
举例:一个扣减用户余额的业务。假设账户 A 原来余额是 100,需要余额扣减 30 元。
- 阶段一( Try ):检查余额是否充足,如果充足则冻结金额增加 30 元,可用余额扣除 30
初识余额
余额充足,可以冻结
此时,总金额 = 冻结金额 + 可用金额,数量依然是 100 不变。
事务直接提交无需等待其它事务
- 阶段二(Confirm):假如要提交(Confirm),则冻结金额扣减 30
确认可以提交,不过之前可用金额已经扣减过了,这里只要清除冻结金额就好了
此时,总金额 = 冻结金额 + 可用金额 = 0 + 70 = 70元
- 阶段二(Canncel):如果要回滚(Cancel),则冻结金额扣减 30,可用余额增加 30
需要回滚,那么就要释放冻结金额,恢复可用金额
9.3.3.Seata 的 TCC 模型
Seata 中的 TCC 模型依然延续之前的事务架构
9.3.4.优缺点
TCC 模式的每个阶段是做什么的?
-
Try
:资源检查和预留 -
Confirm
:业务执行和提交 -
Cancel
:预留资源的释放
TCC 的优点是什么?
- 一阶段完成直接提交事务,释放数据库资源,性能好
- 相比 AT 模型,无需生成快照,无需使用全局锁,性能最强
- 不依赖数据库事务,而是依赖补偿操作,可以用于非事务型数据库
TCC 的缺点是什么?
- 有代码侵入,需要人为编写 try、Confirm 和 Cancel 接口,太麻烦
- 软状态,事务是最终一致
- 需要考虑 Confirm 和 Cancel 的失败情况,做好幂等处理
9.3.5.事务悬挂和空回滚
- 空回滚
当某分支事务的 try
阶段阻塞时,可能导致全局事务超时而触发二阶段的 cancel
操作。
在未执行 try
操作时先执行了 cancel
操作,这时 cancel
不能做回滚,就是空回滚。
执行 cancel
操作时,应当判断 try
是否已经执行,如果尚未执行,则应该空回滚。
- 业务悬挂
对于已经空回滚的业务,之前被阻塞的 try
操作恢复,继续执行 try
,就永远不可能 confirm
或 cancel
,事务一直处于中间状态
这就是业务悬挂。
执行 try
操作时,应当判断 cancel
是否已经执行过了,如果已经执行,应当阻止空回滚后的 try
操作,避免悬挂
9.3.6.实现 TCC 模式
案例
- 改造
account-service
服务,利用 TCC 实现分布式事务
需求
- 修改
account-service
,编写 try、confirm、cancel 逻辑 - try 业务:添加冻结金额,扣减可用金额
- confirm 业务:删除冻结金额
- cancel 业务:删除冻结金额,恢复可用金额
- 保证 confirm、cancel 接口的幂等性
- 允许空回滚
- 拒绝业务悬挂
- 解决空回滚和业务悬挂问题,必须要记录当前事务状态,是在
try
、还是cancel
。
- 思路分析
- 声明 TCC 接口
- 编写实现类
- 修改 Controller 类
- 思路分析
为了实现空回滚、防止业务悬挂,以及幂等性要求。
我们必须在数据库记录冻结金额的同时,记录当前事务 id 和执行状态。
为此我们设计了一张表
DROP TABLE IF EXISTS `account_freeze_tbl`;
CREATE TABLE `account_freeze_tbl` (
`xid` varchar(128) NOT NULL,
`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
:用来记录事务状态,0 为 try,1 为 confirm,2 为 cancel
那此时,我们的业务开怎么做呢?
Try
业务:
- 记录冻结金额和事务状态到
account_freeze
表 - 扣减
account
表可用金额
Confirm
业务
- 根据 xid 删除
account_freeze
表的冻结记录
Cancel
业务
- 修改
account_freeze
表,冻结金额为 0,state
为 2 - 修改
account
表,恢复可用金额
- 如何判断是否空回滚?
-
cancel
业务中,根据 xid 查询account_freeze
,如果为 null 则说明try
还没做,需要空回滚
- 如何避免业务悬挂?
-
try
业务中,根据 xid 查询account_freeze
,如果已经存在则证明Cancel
已经执行,拒绝执行try
业务
接下来,我们改造 account-service
,利用 TCC 实现余额扣减功能。
- 声明 TCC 接口
TCC 的 Try、Confirm、Cancel 方法都需要在接口中基于注解来声明,
我们在 account-service
项目中的 cn.itcast.account.service
包中新建一个接口,声明 TCC 三个接口
package cn.itcast.account.service;
import io.seata.rm.tcc.api.BusinessActionContext;
import io.seata.rm.tcc.api.BusinessActionContextParameter;
import io.seata.rm.tcc.api.LocalTCC;
import io.seata.rm.tcc.api.TwoPhaseBusinessAction;
@LocalTCC
public interface AccountTCCService {
/**
* Try 逻辑
* CTwoPhaseBusinessaction 中的 name 属性要与当前方法名一致
* 用于指定 Try 逻辑对应的方法
*/
@TwoPhaseBusinessAction(name = "deduct", commitMethod = "confirm", rollbackMethod = "cancel")
void deduct(@BusinessActionContextParameter(paramName = "userId") String userId,
@BusinessActionContextParameter(paramName = "money")int money);
/**
* 二阶段 confirm 确认方法、可以另命名,但要保证与 commitMethod 一致
*
* @param ctx 上下文(context),可以传递 try 方法的参数
* @return boolean 执行是否成功
*/
boolean confirm(BusinessActionContext ctx);
/**
* 二阶段回滚方法,要保证与 rollbackMethod 一致
*/
boolean cancel(BusinessActionContext ctx);
}
- 编写实现类
在 account-service
服务中的 cn.itcast.account.service.impl
包下新建一个类,实现 TCC 业务
package cn.itcast.account.service.impl;
import cn.itcast.account.entity.AccountFreeze;
import cn.itcast.account.mapper.AccountFreezeMapper;
import cn.itcast.account.mapper.AccountMapper;
import cn.itcast.account.service.AccountTCCService;
import io.seata.core.context.RootContext;
import io.seata.rm.tcc.api.BusinessActionContext;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@Slf4j
public class AccountTCCServiceImpl implements AccountTCCService {
@Autowired
private AccountMapper accountMapper;
@Autowired
private AccountFreezeMapper freezeMapper;
@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);
}
@Override
public boolean confirm(BusinessActionContext ctx) {
// 1.获取事务 id
String xid = ctx.getXid();
// 2.根据 id 删除冻结记录
int count = freezeMapper.deleteById(xid);
return count == 1;
}
@Override
public boolean cancel(BusinessActionContext ctx) {
// 0.查询冻结记录
String xid = ctx.getXid();
AccountFreeze freeze = freezeMapper.selectById(xid);
String userId = ctx.getActionContext("userId").toString();
// 【空回滚】的判断,判断 freeze 是否是 null
if (freeze == null) {
//若是 null,则证明 try 没有执行,需要空回滚
//不过空回滚也不是什么都不做,需要把记录记入 account_freeze 表中
freeze = new AccountFreeze();
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,则说明已经处理过一次 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;
}
}
- 修改 Controller 类
//@Autowired
//private AccountService accountService;
@Autowired
private AccountTCCService accountService;
9.4.SAGA 模式
9.4.1.简述
Saga 模式是 Seata 即将开源的长事务解决方案,将由蚂蚁金服主要贡献。
其理论基础是 Hector & Kenneth 在 1987 年发表的论文 Sagas。
Seata 官网对于 Saga 的指南:https://seata.io/zh-cn/docs/user/saga.html
9.4.2.原理
在 Saga 模式下
- 分布式事务内有多个参与者
- 每一个参与者都是一个冲正补偿服务
- 需要用户根据业务场景实现其正向操作和逆向回滚操作。
分布式事务执行过程中,依次执行各参与者的正向操作
- 如果所有正向操作均执行成功
- 那么分布式事务提交
- 如果任何一个正向操作执行失败
- 那么分布式事务会去退回去执行前面各参与者的逆向回滚操作,回滚已提交的参与者,使分布式事务回到初始状态。
Saga 也分为两个阶段
- 一阶段:直接提交本地事务
- 二阶段:成功则什么都不做;失败则通过编写补偿业务来回滚
9.4.3.优缺点
优点
- 事务参与者可以基于事件驱动实现异步调用,吞吐高
- 一阶段直接提交事务,无锁,性能好
- 不用编写 TCC 中的三个阶段,实现简单
缺点
- 软状态持续时间不确定,时效性差
- 没有锁,没有事务隔离,会有脏写
9.5.四种模式对比
我们从以下几个方面来对比四种实现
- 一致性:能否保证事务的一致性?强一致还是最终一致?
- 隔离性:事务之间的隔离性如何?
- 代码侵入:是否需要对业务代码改造?
- 性能:有无性能损耗?
- 场景:常见的业务场景
|
|
|
| |
一致性 | 强一致 | 弱一致 | 弱一致 | 最终一致 |
隔离性 | 完全隔离 | 基于全局锁隔离 | 基于资源预留隔离 | 无隔离 |
代码侵入 | 无 | 无 | 有,要编写三个接口 | 有,要编写状态机和补偿业务 |
性能 | 差 | 好 | 非常好 | 非常好 |
场景 | 对一致性、隔离性有高要求的业务 | 基于关系型数据库的大多数分布式事务场景都可以 | 对性能要求较高的事务。 有非关系型数据库要参与的事务。 | 业务流程长、业务流程多 参与者包含其它公司或遗留系统服务,无法提供 TCC |
10.高可用
10.1.高可用架构模型
搭建 TC 服务集群非常简单,启动多个 TC 服务,注册到 nacos 即可。
但集群并不能确保 100% 安全,万一集群所在机房故障怎么办?
所以如果要求较高,一般都会做异地多机房容灾。
比如一个 TC 集群在上海,另一个 TC 集群在杭州
微服务基于事务组(tx-service-group
)与 TC 集群的映射关系,来查找当前应该使用哪个 TC 集群。
当 SH 集群故障时,只需要将 vgroup-mapping
中的映射关系改成 HZ。
则所有微服务就会切换到 HZ 的 TC 集群了。
10.2.实现高可用
具体实现请参考课前资料提供的文档 seata的部署和集成.md
中的第三章节
10.2.1.模拟异地容灾的 TC 集群
计划启动两台 seata 的 tc 服务节点
节点名称 | ip地址 | 端口号 | 集群名称 |
seata | 127.0.0.1 | 8091 | SH |
seata2 | 127.0.0.1 | 8092 | HZ |
之前我们已经启动了一台 seata 服务,端口是 8091,集群名为 SH。
现在,将 seata
目录复制一份,起名为 seata2
修改 seata2/conf/registry.conf
内容如下
registry {
# tc 服务的注册中心类,这里选择 nacos,也可以是 eureka、zookeeper 等
type = "nacos"
nacos {
# seata tc 服务注册到 nacos 的服务名称,可以自定义
application = "seata-tc-server"
serverAddr = "127.0.0.1:8848"
group = "DEFAULT_GROUP"
namespace = ""
cluster = "HZ"
username = "nacos"
password = "nacos"
}
}
config {
# 读取 tc 服务端的配置文件的方式,这里是从 nacos 配置中心读取,这样如果 tc 是集群,可以共享配置
type = "nacos"
# 配置 nacos 地址等信息
nacos {
serverAddr = "127.0.0.1:8848"
namespace = ""
group = "SEATA_GROUP"
username = "nacos"
password = "nacos"
dataId = "seataServer.properties"
}
}
进入 seata2/bin
目录,然后运行命令
seata-server.bat -p 8092
打开 nacos 控制台,查看服务列表
点进详情查看:
10.2.2.将事务组映射配置到 nacos
接下来,我们需要将 tx-service-group
与 cluster
的映射关系都配置到 nacos 配置中心。
新建一个配置
配置的内容如下
# 事务组映射关系
service.vgroupMapping.seata-demo=SH
service.enableDegrade=false
service.disableGlobalTransaction=false
# 与 TC 服务的通信配置
transport.type=TCP
transport.server=NIO
transport.heartbeat=true
transport.enableClientBatchSendRequest=false
transport.threadFactory.bossThreadPrefix=NettyBoss
transport.threadFactory.workerThreadPrefix=NettyServerNIOWorker
transport.threadFactory.serverExecutorThreadPrefix=NettyServerBizHandler
transport.threadFactory.shareBossWorker=false
transport.threadFactory.clientSelectorThreadPrefix=NettyClientSelector
transport.threadFactory.clientSelectorThreadSize=1
transport.threadFactory.clientWorkerThreadPrefix=NettyClientWorkerThread
transport.threadFactory.bossThreadSize=1
transport.threadFactory.workerThreadSize=default
transport.shutdown.wait=3
# RM 配置
client.rm.asyncCommitBufferLimit=10000
client.rm.lock.retryInterval=10
client.rm.lock.retryTimes=30
client.rm.lock.retryPolicyBranchRollbackOnConflict=true
client.rm.reportRetryCount=5
client.rm.tableMetaCheckEnable=false
client.rm.tableMetaCheckerInterval=60000
client.rm.sqlParserType=druid
client.rm.reportSuccessEnable=false
client.rm.sagaBranchRegisterEnable=false
# TM 配置
client.tm.commitRetryCount=5
client.tm.rollbackRetryCount=5
client.tm.defaultGlobalTransactionTimeout=60000
client.tm.degradeCheck=false
client.tm.degradeCheckAllowTimes=10
client.tm.degradeCheckPeriod=2000
# undo 日志配置
client.undo.dataValidation=true
client.undo.logSerialization=jackson
client.undo.onlyCareUpdateColumns=true
client.undo.logTable=undo_log
client.undo.compress.enable=true
client.undo.compress.type=zip
client.undo.compress.threshold=64k
client.log.exceptionRate=100
10.2.3.微服务读取 nacos 配置
接下来,需要修改每一个微服务的 application.yml
文件,让微服务读取 nacos 中的 client.properties
文件
seata:
config:
type: nacos
nacos:
server-addr: 127.0.0.1:8848
username: nacos
password: nacos
group: SEATA_GROUP
data-id: client.properties
重启微服务,现在微服务到底是连接 tc 的 SH 集群,还是 tc 的 HZ 集群,都统一由 nacos 的 client.properties
来决定了。