1. 引言
随着微服务架构的普及,业务逻辑被拆分为多个独立的服务,每个服务可能使用不同的数据库或资源管理器。在这种情况下,一个业务操作可能涉及多个服务之间的协调,确保这些跨服务的操作要么全部成功,要么全部失败变得至关重要。这就是分布式事务的作用。本文将详细介绍分布式事务的概念及其解决方案,并重点介绍如何使用Seata来实现分布式事务。
2. 分布式事务介绍
分布式事务是指在分布式系统中,涉及多个独立节点或服务的事务。这些节点可能位于不同的物理或虚拟机上,每个节点可能有自己的数据库、资源管理器或其他持久化存储。为了确保数据的一致性,分布式事务需要协调这些节点上的操作,使得所有操作要么全部成功,要么全部失败。以下是几种典型的分布式事务场景:
- 跨数据源的分布式事务
- 跨服务的分布式事务
- 既跨服务又跨数据库的分布式事务
假设我们有一个电商系统,用户下单时需要执行以下操作:
- 创建新订单:订单服务负责创建订单并写入订单数据库。
- 扣减商品库存:库存服务负责检查并扣减商品库存,更新库存数据库。
- 从用户账户余额扣除金额:支付服务负责从用户账户余额中扣除相应金额,更新用户账户数据库。
完成上面的操作需要访问三个不同的微服务和三个不同的数据库。
3. 分布式事务问题示例
以上面的电商系统为例:
- 创建数据库,名为seata_demo,然后导入SQL文件:
/*Navicat Premium Data TransferSource Server : localhostSource Server Type : MySQLSource Server Version : 80034 (8.0.34)Source Host : localhost:3306Source Schema : testTarget Server Type : MySQLTarget Server Version : 80034 (8.0.34)File Encoding : 65001Date: 23/02/2025 19:38:44
*/SET NAMES utf8mb4;
SET FOREIGN_KEY_CHECKS = 0;-- ----------------------------
-- Table structure for t_account
-- ----------------------------
DROP TABLE IF EXISTS `t_account`;
CREATE TABLE `t_account` (`id` int NOT NULL AUTO_INCREMENT,`user_id` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL,`money` decimal(10, 2) UNSIGNED NULL DEFAULT 0.00,PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 2 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci ROW_FORMAT = COMPACT;-- ----------------------------
-- Records of t_account
-- ----------------------------
INSERT INTO `t_account` VALUES (1, '1001', 1000.00);-- ----------------------------
-- Table structure for t_order
-- ----------------------------
DROP TABLE IF EXISTS `t_order`;
CREATE TABLE `t_order` (`id` int NOT NULL AUTO_INCREMENT,`user_id` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL,`count` int NULL DEFAULT 0,`money` decimal(10, 2) NULL DEFAULT 0.00,PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 8 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci ROW_FORMAT = COMPACT;-- ----------------------------
-- Records of t_order
-- ------------------------------ ----------------------------
-- Table structure for t_storage
-- ----------------------------
DROP TABLE IF EXISTS `t_storage`;
CREATE TABLE `t_storage` (`id` int NOT NULL AUTO_INCREMENT,`count` int UNSIGNED NULL DEFAULT 0,`price` decimal(10, 2) NULL DEFAULT NULL,PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 2 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci ROW_FORMAT = COMPACT;-- ----------------------------
-- Records of t_storage
-- ----------------------------
INSERT INTO `t_storage` VALUES (1, 10, 100.00);SET FOREIGN_KEY_CHECKS = 1;
2)创建微服务:
微服务示例代码:微服务示例代码
微服务结构如下:
其中:
- seata-demo:父工程,负责管理项目依赖
- account-service:账户服务,负责管理用户的资金账户。提供扣减余额的接口
- storage-service:库存服务,负责管理商品库存。提供扣减库存的接口
- order-service:订单服务,负责管理订单。创建订单时,需要调用account-service和storage-service
3)启动所有微服务
4)测试下单功能,发出Post请求:
订单表:
账户表:
库存表:
测试发现,当库存不足时,余额已经扣减,并不会回滚,出现了分布式事务问题。
4. 理论基础
4.1 CAP定理
CAP定理,也被称为布鲁尔定理,是分布式系统领域的一个重要理论。它指出对于一个分布式计算系统不可能同时完美地实现以下三个特性:
- 一致性(Consistency)
- 可用性(Availability)
- 分区容忍性(Partition Tolerance)
4.1.1 一致性(Consistency)
所有节点在同一时间具有相同的数据副本。每次读取操作都能返回最新的写入结果。
以下图为例,现有两个节点,其中的初始数据是一致的。
当我们修改其中一个节点的数据时,两者的数据产生了差异。
基于一致性要求,那么node02节点的数据需要保持和node01节点数据相同。
4.1.2 可用性(Availability)
每个请求都能在有限时间内收到响应,而不必担心数据不一致的问题。即使部分节点失效,系统仍然能够处理请求并返回结果。
以下图为例,有三个节点的集群,访问任何一个都可以及时得到响应。
运行一段时间后,node03 因某些原因导致无法访问。
基于可用性,系统只访问 node01 和 node02 ,确保服务的正常运行。
4.1.3 分区容忍性(Partition Tolerance)
系统能够继续运行,即使网络中某些消息丢失或部分节点失效。换句话说,即使网络分区发生,系统仍然能够提供服务。
以下图为例,有三个节点的集群,三个节点能正常通信。
运行一段时间后,因为网络故障或其它原因导致 node03与其它节点失去连接,形成独立分区。
基于分区容忍性,系统必须能够在这种情况下继续运行,而不是完全停止服务。例如,node03 可以继续接受读写请求,而其他节点则在网络恢复后进行数据同步。
4.1.4 权衡与矛盾
- 一致性和可用性(C vs A)的矛盾
当网络分区发生时,即部分节点之间的通信中断时,必须在一致性和可用性之间做出选择:
- 如果选择一致性(C):
-
- 系统必须确保所有节点的数据保持一致。
- 在网络分区期间,为了保证数据一致,某些操作(如写入)可能会被阻塞,直到网络恢复并且所有节点同步完毕。
- 这会导致系统的部分或全部不可用,因为用户请求可能会超时或被拒绝。
- 如果选择可用性(A):
-
- 系统必须确保每个请求都能在有限时间内得到响应。
- 在网络分区期间,为了保证可用性,系统会继续处理请求,即使这些请求可能导致不同节点上的数据不一致。
- 这意味着在分区期间,用户可能会读取到旧数据或部分更新的数据,导致数据不一致。
- 分区容错性和一致性(P vs C)的矛盾
分区容错性是不可避免的,因为在实际的分布式系统中,网络故障是常态。因此,必须在网络分区发生时,决定是否要保持一致性:
- 如果选择一致性(C):
-
- 必须等待网络恢复并完成数据同步后,才能继续提供服务。
- 这会导致系统在分区期间不可用,因为写入操作会被阻塞,直到所有节点同步完毕。
- 如果选择分区容错性(P):
-
- 系统必须能够在网络分区期间继续运行,并对外提供服务。
- 这意味着在分区期间,不同节点上的数据可能会不一致,因为某些节点无法与其他节点通信并同步数据。
- 分区容错性和可用性(P vs A)的矛盾
虽然分区容错性和可用性之间没有直接的冲突,但在实际应用中,选择分区容错性通常意味着牺牲一定的可用性来保证数据的一致性,或者牺牲一定的数据一致性来保证高可用性。
- 如果选择分区容错性(P):
-
- 系统必须能够在网络分区期间继续运行,并对外提供服务。
- 这意味着在分区期间,系统可能会面临数据不一致的问题,但仍然能够处理请求。
- 如果选择可用性(A):
-
- 系统必须确保每个请求都能在有限时间内得到响应。
- 在网络分区期间,为了保证可用性,系统可能会允许数据不一致的情况发生。
因此,CAP定理的核心矛盾在于,在网络分区(P)不可避免的情况下,必须在一致性和可用性之间做出权衡:
- 选择一致性(C):牺牲可用性(A),确保数据一致,但可能导致系统在分区期间不可用。
- 选择可用性(A):牺牲一致性(C),确保系统始终可用,但可能导致数据不一致。
4.2 BASE理论
BASE理论是分布式系统设计中的一种设计理念,它与CAP定理密切相关,但更侧重于如何在实际应用中通过牺牲强一致性来换取高可用性和分区容错性。BASE理论的全称是:
- Basically Available(基本可用)
- Soft state(软状态)
- Eventually consistent(最终一致)
4.2.1 基本可用(Basically Available)
即使在部分节点故障或网络分区的情况下,系统仍然能够对外提供服务,尽管可能不是所有功能都完全可用。
解释:
- 容忍部分不可用:允许某些功能或服务在特定情况下暂时不可用,但整个系统不会完全瘫痪。
- 降级服务:当某些组件不可用时,系统可以提供降级的服务或备用方案,以确保用户仍能获得响应。
示例:
- 电商网站:在高峰期或部分服务器宕机时,用户仍然可以浏览商品,但下单功能可能会暂时受限或延迟处理。
- 社交媒体平台:即使某些节点不可用,用户仍然可以发布动态,但这些动态可能需要几秒钟才能被其他用户看到。
4.2.2 软状态(Soft state)
系统的状态可以在一段时间内发生变化,而不需要立即同步到所有节点。这意味着系统中的数据副本可以在不同时间点上存在差异。
解释:
- 异步更新:数据的更新和同步是异步进行的,而不是实时同步。
- 中间状态:系统允许存在临时的、不一致的状态,这些状态会在后续的操作中逐渐收敛。
示例:
- 缓存系统:缓存中的数据可能不是最新的,但在一定时间内会逐渐更新为最新版本。
- 分布式数据库:不同节点上的数据副本可以在短时间内存在差异,但最终会通过复制机制达到一致。
4.2.3 最终一致(Eventually consistent)
虽然系统中的数据副本在某个时间段内可能存在不一致的情况,但经过一段时间后,所有副本最终会达到一致状态。
解释:
- 时间窗口:在一定的时间窗口内,数据副本之间可能存在差异,但这段时间通常是短暂的。
- 收敛机制:系统通过各种机制(如异步复制、冲突解决等)确保数据最终会一致。
示例:
- NoSQL数据库:如DynamoDB、Cassandra等,通常采用最终一致性模型,以提高性能和可用性。
- 消息队列:消息传递系统中,消息可能会有短暂的延迟,但最终会按顺序到达目标节点。
4.3 BASE理论与CAP定理的关系
BASE理论并不是对CAP定理的否定,而是对其的一种补充和实践指导。具体来说:
- CP vs AP:在选择AP(可用性和分区容错性)时,可以通过BASE理论来设计系统,以确保在分区期间仍然能够提供基本服务,并最终实现数据的一致性。
- 权衡一致性:通过接受软状态和最终一致性,系统可以在保证高可用性的前提下,合理地管理数据的一致性问题。
4.4 解决分布式事务的思路
在分布式系统中,确保各个子事务的一致性是一个关键挑战。借鉴CAP定理和BASE理论,可以采用两种主要思路来解决这一问题:AP模式(最终一致性) 和 CP模式(强一致性)。
- AP模式:最终一致性
核心思想:各子事务分别执行和提交,允许出现短暂的结果不一致,然后通过补偿措施恢复数据,最终实现一致性。
- CP模式:强一致性
核心思想:各个子事务执行后互相等待,确保所有分支事务同时提交或回滚,达成强一致性。但在事务等待过程中,系统处于弱可用状态。
但不管是哪一种模式,都需要在子系统事务之间互相通讯,协调事务状态,也就是需要一个事务协调者(TC):
这里的子系统事务,称为分支事务;有关联的各个分支事务在一起称为全局事务。
5. 分布式事务解决方案:Seata
Seata 是一款开源的分布式事务解决方案,致力于提供高性能和简单易用的分布式事务服务。Seata 将为用户提供了 AT、TCC、SAGA 和 XA 事务模式,为用户打造一站式的分布式解决方案。
英文官网:Apache Seata
中文官网:Apache Seata
5.1 Seata的架构
其核心组件主要如下:
- Transaction Coordinator(TC)
事务协调器,维护全局事务的运行状态,负责协调并驱动全局事务的提交或回滚。
- Transaction Manager(TM)
控制全局事务的边界,负责开启一个全局事务,并最终发起全局提交或全局回滚的决议,TM定义全局事务的边界。
- Resource Manager(RM)
控制分支事务,负责分支注册、状态汇报,并接收事务协调器的指令,驱动分支(本地)事务的提交和回滚。RM负责定义分支事务的边界和行为。
Seata基于上述架构提供了四种不同的分布式事务解决方案:
● XA模式:基于 CP 理论,强一致性分阶段事务模式,牺牲了一定的可用性,无业务侵入
● AT模式:基于 AP 理论,最终一致的分阶段事务模式,无业务侵入,也是Seata的默认模式
● TCC模式:软状态,最终一致的分阶段事务模式,有业务侵入
● SAGA模式:长事务模式,有业务侵入
无论哪种方案,都离不开TC,也就是事务的协调者。
5.2 部署 TC 服务(Windows版)
5.2.1 下载
安装包:Seata Java Download | Apache Seata
历史版本安装包:Seata-Server版本历史 | Apache Seata
5.2.2. 解压
在非中文目录解压缩这个zip包,其目录结构如下:
5.2.3. 修改配置
以 nacos 配置中心为例,覆盖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"}
}
5.2.4. 在nacos添加配置
注意:
为了让tc服务的集群可以共享配置,我们选择了nacos作为统一配置中心。因此服务端配置文件seataServer.properties文件需要在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&characterEncoding=UTF-8&serverTimezone=GMT%2B8&useSSL=false
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
其中的数据库地址、用户名、密码都需要修改成你自己的数据库信息。
5.2.5. 创建数据库表
注意:
tc服务在管理分布式事务时,需要记录事务相关数据到数据库中,需要提前创建好这些表。
新建一个名为seata的数据库,运行sql文件:
/*Navicat Premium Data TransferSource Server : localhostSource Server Type : MySQLSource Server Version : 80034 (8.0.34)Source Host : localhost:3306Source Schema : seataTarget Server Type : MySQLTarget Server Version : 80034 (8.0.34)File Encoding : 65001Date: 27/02/2025 10:15:50
*/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 NOT NULL,`xid` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL,`transaction_id` bigint NULL DEFAULT NULL,`resource_group_id` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL,`resource_id` varchar(256) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL,`branch_type` varchar(8) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL,`status` tinyint NULL DEFAULT NULL,`client_id` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL,`application_data` varchar(2000) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_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` ASC) USING BTREE
) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci ROW_FORMAT = COMPACT;-- ----------------------------
-- Table structure for global_table
-- ----------------------------
DROP TABLE IF EXISTS `global_table`;
CREATE TABLE `global_table` (`xid` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL,`transaction_id` bigint NULL DEFAULT NULL,`status` tinyint NOT NULL,`application_id` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL,`transaction_service_group` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL,`transaction_name` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL,`timeout` int NULL DEFAULT NULL,`begin_time` bigint NULL DEFAULT NULL,`application_data` varchar(2000) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_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` ASC, `status` ASC) USING BTREE,INDEX `idx_transaction_id`(`transaction_id` ASC) USING BTREE
) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci ROW_FORMAT = COMPACT;-- ----------------------------
-- Table structure for lock_table
-- ----------------------------
DROP TABLE IF EXISTS `lock_table`;
CREATE TABLE `lock_table` (`row_key` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL,`xid` varchar(96) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL,`transaction_id` bigint NULL DEFAULT NULL,`branch_id` bigint NOT NULL,`resource_id` varchar(256) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL,`table_name` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL,`pk` varchar(36) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_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` ASC) USING BTREE
) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci ROW_FORMAT = COMPACT;SET FOREIGN_KEY_CHECKS = 1;
5.2.6. 启动TC服务
进入bin目录,运行其中的seata-server.bat即可:
启动成功后,seata-server应该已经注册到nacos注册中心了。
打开浏览器,访问nacos地址:http://localhost:8848/nacos ,然后进入服务列表页面,可以看到seata-tc-server的信息:
5.3 微服务集成Seata
5.3.1 引入依赖
spring 与 seata 的版本关系:版本说明 · alibaba/spring-cloud-alibaba Wiki · GitHub
在之前的 seata-demo 项目中三个微服务分别引入 seata 依赖:
<!--seata-->
<dependency><groupId>com.alibaba.cloud</groupId><artifactId>spring-cloud-starter-alibaba-seata</artifactId><exclusions><!--版本较低,因此排除--> <exclusion><artifactId>seata-spring-boot-starter</artifactId><groupId>io.seata</groupId></exclusion></exclusions>
</dependency>
<dependency><groupId>io.seata</groupId><artifactId>seata-spring-boot-starter</artifactId><!--seata starter 采用和部署的seata相同版本--><version>${seata.version}</version>
</dependency>
5.3.2 配置TC地址
三个微服务中的application.yml中,配置TC服务信息,通过注册中心nacos,结合服务名称获取TC地址:
seata:registry: # TC服务注册中心的配置,微服务根据这些信息去注册中心获取tc服务地址type: nacos # 注册中心类型 nacosnacos:server-addr: 127.0.0.1:8848 # nacos地址namespace: "" # namespace,默认为空group: DEFAULT_GROUP # 分组,默认是DEFAULT_GROUPapplication: seata-tc-server # seata服务名称username: nacospassword: nacostx-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拉取对应的实例信息了。
6. Seata 的四个模式
6.1 XA(eXtended Architecture)模式
XA 是由 X/Open 组织定义的分布式事务处理标准(DTP,Distributed Transaction Processing),后来被 ISO 和 ANSI 采纳。它提供了一种机制,使得多个资源管理器(如数据库、消息队列等)可以作为一个整体参与同一个事务,确保数据的一致性。
6.1.1 XA 的工作原理
XA 使用两阶段提交(Two-Phase Commit, 2PC)协议来协调分布式事务。以下是详细的流程:
1. 准备阶段(Prepare Phase):
1)事务管理器(Transaction Manager, TM) 向所有参与者(资源管理器,Resource Managers, RMs)发送 PREPARE 请求。
2)每个 RM 执行本地事务,并检查是否可以安全地提交。如果可以,RM 会锁定必要的资源并回复 PREPARED;否则回复 NOT PREPARED 或 ABORT。
2. 提交阶段(Commit Phase):
1)如果所有 RM 都回复了 PREPARED,TM 向所有 RM 发送 COMMIT 请求。
2)每个 RM 执行本地提交,并释放锁。然后回复 ACK。
3)如果任何一个 RM 回复 NOT PREPARED 或 ABORT,TM 向所有 RM 发送 ROLLBACK 请求,所有 RM 回滚本地事务。
6.1.2 优点
-
强一致性:确保所有参与者要么都成功提交,要么都回滚。
-
可靠性:通过两阶段提交保证事务的持久性和原子性。
-
标准化:遵循国际标准,便于不同系统的互操作。
-
业务代码无侵入:业务代码不需要做任何改动,只需要按照常规方式编写SQL和业务逻辑。
6.1.3 缺点
-
性能开销:两阶段提交增加了额外的通信和等待时间,特别是在高并发场景下。
-
依赖数据库事务:依赖关系型数据库实现事务
6.1.4 适用场景
适用于想要迁移到 Seata 平台基于 XA 协议的老应用,使用 XA 模式将更平滑,还有 AT 模式未适配的数据库应用。
6.1.5 代码实现
1. 修改每个参与事务的微服务的 application.yaml 文件:
seata:data-source-proxy-mode: XA
2. 给发起全局事务的入口方法添加@GlobalTransactional注解。以 seata-demo 项目为例,只需要将 order-service 服务的 @Transactional 改为 @GlobalTransactional 。
3. 重启服务并测试
重启order-service,再次测试,发现无论怎样,三个微服务都能成功回滚。
6.2 AT(Atomic Transaction)模式
AT 模式是 Seata 创新的一种非侵入式的分布式事务解决方案,Seata 在内部做了对数据库操作的代理层,我们使用 Seata AT 模式时,实际上用的是 Seata 自带的数据源代理 DataSourceProxy,Seata 在这层代理中加入了很多逻辑,比如插入回滚 undo_log 日志,检查全局锁等。
6.2.1 AT 的工作原理
AT 使用两阶段提交(Two-Phase Commit, 2PC)协议来协调分布式事务。以下是详细的流程:
1. 准备阶段(Prepare Phase):
1)本地提交:每个分支事务先执行本地操作,并将数据修改写入数据库。
2)记录回滚日志:同时,Seata会在数据库中插入一条回滚日志,记录当前事务的状态和如何回滚。
3) 全局锁:为了防止并发冲突,Seata会对涉及的行加全局锁,确保其他事务不能修改同一行数据。
2. 提交阶段(Commit Phase):
1)提交:如果所有分支都成功完成准备阶段,则全局事务协调者通知各分支提交事务。此时,分支事务会清理回滚日志并释放全局锁。
2)回滚:如果有任一分支失败,则全局事务协调者通知所有分支回滚。分支事务会使用回滚日志将数据恢复到事务前的状态,并释放全局锁。
6.2.2 优点
- 透明性:对开发者来说,使用AT模式可以像处理单个数据库的本地事务一样简单,无需额外编写复杂的分布式事务管理代码。
- 性能较高:由于在第一阶段就直接提交了本地事务,减少了资源锁定的时间,从而提高了系统的并发性和响应速度。
- 易于集成:对于已经存在的系统,AT模式更容易集成,因为它不需要对现有业务逻辑做太多修改。
- 减少复杂度:与传统的两阶段提交(如XA协议)相比,AT模式通过自动化的机制来处理分布式事务,降低了实现和维护的复杂度。
6.2.3 缺点
- 最终一致性:AT模式通常提供的是最终一致性而非强一致性。这意味着,在某些情况下,可能会出现短暂的数据不一致状态,直到所有相关事务都完成。
- 脏写问题:如果两个或多个事务同时更新同一数据项,而这些事务之间没有适当的协调,可能导致脏写现象,即一个事务覆盖了另一个未完成事务的数据。
- 全局锁需求:为了解决脏写问题,可能需要引入全局锁的概念,这又会带来新的复杂性和潜在的性能瓶颈。
- 回滚成本高:一旦本地事务被提交,如果后续发现需要回滚,则必须依赖于补偿操作,这比直接撤销未提交的更改要复杂得多,也可能更耗时。
- 依赖快照隔离:为了确保数据的一致性,AT模式通常依赖于数据库提供的快照隔离级别,这要求数据库支持该特性,并且可能会影响读取性能。
AT模式脏写问题:
1. 原因:
- 立即提交:在AT模式下,本地事务在一阶段就直接提交了,这意味着数据变更会立即生效。如果多个分布式事务同时修改同一数据项,并且这些事务之间没有适当的协调机制,可能会导致脏写现象。
- 最终一致性:AT模式提供的是最终一致性而非强一致性。在某些情况下,可能会出现短暂的数据不一致状态,直到所有相关事务都完成。这种延迟可能导致脏写问题的发生。
2. 具体场景
假设有一个账户余额更新的操作,涉及两个分布式事务T1和T2:
- T1读取余额:T1读取当前余额为100元。
- T2读取余额:几乎同时,T2也读取到余额为100元。
- T1更新余额:T1将余额增加50元,并提交事务(余额变为150元)。
- T2更新余额:T2也将余额增加50元,并提交事务(余额变为150元,而不是预期的200元)。
在这个例子中,T2覆盖了T1的更新,这就是一个典型的脏写问题。
3. 解决方案
为了防止脏写问题,通常可以采取以下几种措施:
- 全局锁:在关键数据项上引入全局锁,确保同一时间只有一个事务能够修改该数据项。虽然这会降低并发性能,但能有效避免脏写问题。
- 乐观锁:使用版本号或时间戳等乐观锁机制。每次更新时检查版本号是否匹配,如果不匹配则拒绝更新并回滚事务。这种方式可以在一定程度上提高并发性能,同时避免脏写。
- 补偿事务:引入补偿事务机制,在检测到脏写后,通过补偿操作来恢复数据的一致性。但这增加了系统的复杂性和处理成本。
- 快照隔离:利用数据库提供的快照隔离级别(如SNAPSHOT ISOLATION),确保读取的是事务开始时的数据快照,而不是最新的数据。这可以减少脏写的概率,但可能会影响读取性能。
但是,如果同时存在受seata管理的事务和不受seata管理的事务,则还是可能导致修改丢失,因此seata在记录redo_log的时候要同时记录before-image和after-image。
6.2.4 AT 与 XA 的区别
AT模式与XA模式是两种不同的分布式事务管理方式,它们的主要区别在于事务处理的机制和一致性保障上:
- 资源锁定与提交:
- XA模式:在第一阶段不提交事务,而是锁定资源。所有参与者(Resource Manager, RM)准备完成后,事务协调者(Transaction Coordinator, TC)会检查每个参与者的状态,确保所有参与者都能成功完成事务。只有在第二阶段,当所有参与者都准备好并且收到TC的提交指令时,才会真正提交事务。
- AT模式:在第一阶段直接提交本地事务,不会锁定资源。这意味着数据变更会在一阶段就生效,而不是等到全局事务确认后再提交。
- 回滚机制:
- XA模式:依赖数据库机制来实现回滚。如果某个分支事务失败,整个全局事务将被回滚,所有参与者的更改都将被撤销。
- AT模式:使用数据快照来实现回滚。它记录了事务前后的数据状态,以便在需要时可以恢复到事务前的状态。
- 一致性:
- XA模式:提供强一致性,因为它确保了所有参与者要么全部提交,要么全部回滚,没有中间状态。
- AT模式:提供最终一致性。虽然它提高了性能,但在某些情况下可能会出现脏写问题(即一个事务覆盖了另一个未完成事务的数据),这通常通过引入全局锁的概念来解决。
- 性能:
- XA模式:由于XA模式中存在较长的资源锁定时间,可能导致并发性能下降。
- AT模式:相比之下,AT模式因为立即提交而减少了锁定时间,从而提高了系统的吞吐量和响应速度。
6.2.5 代码实现
AT模式中的快照生成、回滚等动作都是由框架自动完成,没有任何代码侵入,因此实现非常简单。只不过,AT模式需要一个表来记录全局锁、另一张表来记录数据快照undo_log。
1. 创建 lock_table 和 undo_log 表。其中 lock_table 导入到TC服务关联的数据库,undo_log 表导入到微服务关联的数据库:
/*Navicat Premium Data TransferSource Server : localhostSource Server Type : MySQLSource Server Version : 80034 (8.0.34)Source Host : localhost:3306Source Schema : testTarget Server Type : MySQLTarget Server Version : 80034 (8.0.34)File Encoding : 65001Date: 27/02/2025 17:49:11
*/SET NAMES utf8mb4;
SET FOREIGN_KEY_CHECKS = 0;-- ----------------------------
-- Table structure for lock_table
-- ----------------------------
DROP TABLE IF EXISTS `lock_table`;
CREATE TABLE `lock_table` (`row_key` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL,`xid` varchar(96) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL,`transaction_id` bigint NULL DEFAULT NULL,`branch_id` bigint NOT NULL,`resource_id` varchar(256) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL,`table_name` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL,`pk` varchar(36) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_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` ASC) USING BTREE
) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci ROW_FORMAT = COMPACT;-- ----------------------------
-- Records of lock_table
-- ------------------------------ ----------------------------
-- Table structure for undo_log
-- ----------------------------
DROP TABLE IF EXISTS `undo_log`;
CREATE TABLE `undo_log` (`branch_id` bigint NOT NULL COMMENT 'branch transaction id',`xid` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT 'global transaction id',`context` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT 'undo_log context,such as serialization',`rollback_info` longblob NOT NULL COMMENT 'rollback info',`log_status` int 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` ASC, `branch_id` ASC) USING BTREE
) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci COMMENT = 'AT transaction mode undo table' ROW_FORMAT = COMPACT;-- ----------------------------
-- Records of undo_log
-- ----------------------------SET FOREIGN_KEY_CHECKS = 1;
2. 修改每个参与事务的微服务的 application.yml 文件(如果不配置,默认就是 AT 模式):
seata:data-source-proxy-mode: AT # 默认就是AT
3. 给发起全局事务的入口方法添加@GlobalTransactional注解。以 seata-demo 项目为例,只需要将 order-service 服务的 @Transactional 改为 @GlobalTransactional 。
4. 重启服务并测试
重启order-service,再次测试,发现无论怎样,三个微服务都能成功回滚。
6.2.6 AT 回滚失败示例
如果after-image跟数据库的数据不一致会导致无法回滚。
1. 修改OrderService中feign的超时时间配置
feign:client:config:default:read-timeout: 100000connect-timeout: 100000
2. debug 模式运行 order-service,扣减余额以后,扣减库存的 feign 调用方法上打断点
3. 访问下单接口:http://localhost:8082/order
运行完扣用户余额以后,停在断点上。
然后新发起一个不受seata事务管理的扣减余额的请求:http://localhost:8083/account/1001/100
account表由原来的1000元变为900元。
然后继续执行扣减库存的请求,由于库存不足,导致全局事务回滚,可以看到回滚失败, account-service 一直回滚,同时数据库中的undo_log中的数据一直无法删除。
6.3 TCC 模式
TCC 模式是 Seata 支持的一种由业务方细粒度控制的侵入式分布式事务解决方案,是继 AT 模式后第二种支持的事务模式,最早由蚂蚁金服贡献。其分布式事务模型直接作用于服务层,不依赖底层数据库,可以灵活选择业务资源的锁定粒度,减少资源锁持有时间,可扩展性好,可以说是为独立部署的 SOA 服务而设计的。
6.3.1 TCC 的工作原理
TCC通过显式的三阶段操作来确保分布式事务的一致性。下面以扣减用户余额的业务为例,讲解三个阶段的操作。假设账户A原来余额是100元,需要余额扣减30元。
1. Try 阶段
- 目的:准备资源,锁定必要的业务资源,确保在后续的 Confirm 阶段可以成功执行。
- 操作:在这个阶段,服务提供者会检查并预留所需的资源。例如,在库存系统中,Try 阶段会检查是否有足够的库存,并锁定这些库存以防止其他事务占用。
- 幂等性:Try 阶段的操作需要是幂等的,即多次调用不会产生不同的结果。
- 案例:由于账户扣减30元余额充足,故将账户冻结30元。此时,总金额 = 冻结金额 + 可用金额,数量依然是100不变。事务直接提交无需等待其它事务。
2. Confirm 阶段
- 目的:提交资源,正式执行业务操作。
- 操作:如果全局事务成功,则执行 Confirm 阶段,释放 Try 阶段锁定的资源并完成业务操作。例如,在库存系统中,Confirm 阶段会真正减少库存数量。
- 幂等性:Confirm 阶段必须是幂等的,即使重复执行也不会产生副作用。
- 案例:如果业务执行完毕没有异常,则执行此阶段,直接将冻结的余额清除。
3. Cancel 阶段
- 目的:回滚资源,释放 Try 阶段锁定的资源。
- 操作:如果全局事务失败或超时,则执行 Cancel 阶段,释放 Try 阶段锁定的资源。例如,在库存系统中,Cancel 阶段会解锁之前锁定的库存。
- 幂等性:Cancel 阶段也必须是幂等的,确保多次调用不会产生不同的结果。
- 案例:如果业务执行过程中出现异常,执行此阶段,将冻结的余额释放。
6.3.2 TCC 模式的实现要点
- 幂等性设计:每个阶段的操作都必须是幂等的,这是 TCC 模式的核心要求。可以通过数据库唯一约束、状态机等方式保证幂等性。
- 资源锁定:Try 阶段需要尽量减少对资源的长期持有,避免影响系统性能。可以使用乐观锁或悲观锁来管理资源。
- 超时处理:设置合理的超时机制,防止资源长时间被锁定。如果超过设定的时间,可以自动触发 Cancel 阶段。
- 异常捕获:在每个阶段都需要捕获可能的异常情况,并进行适当的处理。例如,Try 阶段失败则直接返回错误,Confirm 或 Cancel 阶段失败则需要重试或记录日志以便后续排查。
6.3.3 优点
1. 高性能:
- 异步提交:TCC模式允许在Try阶段完成业务逻辑的预处理后立即提交事务,而不需要等待其他服务的响应,从而提高了系统的吞吐量。
- 减少锁竞争:通过Try阶段的预处理和锁定资源,减少了对资源的长时间锁定,降低了锁竞争的可能性。
2. 高可用性:
- 幂等性:每个阶段(Try、Confirm、Cancel)的操作都是幂等的,即使重复执行也不会产生不同的结果,提高了系统的可靠性。
- 容错性:在Confirm或Cancel阶段失败时,可以通过重试机制或补偿机制来处理,确保事务的一致性。
3. 灵活性:
- 细粒度控制:TCC模式允许对每个业务操作进行细粒度的控制,适用于复杂的分布式事务场景。
- 业务逻辑分离:Try、Confirm和Cancel阶段可以分别实现不同的业务逻辑,使得代码结构更加清晰。
- 不依赖数据库事务:不依赖数据库事务,而是依赖补偿操作,可以用于非事务型数据库
6.3.4 缺点
1. 复杂性:
- 实现复杂:TCC模式需要为每个业务操作实现Try、Confirm和Cancel三个阶段的方法,增加了系统的复杂性。
- 状态管理:需要维护每个阶段的状态,增加了系统的复杂性。
2. 幂等性保证:确保每个阶段的操作是幂等的需要额外的开发工作,增加了实现难度。
6.3.5 适用场景
TCC 模式是高性能分布式事务解决方案,适用于核心系统等对性能有很高要求的场景。
6.3.6 TCC 常见问题
6.3.6.1 事务悬挂(Transaction Hanging)
事务悬挂是指在分布式事务中,Try阶段成功后,由于网络或其他原因,Confirm或Cancel阶段的请求没有到达服务提供者,导致Try阶段的操作一直被锁定,无法释放资源。
场景示例:
- Try阶段:订单服务调用账户服务的Try阶段,账户服务冻结了30元。
- Confirm阶段:订单服务调用账户服务的Confirm阶段,但由于网络问题,Confirm请求没有到达账户服务。
- 结果:账户服务中的30元一直被冻结,无法释放,导致资源浪费。
解决方案:
1. 超时机制:
- 设置超时时间:在Try阶段成功后,设置一个合理的超时时间。如果在超时时间内没有收到Confirm或Cancel请求,则自动执行Cancel操作,释放资源。
- 定时任务:使用定时任务定期检查Try阶段的操作,如果超过一定时间没有Confirm或Cancel请求,则自动执行Cancel操作。
2. 补偿机制:在Try阶段成功后,启动一个补偿机制,如果在一定时间内没有收到Confirm或Cancel请求,则执行补偿操作。
1.5.1版本已经解决了幂等、空回滚和悬挂问题:
阿里 Seata 新版本终于解决了 TCC 模式的幂等、悬挂和空回滚问题 | Apache Seata
6.3.6.2 空回滚(Empty Rollback)
空回滚是指在分布式事务中,Try阶段失败,但Cancel阶段被错误地调用,导致系统执行了不必要的回滚操作。
场景示例:
- Try阶段:订单服务调用账户服务的Try阶段,账户服务发现余额不足,返回失败。
- Cancel阶段:订单服务错误地调用了账户服务的Cancel阶段。
- 结果:账户服务执行了不必要的回滚操作,导致系统状态异常。
解决方案:
在Cancel阶段,检查Try阶段是否成功。如果Try阶段失败,则不需要执行Cancel操作。
6.3.7 代码实现
1. 创建一个用来冻结的表
/*Navicat Premium Data TransferSource Server : localhostSource Server Type : MySQLSource Server Version : 80034 (8.0.34)Source Host : localhost:3306Source Schema : testTarget Server Type : MySQLTarget Server Version : 80034 (8.0.34)File Encoding : 65001Date: 01/03/2025 00:47:56
*/SET NAMES utf8mb4;
SET FOREIGN_KEY_CHECKS = 0;-- ----------------------------
-- Table structure for t_account_freeze
-- ----------------------------
DROP TABLE IF EXISTS `t_account_freeze`;
CREATE TABLE `t_account_freeze` (`xid` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL,`user_id` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '用户id',`freeze_money` decimal(10, 2) UNSIGNED NULL DEFAULT 0.00 COMMENT '冻结金额',`state` int NULL DEFAULT NULL COMMENT '事务状态,0:try,1:confirm,2:cancel',PRIMARY KEY (`xid`) USING BTREE
) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci ROW_FORMAT = COMPACT;-- ----------------------------
-- Table structure for undo_log
-- ----------------------------
DROP TABLE IF EXISTS `undo_log`;
CREATE TABLE `undo_log` (`branch_id` bigint NOT NULL COMMENT 'branch transaction id',`xid` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT 'global transaction id',`context` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT 'undo_log context,such as serialization',`rollback_info` longblob NOT NULL COMMENT 'rollback info',`log_status` int 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` ASC, `branch_id` ASC) USING BTREE
) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci COMMENT = 'AT transaction mode undo table' ROW_FORMAT = COMPACT;SET FOREIGN_KEY_CHECKS = 1;
其中:
- xid:是全局事务id
- freeze_money:用来记录用户冻结金额
- state:用来记录事务状态
2. 声明TCC接口
首先,在 account-service 服务中,你需要定义一个TCC接口,其中包含Try、Confirm和Cancel三个方法。并在接口上加上注解 @LocalTCC 。
package com.zjp.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 {@TwoPhaseBusinessAction(name = "deduct", commitMethod = "confirm", rollbackMethod = "cancel")void deduct(@BusinessActionContextParameter(paramName = "userId") String userId,@BusinessActionContextParameter(paramName = "money") double money);boolean confirm(BusinessActionContext ctx);boolean cancel(BusinessActionContext ctx);
}
接下来,你需要实现这个TCC接口。
package com.zjp.account.service.impl;import com.zjp.account.entity.AccountFreeze;
import com.zjp.account.mapper.AccountFreezeMapper;
import com.zjp.account.mapper.AccountMapper;
import com.zjp.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 {@Autowiredprivate AccountMapper accountMapper;@Autowiredprivate AccountFreezeMapper freezeMapper;/*** 从用户账户中扣除指定金额,并创建相应的账户冻结记录* 此方法用于在分布式事务中处理账户扣款逻辑,通过XA事务来保证数据的一致性** @param userId 用户ID,用于标识哪个用户的账户将被扣除* @param money 要扣除的金额,必须是正数*/@Override@Transactionalpublic void deduct(String userId, double money) {// 获取当前的全局事务ID,用于后续的事务管理String xid = RootContext.getXID();// 执行扣款操作accountMapper.deduct(userId, money);// 创建账户冻结对象,用于记录此次扣款操作的冻结信息AccountFreeze freeze = new AccountFreeze();freeze.setUserId(userId);freeze.setFreezeMoney(money);freeze.setState(0); // 设置冻结状态为0,表示冻结中freeze.setXid(xid); // 关联冻结记录与全局事务ID// 插入冻结记录到数据库freezeMapper.insert(freeze);}/*** 执行确认操作,主要用于二阶段提交的场景* 该方法的目的是确认之前阶段的操作,并进行相应的数据清理或更新** @param ctx 业务动作上下文,包含事务信息和业务数据* @return 返回确认操作是否成功,true表示成功,false表示失败*/@Overridepublic boolean confirm(BusinessActionContext ctx) {// 1.获取事务idString xid = ctx.getXid();// 2.根据id删除冻结记录int count = freezeMapper.deleteById(xid);// 3.判断删除操作是否成功return count == 1;}/*** 取消业务操作并恢复账户冻结金额。** @param ctx 业务动作上下文,包含事务ID等信息* @return 操作是否成功,返回true表示成功,false表示失败*/@Overridepublic boolean cancel(BusinessActionContext ctx) {try {// 根据业务动作上下文获取冻结事务IDString xid = ctx.getXid();log.info("开始取消操作,xid: {}", xid);// 通过ID查询冻结记录AccountFreeze freeze = freezeMapper.selectById(xid);if (freeze == null) {log.error("未找到对应的冻结记录,xid: {}", xid);return false;}log.info("恢复用户{}的可用余额{}", freeze.getUserId(), freeze.getFreezeMoney());// 将之前冻结的金额退还给用户,恢复其可用余额accountMapper.refund(freeze.getUserId(), freeze.getFreezeMoney());// 更新冻结记录,将冻结金额设置为0,状态改为取消freeze.setFreezeMoney(0.0);freeze.setState(2);log.info("更新冻结记录状态为CANCEL");// 执行更新操作int count = freezeMapper.updateById(freeze);// 根据更新结果返回操作是否成功return count == 1;} catch (Exception e) {log.error("取消操作失败", e);return false;}}
}
在你的业务逻辑中调用TCC服务。
package com.zjp.account.controller;import com.zjp.account.service.AccountTCCService;
import com.zjp.account.service.IAccountService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestMapping;import org.springframework.web.bind.annotation.RestController;@RestController
@RequestMapping("/account")
public class AccountController {@Autowiredprivate AccountTCCService accountService;@PutMapping("/{userId}/{money}")public String deduct(@PathVariable("userId") String userId, @PathVariable("money") Double money) {accountService.deduct(userId, money);return "success";}
}
6.4 SAGA 模式
Saga 模式是 SEATA 提供的长事务解决方案,在 Saga 模式中,业务流程中每个参与者都提交本地事务,当出现某一个参与者失败则补偿前面已经成功的参与者,一阶段正向服务和二阶段补偿服务都由业务开发实现。
理论基础:Hector & Kenneth 发表论⽂ Sagas (1987)
6.4.1 SAGA 的工作原理
在 Saga 模式下,分布式事务内有多个参与者,每一个参与者都是一个冲正补偿服务,需要用户根据业务场景实现其正向操作和逆向回滚操作。
分布式事务执行过程中,依次执行各参与者的正向操作,如果所有正向操作均执行成功,那么分布式事务提交。如果任何一个正向操作执行失败,那么分布式事务会去退回去执行前面各参与者的逆向回滚操作,回滚已提交的参与者,使分布式事务回到初始状态。
Saga也分为两个阶段:
- 一阶段:直接提交本地事务
- 二阶段:成功则什么都不做;失败则通过编写补偿业务来回滚
6.4.2 优点
- 一阶段提交本地事务,无锁,高性能
- 事件驱动架构,参与者可异步执行,高吞吐
- 补偿服务易于实现
6.4.3 缺点
- 软状态持续时间不确定,时效性差
- 没有锁,没有事务隔离,会有脏写
6.4.4 适用场景
- 业务流程长、业务流程多
- 参与者包含其它公司或遗留系统服务,无法提供 TCC 模式要求的三个接口
6.4.5 Saga 服务设计的实践经验
1. 允许空补偿
- 空补偿:原服务未执行,补偿服务执行了
- 出现原因:
- 原服务 超时(丢包)
- Saga 事务触发 回滚
- 未收到 原服务请求,先收到 补偿请求
所以服务设计时需要允许空补偿, 即没有找到要补偿的业务主键时返回补偿成功并将原业务主键记录下来。
2. 防悬挂控制
- 悬挂:补偿服务 比 原服务 先执行
- 出现原因:
- 原服务 超时(拥堵)
- Saga 事务回滚,触发 回滚
- 拥堵的 原服务 到达
所以要检查当前业务主键是否已经在空补偿记录下来的业务主键中存在,如果存在则要拒绝服务的执行。
3. 幂等控制
原服务与补偿服务都需要保证幂等性, 由于网络可能超时, 可以设置重试策略,重试发生时要通过幂等控制避免业务数据重复更新
4. 隔离性的应对
由于 Saga 事务不保证隔离性, 在极端情况下可能由于脏写无法完成回滚操作, 比如举一个极端的例子, 分布式事务内先给用户 A 充值, 然后给用户 B 扣减余额, 如果在给 A 用户充值成功, 在事务提交以前, A 用户把余额消费掉了, 如果事务发生回滚, 这时则没有办法进行补偿了。这就是缺乏隔离性造成的典型的问题, 实践中一般的应对方法是:
- 业务流程设计时遵循“宁可长款, 不可短款”的原则, 长款意思是客户少了钱机构多了钱, 以机构信誉可以给客户退款, 反之则是短款, 少的钱可能追不回来了。所以在业务流程设计上一定是先扣款。
- 有些业务场景可以允许让业务最终成功, 在回滚不了的情况下可以继续重试完成后面的流程, 所以状态机引擎除了提供“回滚”能力还需要提供“向前”恢复上下文继续执行的能力, 让业务最终执行成功, 达到最终一致性的目的。
5. 性能优化
配置客户端参数client.rm.report.success.enable=false,可以在当分支事务执行成功时不上报分支状态到 server,从而提升性能。
当上一个分支事务的状态还没有上报的时候,下一个分支事务已注册,可以认为上一个实际已成功。
6.5 高可用和异地容灾
Seata的TC服务作为分布式事务核心,一定要保证集群的高可用性。
6.5.1 高可用架构模型
搭建TC服务集群非常简单,启动多个TC服务,注册到nacos即可。
但集群并不能确保100%安全,万一集群所在机房故障怎么办?所以如果要求较高,一般都会做异地多机房容灾。
比如一个TC集群在上海,另一个TC集群在杭州:
微服务基于事务组(tx-service-group)与TC集群的映射关系,来查找当前应该使用哪个TC集群。当SH集群故障时,只需要将vgroup-mapping中的映射关系改成HZ。则所有微服务就会切换到HZ的TC集群了。
6.5.2 实现高可用
6.5.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控制台,查看服务列表:
点进详情查看:
6.5.2.2 将事务组映射配置到nacos
接下来,我们需要将tx-service-group与cluster的映射关系都配置到nacos配置中心。
新建一个配置:
配置的内容如下:
# 事务组映射关系
service.vgroupMapping.seata-demo=SHservice.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
6.5.2.3 微服务读取nacos配置
接下来,需要修改每一个微服务的application.yml文件,让微服务读取nacos中的client.properties文件:
seata:config:type: nacosnacos:server-addr: 127.0.0.1:8848username: nacospassword: nacosgroup: SEATA_GROUPdata-id: client.properties
重启微服务,现在微服务到底是连接tc的SH集群,还是tc的HZ集群,都统一由nacos的client.properties来决定了。