Seata分布式事务

在这里插入图片描述

人生就像骑单车,想保持平衡就得往前走。

在这里插入图片描述


一、什么是分布式事务


事务的作用:保证数据的完整性和一致性。

本地事务也叫做单机事务,通常指操作的数据在同一个数据库中。

在传统的数据库事务中,必须要满足四个原则:

  • 原子性(Atomicity):事务是不可分割的,事务里的所有操作,要么全部成功,要么全部失败。
  • 一致性(Consistency):事务提交前后,数据的状态应该是一致的,符合业务要求的。
  • 隔离性(Isolation):事务并发时,理论上应该是互不干扰、相互独立的。
  • 持久性(Durability):事务一旦提交,数据就永久保存到磁盘文件上,即使系统发生故障或重启,数据也不会丢失。

分布式事务:在分布式系统中,完成一个业务功能可能需要跨多个服务 、操作多个数据库,此时传统的单机事务就不可用了,因为它们通常是为了单个数据库设计的,无法处理分布式环境下数据一致性问题。


1. 演示分布式事务的问题

1.1 数据库环境

执行sql脚本:

CREATE DATABASE IF NOT EXISTS seata_demo character SET utf8mb4;
USE seata_demo;
SET NAMES utf8mb4;
SET FOREIGN_KEY_CHECKS = 0;

