分布式事务

82 阅读27分钟

一、分布式事务的问题

1. 本地事务

事务概念:即传统的单机事务,是数据库的概念,表示由一个或多个操作组成的一个业务。比如:银行转账

事务作用:组成事务的多个操作单元,在操作数据库时,要成功都成功,要失败都失败

事务特性:ACID

image-20210724165045186.png

  • 多个数据库操作如果想要属于同一个事务:必须使用同一个数据库连接
  • 如果开启了事务,在数据库底层会对数据加锁:如果一个事务长时间不提交,一定会影响性能

2. 分布式事务

2.1 介绍

在分布式环境上同样需要事务来保证数据的一致性。而因为跨数据源或跨服务环境所导致的传统事务不可用,形成的新的事务需求,这样的事务叫分布式事务。

传统事务中,要想让多个操作属于同一事务,就需要使用同一个数据库连接Connection对象。但是在分布式环境下,通常是做不到这一点的,必须使用分布式事务。比如:

  • 跨数据源的分布式事务:程序要操作不同服务器上的数据库
  • 跨服务的分布式事务:程序要调用多个服务,每个服务都要操作数据库
  • 综合情况

2.2 示例场景

电商行业中比较常见的下单付款案例,包括下面几个行为:

  • 创建新订单
  • 扣减商品库存
  • 从用户账户余额扣除金额

要完成上面的操作,需要访问三个不同的微服务和三个不同的数据库,如图:

image-20210724165338958.png

订单的创建、库存的扣减、账户扣款在每一个服务和数据库内是一个本地事务,可以保证ACID原则。

但是当我们把三件事情看做一个"业务",要满足保证“业务”的原子性,要么所有操作全部成功,要么全部失败,不允许出现部分成功部分失败的现象,这就是分布式系统下的事务要解决的问题了

二、CAP定理和BASE理论

分布式事务问题的处理,其实就是在数据的一致性与服务的可用性之间做一个权衡:

  • 如果要保证所有子事务的数据一致性:就要舍弃一些服务的可用性。因为数据库事务会对数据行加锁
  • 如果要保证所有服务的可用性:就要考虑一下数据的一致性如何处理

解决分布式事务问题,需要一些分布式系统的基础知识作为理论指导

1. CAP定理

1998年,加州大学的计算机科学家 Eric Brewer 提出,分布式系统有三个指标:一致性(Consistency)、可用性(Availability)、分区容错性(Partition tolerance)。 Eric Brewer 说,这三个指标不可能同时做到,这个结论就叫做 CAP 定理。

image-20210724170517944.png

1.1 CAP三指标介绍

1.1.1 C一致性

一致性Consitency,即 用户访问分布式系统中的任意节点,得到的数据必须一致(业务上的一致)。这就要求节点之间必须要及时同步数据

比如:集群中有两个节点,初始数据是一致的;当修改了其中一个节点的数据时,要把数据变更立即同步到另外一个节点,保证所有节点的数据是一致的。

CAP-一致性.gif

1.1.2 A可用性

可用性Availability,即 用户访问集群中的任意健康节点,必须能立即得到响应,而不是超时或拒绝。

CAP-可用性.gif

1.1.3 P分区容错性

分区Partition:因为网络故障或者其它原因,导致分布式系统中的部分节点与其它节点失联,形成独立分区

分区容错性Partition Tolerance,即 集群出现分区时,整个系统也要持续对外提供服务

image-20220717172620757.png

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模式:各个子事务执行后互相等待,同时提交,同时回滚,达成强一致。但事务等待过程中,处于弱可用状态。

01.分布式事务的解决思路.png

3.2 几个概念

  • 分支事务RM:ResourceManager 在整个业务中,每一个子系统的事务,称为一个分支事务。

    对应@Transactional

  • 全局事务TM:TransactionManager 一个完整的业务,需要众多分支事务共同组成一个全局事务。

    对应@GlobalTransactional

  • 事务协调者TC:TransactionCoordinator 用于在整个全局事务里,管理、协调各个分支事务的状态。

    对应Seata软件

三、Seata入门与整合

1. Seata简介

Seata是 2019 年 1 月份蚂蚁金服和阿里巴巴共同开源的分布式事务解决方案。致力于提供高性能和简单易用的分布式事务服务,为用户打造一站式的分布式解决方案。

官网地址:seata.io/,其中的文档、博客中提…

2. Seata架构

Seata事务管理中有三个重要的角色:

  • TC (Transaction Coordinator) - 事务协调者: 维护全局和分支事务的状态,协调全局事务提交或回滚。

    是Seata本身

  • TM (Transaction Manager) - 事务管理器: 定义全局事务的范围、开启全局事务、提交或回滚全局事务。

    负责事务的边界。@GlobalTransactional

  • RM (Resource Manager) - 资源管理器: 处理分支事务的资源,与TC交谈以注册分支事务和报告分支事务的状态,并驱动分支事务提交或回滚。@Transactional

image-20210724172326452.png

Seata基于上述架构提供了四种不同的分布式事务解决方案:

  • XA模式:强一致性分阶段事务模式,牺牲了一定的可用性,无业务侵入
  • AT模式:最终一致的分阶段事务模式,无业务侵入,也是Seata的默认模式
  • TCC模式:最终一致的分阶段事务模式,有业务侵入
  • SAGA模式:长事务模式,有业务侵入

无论哪种方案,都离不开TC,也就是事务的协调者。

3. Seata部署

seata-server中分布式事务中充当了TC的角色

image-20240509110012981.png

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中配好

  1. 在nacos中新建配置:

image-20220720154437057.png

  1. 配置的内容如下:

    注意:其中的数据库地址、帐号、密码都要修改成自己的

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

  1. 先启动nacos

  2. 启动seata:

    进入seata的bin目录,运行其中的seata-server.bat

image-20220717204438861.png

  1. 验证是否启动:

    如果启动成功了,seata-server应该已经注册到nacos注册中心了

    我们打开nacos,看一下有没有seata服务

image-20220717204557802.png

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的最终决策,提交或回滚

如图:

image-20240512161318448.png

Seata的XA基本架构如图:

image-20210724174424070.png

1.2 XA模式的优缺点

XA模式的优点:

  • 事务的强一致性,满足ACID原则。
  • 多事务(即多分布式事务之间)之间是完全隔离。多事务并发完全不受影响
  • 常用RDBMS数据库都支持,实现简单,并且没有代码侵入

XA模式的缺点:

  • 性能较差,因为一阶段需要锁定数据库资源,等待二阶段结束才释放,所以性能较差
  • 依赖关系型数据库实现事务。NoSQL参与不进来

1.3 使用示例

Seata的依赖启动器已经完成了XA模式的自动装配,使用起来非常简单,步骤:

  1. 修改配置文件,开启XA模式

  2. 修改全局事务入口方法,添加注解@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备份

如图:

image-20240512162843888.png

Seata的AT基本架构如图:

image-20210724175327511.png

2.2 AT与XA的区别

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

2.3 脏写问题

多线程并发访问AT模式的分布式事务时,可能出现脏写问题。如图:

image-20210724181541234.png

解决思路就是引入了全局锁的概念。在释放DB锁之前,先拿到全局锁。避免同一时刻有另外一个事务来操作当前数据。

具体的过程是:

一阶段:全程持有数据的DB锁

开启事务:抢DB锁,锁定要修改的数据

执行SQL并备份:备份的是变更前后的数据。比如 余额之前100,之后90

加全局锁:对数据加全局锁===> 事务xx对xx数据持有全局锁

提交事务:释放DB锁

一和二阶段之间:持有数据的全局锁

在这个阶段,其它全局事务不可能抢到数据的全局锁,不可能对数据进行修改

但是其它 非Seata事务仍然可以修改数据

二阶段:如果要回滚,全程持有数据的DB锁

开启事务:抢DB锁,锁定要修改的数据

释放全局锁

恢复数据:拿备份的数据进行恢复

  • 先判断:数据库里当前数据,和自己之前修改后的数据是否相同
  • 如果不同:说明在我一阶段到二阶段之间,数据被其他人修改了,Seata会报错,等待人工干预
  • 如果相同:说明在我一阶段一二阶段之间,数据没有被别人修改,直接恢复数据即可

提交事务:释放DB锁

image-20210724181843029.png

2.4 AT模式的优缺点

AT模式的优点:

  • 一阶段完成直接提交事务,释放数据库资源,性能比较好
  • 利用全局锁实现事务之间的隔离
  • 没有代码侵入,框架自动完成回滚和提交