-- ----------------------------
-- Table structure for account_tbl
-- ----------------------------
DROP TABLE IF EXISTS `account_tbl`;
CREATE TABLE `account_tbl`  (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `user_id` varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL,
  `money` int(11) UNSIGNED NULL DEFAULT 0,
  PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 2 CHARACTER SET = utf8 COLLATE = utf8_general_ci ROW_FORMAT = COMPACT;

-- ----------------------------
-- Records of account_tbl
-- ----------------------------
INSERT INTO `account_tbl` VALUES (1, 'user202103032042012', 1000);

-- ----------------------------
-- Table structure for order_tbl
-- ----------------------------
DROP TABLE IF EXISTS `order_tbl`;
CREATE TABLE `order_tbl`  (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `user_id` varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL,
  `commodity_code` varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL,
  `count` int(11) NULL DEFAULT 0,
  `money` int(11) NULL DEFAULT 0,
  PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 1 CHARACTER SET = utf8 COLLATE = utf8_general_ci ROW_FORMAT = COMPACT;

-- ----------------------------
-- Records of order_tbl
-- ----------------------------

-- ----------------------------
-- Table structure for storage_tbl
-- ----------------------------
DROP TABLE IF EXISTS `storage_tbl`;
CREATE TABLE `storage_tbl`  (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `commodity_code` varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL,
  `count` int(11) UNSIGNED NULL DEFAULT 0,
  PRIMARY KEY (`id`) USING BTREE,
  UNIQUE INDEX `commodity_code`(`commodity_code`) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 2 CHARACTER SET = utf8 COLLATE = utf8_general_ci ROW_FORMAT = COMPACT;

-- ----------------------------
-- Records of storage_tbl
-- ----------------------------
INSERT INTO `storage_tbl` VALUES (1, '100202003032041', 10);

SET FOREIGN_KEY_CHECKS = 1;

账户表:

image-20240509235250260

订单表:

image-20240509235836412

库存表:

image-20240509235330309

1.2 拉取项目

git clone https://gitee.com/z3inc/seata-demo.git

image-20240509235910624

业务流程:模拟下单场景,订单微服务创建订单后,通过feign远程调用账户、库存微服务,分别扣减余额、库存。

image-20240512075327662

1.3 测试

1、启动Nacos

# bin目录下执行
startup.cmd -m standalone

image-20240510001340426

2、debug方式启动这三个微服务

image-20240510000003053

访问nacos管理界面,查看服务列表,访问地址:http://localhost:8848/nacos ,账号密码naocs

image-20240510000326297

3、测试订单微服务的下单接口,模拟异常情况

image-20240510000502611

因为库存表的count字段是无符号的,不能为负数,所以数据库会报错,下单一定失败。

image-20240510000834325

最终:订单没有创建成功、库存没有扣减成功,但是扣用户钱了 >_<

image-20240510003446155


二、CAP定理


原文地址:https://www.julianbrowne.com/article/brewers-cap-theorem/

CAP定理又被称作布鲁尔定理,它是衡量一个分布式系统是否优秀的三个指标:

  • C:一致性,访问分布式系统的任意节点,它们的数据或状态必须是一致的,需要花时间进行数据同步;
  • A:可用性,访问分布式系统的任意节点,都必须立即响应;
  • P:分区容错性,当分布式系统出现分区(某些节点出现故障),整个系统必须仍然持续正常提供服务。

CAP的核心是:一个分布式系统最多只能同时满足两个指标,必须满足的是P分区容错性,然后A和C是矛盾的

  • 如果追求A可用性:即访问节点必须立即响应,那么就不能保证数据是已经同步完成的,即不能保证一致性。
    • AP :如Eureka
  • 如果追求C一致性:即访问节点时必须等待数据同步完成,那么等待的时间不确定,不能保证可用性。
    • CP:如zookeeper

image-20240512070725565


三、Base理论


BASE理论,是对CAP定理的一种权衡后的处理方案。

  • BA(Basically Available): 基本可用
    • 不要求绝对可用,只要做到基本可用即可;
    • 追求一致性的时候,允许响应时间略有延迟、允许非核心功能暂不可用;
  • SE:S(Soft State)软状态 + E(Eventually Consistent),最终一致
    • 不要求绝对的一致,只要达到最终一致即可,允许存在临时数据不一致状态。

四、Seata概述


Seata 是蚂蚁金服和阿里共同开源的分布式事务解决方案,致力于提供高性能和简单易用的分布式事务服务。Seata 提供了 AT、TCC、SAGA 和 XA 事务模式,为用户打造一站式的分布式解决方案。

官网地址:https://seata.apache.org/

在官方文档、博客中提供了大量的使用说明、源码分析。

image-20240512073040295

Seata的术语:

  • RM (Resource Manager) 资源管理器:在代码里指@Transactional,分支事务;
  • TM (Transaction Manager) 事务管理器:在代码里指@GlobalTransactional,全局事务,决定了事务的边界与开启、结束;
  • TC (Transaction Coordinator) 事务协调者:指Seata软件,用于协调各分支事务的状态,决定提交还是回滚。

Seata的事务模式:

  • XA模式:强一致、弱可用,不需要编写代码;
  • AT模式:弱一致,可用性较强,不需要编写代码,也是Seata的默认模式;
  • TCC模式:可用性强,基于资源预留实现事务并发的隔离,需要编写代码来实现数据恢复;
  • SAGA模式:长事务模式,可以让第三方系统和旧系统加入全局事务,性能强,完全不做隔离,需要编写代码来实现数据恢复。

1. Seata安装部署

软件架构:
image-20240512085830143

安装步骤如下:

1、执行sql脚本,创建数据库,给seata使用。

/*
 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: 20/06/2021 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

2、下载seata安装包

#seata1.4.2版下载地址
https://github.com/apache/incubator-seata/releases/download/v1.4.2/seata-server-1.4.2.zip

3、将seata-server压缩包,解压到一个不含中文、空格、特殊字符的路径下。

image-20240512090512427


4、进入conf文件夹,修改registry.conf配置:

image-20240512092806762

# ==================================注册中心=====================================
registry {
  # file 、nacos 、eureka、redis、zk、consul、etcd3、sofa
  type = "nacos" #表示使用Nacos作为注册中心

  nacos {
    application = "seata-server"  #服务名称
    serverAddr = "127.0.0.1:8848" #nacos地址
    group = "DEFAULT_GROUP"       #分组
    namespace = ""                #名称空间
    cluster = "BJ"                #集群
    username = "nacos"            #账号
    password = "nacos"            #密码
  }
}

# ==============================配置中心===============================
config {
  # file、nacos 、apollo、zk、consul、etcd3
  type = "nacos" #表示使用nacos作为配置中心

   nacos {
    serverAddr = "127.0.0.1:8848"
    namespace = ""
    group = "SEATA_GROUP"
    username = "nacos"
    password = "nacos"
    dataId = "seataServer.properties" #要拉取配置中心的文件
   }
}

5、启动Naocs,在Naocs管理界面中,创建配置文件

  • Data ID:seataServer.properties

  • Group:SEATA_GROUP

  • 配置描述:随便写

  • 配置内容:

    # 数据存储方式,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=123321
    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
    

6、进入bin文件夹,双击执行seata-server.bat脚本

在这里插入图片描述

image-20240512093012556


7、查看Nacos的服务列表,看看Seata服务是否注册成功

image-20240512093329350

后续Seata使用:先启动Nacos、再启动Seata。


8、微服务整合Seata:(三个微服务都需要加依赖改配置)

(1)添加依赖:

image-20240512095428750

<!--seata-->
<dependency>
    <groupId>com.alibaba.cloud</groupId>
    <artifactId>spring-cloud-starter-alibaba-seata</artifactId>
    <exclusions>
        <!-- springcloudalibaba中的seata版本较低,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>

(2)修改seata配置:

image-20240512095243500

seata:
  registry:
    type: nacos
    nacos:
      server-addr: localhost:8848 
      namespace: "" #名称空间,如果没设置,默认用""
      group: DEFAULT_GROUP #分组,没有设置,默认用DEFAULT_GROUP
      application: seata-server #seata服务名称
      username: nacos
      password: nacos
  tx-service-group: seata-demo #微服务所属的组名,自定义一个组名
  service:
    vgroup-mapping:  #设置微服务组关联的seata集群
      seata-demo: BJ #这里seata-demo服务组,要使用BJ集群的seata服务

(3)重启订单、账户、库存微服务。

image-20240512100846346


五、Seata的四种事务模式


1. XA模式

1.1 XA模式的使用

1、修改参与事务操作的微服务配置文件,开启XA事务模式:

seata:
  data-source-proxy-mode: XA  #开启XA模式

2、全局事务的入口方法上加 @GlobalTransactional (如 下单方法),所有分支事务方法上加@Transactional

@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();
}

image-20240512101547911

3、重启所有微服务,再次测试

image-20240512102412299

image-20240512102434172


1.2 XA模式的原理

  • XA 规范 是 X/Open 组织定义的分布式事务处理(DTP,Distributed Transaction Processing)标准;

  • XA规范描述了全局的TM(事务管理器)与局部的RM(资源管理器)之间的接口;

  • 目前主流数据库都对 XA 规范 提供了支持。


image-20240512105021730

Seata-XA模式两阶段提交:(Seata对原始XA的模式做了调整,但大体相似)

一阶段:执行、汇报但不提交

  • 开启全局事务,各分支事务注册到TC
  • 各分支事务执行SQL,但不提交
  • 各分支事务向TC汇报事务状态

二阶段:最终的提交或回滚

  • 全局事务要结束,通知TC做最终决策

    • 如果所有分支事务都成功,就通知所有分支事务一起提交
    • 如果任意分支事务失败了,就通知所有分支事务一起回滚
  • 各分支事务:执行TC的最终决策,提交或回滚


1.3 XA模式的优缺点

  • 优点:
    • 强一致性,各个分支事务之间是完全一致的,一起提交,一起回滚
    • 隔离性好:借助于单机事务实现多全局事务的隔离。多全局事务并发不会受影响
    • 实现简单:没有代码入侵,只要添加注解就能实现XA模式的事务
  • 缺点:
    • 可用性弱:因为一阶段开启事务执行SQL但不提交,数据库底层会对数据长时间加锁
    • NoSQL参与不进来:依赖于关系型数据库的事务机制实现

2. AT模式

AT模式:是对XA的一种增强,增强的是性能(可用性)

  • 是最终一致:存在数据的临时不一致状态
  • 多事务并发的隔离性:通过全局锁实现的隔离
  • 但是:AT仍然依赖于关系数据库本身的机制,所以NoSQL参与不进来

2.1 AT模式的使用

1、在微服务的数据库里准备一张undo_log表。给seata用的,我们的代码用不上

-- ----------------------------
-- 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;

image-20240512111102883

2、修改所有微服务的配置文件,设置事务模式为AT。

seata:
  data-source-proxy-mode: AT  #seata默认使用AT模式,可以不加这行配置参数

image-20240512164216839

3、全局事务入口方法上加 @GlobalTransactional;所有分支事务方法上加@Transactional


2.2 AT模式的原理

AT模式同样是分阶段提交的事务模型,不过缺弥补了XA模型中资源锁定周期过长的缺陷。

image-20240512165547277

Seata的AT模式两阶段提交过程:

  • 一阶段:

    • 开启全局事务,各分支事务注册到TC

    • 各分支事务执行SQL提交,并备份数据(备份变更前后的数据)

    • 各分支事务向TC汇报状态

  • 二阶段:

    • TC根据各分支事务状态做最终的决策,通知所有分支事务
    • 各分支事根据TC的通知:
      • 如果要提交:直接清除undo_log备份
      • 如果要回滚:就拿undo_log备份的数据进行恢复,然后再删除undo_log备份。

2.3 AT模式的优缺点

  • 优点:

    • 一阶段提交事务,性能比较好

    • 事务并发时的隔离利用了全局锁

    • 实现简单,没有代码侵入

  • 缺点:

    • 存在数据的临时不一致状态
    • 使用了数据库的快照机制进行备份,会影响性能,但比XA模式要好很多

2.4 AT模式的脏写问题

脏写:一个事务修改了另一个事务未提交的数据,就发生了脏写现象。

解决方案:使用了全局锁。

在释放DB锁之前,先拿到全局锁,避免同一时刻有另外一个事务来操作当前数据。

image-20240512182204166

具体的过程是:
    一阶段:全程持有数据的DB锁
    	开启事务:抢DB锁,锁定要修改的数据
    	执行SQL并备份:备份的是变更前后的数据。比如 余额之前100,之后90
		加全局锁:对数据加全局锁===> 事务xx对xx数据持有全局锁
		提交事务:释放DB锁
	
	一和二阶段之间:持有数据的全局锁
		在这个阶段,其它全局事务不可能抢到数据的全局锁,不可能对数据进行修改
		但是其它 非Seata事务仍然可以修改数据
		
	二阶段:如果要回滚,全程持有数据的DB锁
		开启事务:抢DB锁,锁定要修改的数据
		释放全局锁
		恢复数据:拿备份的数据进行恢复
			先判断:数据库里当前数据,和自己之前修改后的数据是否相同
			如果不同:说明在我一阶段到二阶段之间,数据被其他人修改了,Seata会报错,等待人工干预
			如果相同:说明在我一阶段一二阶段之间,数据没有被别人修改,直接恢复数据即可
		提交事务:释放DB锁

2.5 AT与XA模式的区别

  • XA模式一阶段不提交事务,锁定资源;AT模式一阶段直接提交,不锁定资源。
  • XA模式依赖数据库机制实现回滚;AT模式利用数据快照实现数据回滚。
  • XA模式强一致;AT模式最终一致

3. TCC模式

TCC:

对比AT模式,TCC不需要全局锁也能实现事务隔离,NoSQL也能参与进来,数据备份是基于资源的预留预扣实现的,而不是数据的备份快照方式。

不同的是TCC需要编写代码来实现数据恢复。需要实现三个方法:

  • Try:尝试执行业务,并进行资源的预留预扣(冻结); 比如 余额原始100,要扣掉10元==>余额90,冻结金额10。
  • Confirm:确认事务执行成功,相当于提交;比如 把冻结的金额直接清除即可。
  • Cancel:取消事务的执行,相当于回滚;比如 把冻结的金额重新加回到余额里:余额+10。

TCC适合的场景:资源的加减操作,比如 账户余额的操作。

实际开发中:通常是AT+TCC混用

越傻瓜式底层就没有过多处理使用上就简单(如AT、XA),越灵活越麻烦(如TCC、SAGA)。


3.1 TCC模式的使用

1、执行sql脚本,创建一张冻结表,用于存储冻结的数据

use seata-demo;
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、在账户微服务,创建这张表对应的实体类、Mapper等等

//实体类
@Data
@TableName("account_freeze_tbl")
public class AccountFreeze {
    @TableId(type = IdType.INPUT)
    private String xid;
    private String userId;
    private Integer freezeMoney;
    private Integer state;

    public static abstract class State {
        public final static int TRY = 0;
        public final static int CONFIRM = 1;
        public final static int CANCEL = 2;
    }
}
//mapper接口
public interface AccountFreezeMapper extends BaseMapper<AccountFreeze> {
}

image-20240512214051619

3、 编写TCC接口:Service层的接口,后续会让调用者不走之前的分支事务代码让他走tcc的业务逻辑

package cn.aopmin.account.service;

import io.seata.rm.tcc.api.BusinessActionContext;
import io.seata.rm.tcc.api.LocalTCC;
import io.seata.rm.tcc.api.TwoPhaseBusinessAction;

/**
 * 一个接口要实现TCC模式的分支事务,需要:
 * 1.接口上加@LocalTCC注解,告诉seata这个接口是TCC配置的接口
 * 2.在接口里面添加三个方法
 * (1)执行业务预留资源的try方法,方法名随意
 * (2)相当于提交事务的Confirm方法,方法名随意
 * (3)相当于回滚事务的Cancel方法,方法名随意
 *
 * @author 白豆五
 * @since 2024/5/12
 */
@LocalTCC
public interface AccountTCCService {

    /**
     * Try方法: 在方法的实现里,需要扣除userId的money,冻结起来
     * 当全局事务调用到此分支事务时,调用的就是这个try方法:seata知道那个方法是try,但不知道哪个是confirm和cance
     * 所以需要加@TwoPhaseBusinessAction注解,指定二阶段要调用的方法,属性有:
     * name唯一标识
     * commitMethod指定confirm方法
     * rollbackMethod指定cancel方
     *
     * @param userId
     * @param money
     */
    @TwoPhaseBusinessAction(name = "deduct", commitMethod = "confirm", rollbackMethod = "cancel")
    void deduct(String userId, int money);


    /**
     * 相当于提交事务的Confirm方法
     */
    void confirm(BusinessActionContext actionContext);


    /**
     * 相当于回滚事务的Cancel方法
     */
    void cancel(BusinessActionContext actionContext);

}
package cn.aopmin.account.service.impl;

import cn.aopmin.account.mapper.AccountFreezeMapper;
import cn.aopmin.account.mapper.AccountMapper;
import cn.aopmin.account.pojo.AccountFreeze;
import cn.aopmin.account.service.AccountTCCService;
import io.seata.core.context.RootContext;
import io.seata.rm.tcc.api.BusinessActionContext;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

/**
 * TCC接口实现类
 *
 * @author 白豆五
 * @since 2024/5/12
 */
@Service
@RequiredArgsConstructor
public class AccountTCCServiceImpl implements AccountTCCService {

    private final AccountMapper accountMapper;
    private final AccountFreezeMapper freezeMapper;

    /**
     * 扣除余额,添加到冻结金额里
     * 业务中要防止业务悬挂
     *
     * @param userId
     * @param money
     */
    @Override
    @Transactional
    public void deduct(String userId, int money) {
        // 获取当前全局事务的id,在try方法里可以使用RootContext.getXID()
        String xid = RootContext.getXID();

        /**
         * 要防止业务悬挂:可能是Cancel方法已执行,Try方法才开始执行。不允许执行Try了,因为会造成业务悬挂
         */
        AccountFreeze freeze = freezeMapper.selectById(xid);
        if (freeze != null && freeze.getState() == AccountFreeze.State.CANCEL) {
            // 已经执行过Cancel方法,不允许再执行Try
            return;
        }

        // 扣除用户的余额
        accountMapper.deduct(userId, money);
        // 添加到冻结金额里
        freeze = new AccountFreeze();
        freeze.setXid(xid);                      // 全局事务ID
        freeze.setFreezeMoney(money);            // 冻结金额
        freeze.setState(AccountFreeze.State.TRY);// 状态
        freeze.setUserId(userId);                // 用户ID
        freezeMapper.insert(freeze);
    }

    /**
     * 相当于提交事务,直接清除冻结的数据
     */
    @Override
    @Transactional // 多次数据库操作加事务注解
    public void confirm(BusinessActionContext actionContext) {
        // 在confirm和cancel方法里,如何获取当前全局事务id?
        // 当seata调用confirm和cancel方法时,会把xid投递过来,封装成BusinessActionContext对象
        freezeMapper.deleteById(actionContext.getXid());
    }

    /**
     * 相当于回滚事务,把冻结的数据加回到余额里
     * 业务中要允许空回滚
     */
    @Override
    @Transactional
    public void cancel(BusinessActionContext actionContext) {
        // 1.查询当前事务冻结的数据
        String xid = actionContext.getXid();
        AccountFreeze freeze = freezeMapper.selectById(xid);


        /**
         * 允许空回滚,记录一条冻结数据,金额为0,状态为cancal已回滚
         * 如果当前事务因为某些原因一直没有被调用到try方法,直到事务超时进行全局回滚:seata会调用这个cancel方法
         */
        if (freeze == null) {
            freeze = new AccountFreeze();
            freeze.setXid(xid); // 全局事务ID
            freeze.setUserId(actionContext.getActionContext("userId").toString());// 从上下文中取用户ID
            freeze.setFreezeMoney(0);// 冻结金额
            freeze.setState(AccountFreeze.State.CANCEL);// 状态
            freezeMapper.insert(freeze);
            return;
        }


        /**
         * 幂等性:一个方法被重复调用,不应该产生不良结果
         */
        // 如果已经有冻结记录,状态是已回滚:直接结束
        if (freeze.getState() == AccountFreeze.State.CANCEL) {
            return;
        }


        // 2.把冻结的数据加回给余额
        accountMapper.refund(freeze.getUserId(), freeze.getFreezeMoney());

        // 3.把冻结的数据状态设置为cancel已回滚
        freeze.setState(AccountFreeze.State.CANCEL);
        freeze.setFreezeMoney(0);
        freezeMapper.updateById(freeze);
    }
}
  • 解决了:TCC的空回滚、业务悬挂和幂等性问题

4、修改账户微服务的controller代码,让Controller调用AccountTCCService的deduct方法

package cn.aopmin.account.controller;

import cn.aopmin.account.service.AccountService;
import cn.aopmin.account.service.AccountTCCService;
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 {

    @Autowired
    // private AccountService accountService;
    private AccountTCCService accountTCCService;

    @PutMapping("/{userId}/{money}")
    public ResponseEntity<Void> deduct(@PathVariable("userId") String userId, @PathVariable("money") Integer money) {
        // accountService.deduct(userId, money);
        accountTCCService.deduct(userId, money);
        return ResponseEntity.noContent().build();
    }
}

5、重启所有微服务,用postman测试

image-20240512231358891

image-20240512232009886

数据库表数据:

image-20240512232124960


3.2 TCC模式的几个问题

image-20240512232805672

空回滚:

当某分支事务的try阶段阻塞时,可能导致全局事务超时而触发二阶段的cancel操作。在未执行try操作时先执行了cancel操作,这时就要允许空回滚

业务悬挂:

对于已经空回滚的业务,之前被阻塞的try操作恢复,继续执行try,就永远不可能confirm或cancel ,事务一直处于中间状态,这就是业务悬挂。执行try操作时,应当判断cancel是否已经执行过了,如果已经执行,应当阻止空回滚后的try操作,避免悬挂

幂等性:

当TC通知RM提交或回滚时,如果RM明明已经提交或回滚,但是因为某些原因(例如网络拥堵)导致没有给TC返回结果,TC会重复通知RM提交或回滚,直到收到结果为止。

为了避免Try或Confirm业务的重复执行,Try和Confirm需要实现幂等:判断一下事务的状态,如果已经处理过,就直接返回成功,结束即可。


3.3 TCC模式的原理

image-20240512232308737

Seata的TCC模式两阶段提交过程:

  • 一阶段:
    • 开启全局事务,各分支事务注册到TC
    • 各分支事务执行Try方法,直接提交
    • 向TC上报自己的状态
  • 二阶段:
    • TC根据各分支事务的状态做最终的决策,然后通知给所有的分支事务
    • 各分支事务根据TC决策:
      • 如果要提交:就执行Confirm方法
      • 如果要回滚:就执行Cancel方法

3.4 TCC模式的优缺点

优点:

  • 一阶段完成直接提交事务,释放数据库资源,性能好
  • 基于资源预留实现数据隔离;相比AT模型,无需生成快照,无需使用全局锁,性能更强
  • 不依赖数据库事务,而是依赖补偿操作,可以用于非关系型数据库

缺点:

  • 有代码侵入,需要人工编写try、Confirm和Cancel接口,太麻烦
  • 软状态,事务是最终一致
  • 需要考虑Confirm和Cancel的失败情况,做好幂等处理

4. SAGA模式

Saga 模式是 Seata 开源的长事务解决方案,将由蚂蚁金服主要贡献。

其理论基础是Hector & Kenneth 在1987年发表的论文Sagas

Seata官网对于Saga的指南:https://seata.io/zh-cn/docs/user/saga.html

适用场景:

  • 业务流程长、业务流程多
  • 参与者包含其它公司或遗留系统服务(如 屎山代码),无法提供 TCC 模式要求的三个接口

4.1 原理说明

在 Saga 模式下,分布式事务内有多个参与者,每一个参与者都是一个冲正补偿服务,需要用户根据业务场景实现其正向操作和逆向回滚操作。

分布式事务执行过程中,依次执行各参与者的正向操作,如果所有正向操作均执行成功,那么分布式事务提交。如果任何一个正向操作执行失败,那么分布式事务会去退回去执行前面各参与者的逆向回滚操作,回滚已提交的参与者,使分布式事务回到初始状态。

image-20210724184846396·

Saga也分为两个阶段:

  • 一阶段:直接提交本地事务
  • 二阶段:成功则什么都不做;失败则通过编写补偿业务来回滚

4.2 SAGA模式的优缺点

优点:

  • 事务参与者可以基于事件驱动实现异步调用,吞吐高
  • 一阶段直接提交事务,无锁,性能好
  • 不用编写TCC中的三个阶段,实现简单

缺点:

  • 软状态持续时间不确定,时效性差

  • 没有锁,没有事务隔离,会有脏写。

    某一个环节给你转账过去了,后边的环节出错要补偿撤消。但是转给你的钱,已经被你花掉了

    做法:

    • 宁可长款,不可短款:商家宁可多收你钱,如果出错最后退给你;也不能先给你钱,出错后找你要
    • 具体实现:扣你钱的操作放到前边,给你加钱的操作放到最后的环节

5. 四种模式对比

image-20240512234410622

  • 12
    点赞
  • 15
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

白豆五

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值