AT模式的缺点:

  • 两阶段之间属于软状态,属于最终一致,存在数据的临时不一致状态
  • 框架的快照功能会影响性能,但比XA模式要好很多

2.5 使用示例

T模式中的快照生成、回滚等动作都是由框架自动完成,没有任何代码侵入,因此实现非常简单。

只不过,AT模式需要一个表来记录全局锁、另一张表来记录数据快照undo_log。

AT模式的使用步骤:

  1. 在微服务的库里准备一张undo_log表。给seata用的,我们的代码用不上
  2. 修改所有微服务的配置文件,设置事务模式为AT。 seata.data-source-proxy-mode=AT
  3. 全局事务入口方法上加 @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

我们这里在OrderServiceImplcreate方法上添加

3. TCC模式

TCC模式与AT模式非常相似,每阶段都是独立事务,不同的是TCC通过人工编码来实现数据恢复。需要实现三个方法:

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

3.1 TCC模式的实现流程

举例说明:减扣余额的业务。

  • 假设帐户A原本余额是100,需要减扣30元。
  • 要提前准备一个位置存储冻结金额,例如:创建一张数据库表,存储冻结的金额

一阶段(Try)

检查余额是否充足。如果余额充足,则扣除余额30元,在冻结金额里增加30元。

此时总金额 = 余额 + 冻结金额,总数仍然是100元不变,分支事务可以直接提交,无需等待其它事务

image-20210724182457951.png

二阶段(Confirm)

如果TC通知要提交,则冻结金额-30,直接提交; 用户的余额不变

此时总金额 = 余额 + 冻结金额,总数是70

image-20210724182706011.png

二阶段(Cancel)

如果TC通知要回滚,则释放冻结金额,恢复用户余额,即:冻结金额-30,用户余额+30

此时总金额 = 余额 + 冻结金额,总数是100

image-20210724182810734.png

3.2 Seata的TCC模式

TCC模式两阶段提交:

一阶段:

​ 各分支事务注册到TC

​ 各分支事务执行Try方法,直接提交

​ 向TC上报自己的状态

二阶段:

​ TC根据各分支事务的状态做最终的决策,然后通知给所有的分支事务

​ 各分支事务根据TC决策:

  • 如果要提交:就执行Confirm方法

  • 如果要回滚:就执行Cancel方法

如图:

image-20240512165511173.png

Seata的TCC基本架构如图:

image-20210724182937713.png

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。

步骤:

  1. 准备一张表,用于存储冻结的数据;准备这张表的实体类、Mapper等等

  2. 编写TCC接口:Service层的接口

  • 类上加@LocalTCC

  • 类里要编写3个抽象方法:

    Try方法:方法名随意,执行数据的扣除与冻结

      方法上加@TwoPhaseBusinessAction(name="唯一标识",commitMethod="confirm方法",rollbackMethod="cancel方法")	
      	告诉Seata,哪个方法是Confirm,哪个方法是Cancel
      方法的形参加@BusinessActionContextParameter(paramName="参数名")
      	告诉Seata,把这个形参的值维护起来。后续在Confirm和Cancel方法里通过BusinessActionContext对象可以获取到
    

    Confirm方法:方法名随意,相当于提交事务,清除冻结数据

    Cancel方法:方法名随意,相当于回滚事务,把冻结的数据加回到原始数据里

  1. 编写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调用AccountTCCServicededuct方法

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

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

image-20210724184846396.png

Saga也分为两个阶段:

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

4.2 优缺点

优点:

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

缺点:

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

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

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

    做法:

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

适用场景:

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

5. 四种模式对比

image-20210724185021819.png

五、Seata高可用

Seata的TC服务作为分布式事务核心,一定要保证seata集群的高可用性。

1. Seata高可用架构模型

搭建TC服务集群非常简单,启动多个TC服务,注册到nacos即可,但集群并不能确保100%安全。例如集群所在机房故障了怎么办?

所以如果可用性要求较高,一般都会做异地多机房容灾。比如一个TC集群在上海,另一个TC集群在杭州:

image-20210724185240957.png

微服务基于事务组(tx-service-group)与TC集群的映射关系,来查找当前应该使用哪个TC集群。当SH集群故障时,只需要将vgroup-mapping中的映射关系改成HZ。则所有微服务就会切换到HZ的TC集群了。