分布式事务框架Seata的使用

1. 分布式事务入门

事务必须具有的四个特性分别是:原子性(atomicity)、一致性(consistency)、隔离性(isolation,又称独立性)以及持久性(durability)。这就是事务的ACID原则。

请添加图片描述

下面进行分布式服务的案例演示,看看没有分布式事务时可能会引发的问题。项目工程是seata-demo,主要包含以下三个服务,分别是order-service(订单服务)、account-service(账户服务)以及storage-service(库存服务):
image-20211115104659133
项目的初始化数据库脚本如下所示,或者也可以直接下载seata-init.sql文件,内容是一样的。

SET NAMES utf8mb4;
SET FOREIGN_KEY_CHECKS = 0;

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

INSERT INTO `account_tbl` VALUES (1, 'user202109132032012', 1000);

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

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

INSERT INTO `storage_tbl` VALUES (1, '100202109032041', 10);

SET FOREIGN_KEY_CHECKS = 1;

本地使用的数据库地址是jdbc:mysql://localhost:3306/seata_demo。项目的主要业务逻辑是,用户通过订单服务创建订单,然后订单服务会调用账户服务进行用户余额扣减,还会调用库存服务进行商品库存扣减。
image-20211115105626824

执行完初始化数据库脚本后,三个表的数据情况如下所示:
image-20211115110549664
然后依次启动三个微服务项目:
image-20211115110722832
启动成功后,使用工具访问http://localhost:8082/order/create,请求方式为POST,请求体内容如下:

{"userId":"user202109132032012","commodityCode":"100202109032041","count":2,"money":200}

说明userId是用户id,commodityCode是商品编码,count是商品数量,money是商品金额。

出现如下结果,表示订单创建成功:
image-20211115111142372
成功创建订单后数据库中各个表的数据情况如下:
image-20211115112230526
以上演示的是正常情况,下面再来演示下异常情况。在上面提供的初始化数据库脚本中,storage_tbl表的count字段使用了UNSIGNED属性,该属性意思是count字段的值不能为负数,那么我们只要在创建订单时使商品数量大于当前数据库的库存数量即可抛出异常。

再次调用创建订单的http://localhost:8082/order/create接口,将商品数量设置为20,详细请求体如下所示:

{"userId":"user202109132032012","commodityCode":"100202109032041","count":20,"money":200}

返回的应答如下所示:
image-20211115123629621
由返回的应答可知,创建订单接口显然已经调用异常了,这时候我们再看看数据库中各个表的数据情况:
image-20211115124311003
由数据库中各个表的数据可知,在微服务项目中,其中一个服务出现了问题,并不会让所以服务都进行回滚,所以就出现了订单创建失败,但是用户余额依然进行扣减了的情况。而分布式事务就是用来解决以上问题的。

在分布式系统下,一个业务跨越多个服务或数据源,每个服务都是一个分支事务,要保证所有分支事务最终状态一致,这样的事务就是分布式事务。下面将要详细介绍的seata框架就是分布式事务框架。

2. 理论基础
2.1 CAP定理

1998年,加州大学的计算机科学家Eric Brewer提出,分布式系统有三个指标:

  • Consistency(一致性):用户访问分布式系统中的任意节点,得到的数据必须一致;
  • Availability(可用性):用户访问集群中的任意健康节点,必须能得到响应,而不是超时或拒绝;
  • Partition tolerance(分区容错性):如果因为网络故障或其它原因导致分布式系统中的部分节点与其它节点失去连接,就会形成独立分区,在集群出现分区时,整个系统也要持续对外提供服务。
    Eric Brewer说,分布式系统无法同时满足这三个指标,这个结论就叫做CAP定理
    image-20211115141413894
    分布式系统节点通过网络连接,一定会出现分区问题(P),当分区出现时,系统的一致性(C)和可用性(A)就无法同时满足了,举例如下:
    image-20220211104515552
    如上图所示,由于网络连接问题出现了分区,node01节点和node02节点是一个分区,node03节点是另一个分区。

如果这时候对node01节点上的数据进行了修改,是可以将数据同步到node02节点的,但是由于出现了分区,所以数据无法同步到node03节点,这样三个节点的数据就不一致了。

如果我们想要满足一致性,那就需要阻塞或拒绝访问node03节点的请求,等网络连接问题恢复后,数据同步也完成了,再恢复对node03节点的访问。但是node03节点明明是一个健康节点,却拒绝请求访问,这样就不满足可用性了。

说明:由此可见,我们总是无法同时满足一致性、可用性和分区容错性的。如果想要在分区的时候保持一致性,就必须牺牲一部分的可用性;如果不想牺牲可用性,就会面临一致性的问题。

2.2 BASE理论

BASE理论是对CAP的一种解决思路,包含三个思想:

  • Basically Available(基本可用):分布式系统在出现故障时,允许损失部分可用性,即保证核心可用;
  • Soft State(软状态):在一定时间内,允许出现中间状态,比如临时的不一致状态;
  • Eventually Consistent(最终一致性):虽然无法保证强一致性,但是在软状态结束后,最终达到数据一致。
    而分布式事务最大的问题是各个子事务的一致性问题,因此可以借鉴CAP定理和BASE理论:
  • AP模式:各子事务分别执行和提交,允许出现结果不一致,然后采用弥补措施恢复数据即可,实现最终一致。
  • CP模式:各个子事务执行后互相等待,同时提交,同时回滚,达成强一致。但事务等待过程中,处于弱可用状态。
2.3 分布式事务模型

解决分布式事务,各个子系统之间必须能感知到彼此的事务状态,才能保证状态一致,因此需要一个事务协调者来协调每一个事务的参与者(子系统事务)。

这里的子系统事务,称为分支事务,有关联的各个分支事务在一起称为全局事务

image-20211115205059742
3. Seata的入门
3.1 初识Seata

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

  • TC(Transaction Coordinator) - 事务协调者:维护全局和分支事务的状态,协调全局事务提交或回滚;
  • TM(Transaction Manager) - 事务管理器:定义全局事务的范围、开始全局事务、提交或回滚全局事务;
  • RM(Resource Manager) - 资源管理器:管理分支事务处理的资源,与TC交谈以注册分支事务和报告分支事务的状态,并驱动分支事务提交或回滚。
    image-20211115210638103
    Seata提供了四种不同的分布式事务解决方案:
  • XA模式:强一致性分阶段事务模式,牺牲了一定的可用性,无业务侵入;
  • TCC模式:最终一致的分阶段事务模式,有业务侵入;
  • AT模式:最终一致的分阶段事务模式,无业务侵入,也是Seata的默认模式;
  • SAGA模式:长事务模式,有业务侵入。

说明:一般最常用的是AT模式,如果某些接口对于性能的要求非常高的话,这些接口可以使用TCC模式,其他接口还是使用AT模式,多种分布式事务模式是可以共存的。

9.3.2 部署Seata

这里部署的Seata其实就是上文中提到的事务协调者(TC),我们可以直接到GitHub上进行下载,如果网速不好,也可以使用备用地址进行下载。下载完成之后,使用tar -zxvf seata-server-1.4.2.tar.gz 命令进行解压即可。

解压后,我们需要进行到seata/seata-server-1.4.2/conf/目录下,修改registry.conf文件的内容。该文件中主要是配置seata注册中心以及读取seata配置文件的方式,我们这里都使用nacos,修改后的内容如下:

registry {
  # seata tc服务的注册中心类,这里选择nacos,也可以是eureka、zookeeper等
  type = "nacos"

  nacos {
    # seata tc服务注册到nacos的服务名称,可以自定义
    application = "seata-tc-server"
    # nacos的地址
    serverAddr = "192.168.68.11:8848"
    # seata服务所在分组
    group = "DEFAULT_GROUP"
    # seata服务所在的名称空间,这里不填就是使用默认的"public"
    namespace = ""
    # 这个是seata在nacos中的集群配置,默认是"default"
    cluster = "SH"
    # 这个是nacos的用户名
    username = "nacos"
    # 这个是nacos的密码
    password = "nacos"
  }
}

config {
  # 读取tc服务端的配置文件的方式,这里是从nacos配置中心读取,这样如果tc是集群,可以共享配置
  type = "nacos"
  # 配置nacos地址等信息
  nacos {
    serverAddr = "192.168.68.11:8848"
    namespace = ""
    group = "SEATA_GROUP"
    username = "nacos"
    password = "nacos"
    dataId = "seataServer.properties"
  }
}

然后我们需要在nacos的配置管理里面添加上面设置的seataServer.properties文件:
image-20211115214148231

seataServer.properties文件的内容如下所示:

# 数据存储方式,db代表数据库
store.mode=db
store.db.datasource=druid
store.db.dbType=mysql
store.db.driverClassName=com.mysql.jdbc.Driver
store.db.url=jdbc:mysql://192.168.68.11:3306/seata?useUnicode=true
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

说明:其实seataServer.properties文件的内容我们只用关注store开头的配置即可,其余配置使用的都是默认的,所以不写也没事,这里贴出来是为方便以后万一想修改,知道需要修改的是哪些配置。

由于Seata在管理分布式事务时,需要记录事务相关数据到数据库中,因此我们需要提前创建好这些表,上面使用nacos的配置中心管理的seataServer.properties文件中,使用的是名为seata的库,所以首先要创建这个库,然后再在这个库中创建对应的表,建表语句如下所示,或者也可以直接下载seata-tc-server.sql文件,内容是一样的。

-- 全局事务表
CREATE TABLE IF NOT EXISTS `global_table`
(
    `xid`                       VARCHAR(128) NOT NULL,
    `transaction_id`            BIGINT,
    `status`                    TINYINT      NOT NULL,
    `application_id`            VARCHAR(32),
    `transaction_service_group` VARCHAR(32),
    `transaction_name`          VARCHAR(128),
    `timeout`                   INT,
    `begin_time`                BIGINT,
    `application_data`          VARCHAR(2000),
    `gmt_create`                DATETIME,
    `gmt_modified`              DATETIME,
    PRIMARY KEY (`xid`),
    KEY `idx_gmt_modified_status` (`gmt_modified`, `status`),
    KEY `idx_transaction_id` (`transaction_id`)
) ENGINE = InnoDB
  DEFAULT CHARSET = utf8;

-- 分支事务表
CREATE TABLE IF NOT EXISTS `branch_table`
(
    `branch_id`         BIGINT       NOT NULL,
    `xid`               VARCHAR(128) NOT NULL,
    `transaction_id`    BIGINT,
    `resource_group_id` VARCHAR(32),
    `resource_id`       VARCHAR(256),
    `branch_type`       VARCHAR(8),
    `status`            TINYINT,
    `client_id`         VARCHAR(64),
    `application_data`  VARCHAR(2000),
    `gmt_create`        DATETIME(6),
    `gmt_modified`      DATETIME(6),
    PRIMARY KEY (`branch_id`),
    KEY `idx_xid` (`xid`)
) ENGINE = InnoDB
  DEFAULT CHARSET = utf8;

-- 全局锁表
CREATE TABLE IF NOT EXISTS `lock_table`
(
    `row_key`        VARCHAR(128) NOT NULL,
    `xid`            VARCHAR(128),
    `transaction_id` BIGINT,
    `branch_id`      BIGINT       NOT NULL,
    `resource_id`    VARCHAR(256),
    `table_name`     VARCHAR(32),
    `pk`             VARCHAR(36),
    `gmt_create`     DATETIME,
    `gmt_modified`   DATETIME,
    PRIMARY KEY (`row_key`),
    KEY `idx_branch_id` (`branch_id`)
) ENGINE = InnoDB
  DEFAULT CHARSET = utf8;

CREATE TABLE IF NOT EXISTS `distributed_lock`
(
    `lock_key`       CHAR(20) NOT NULL,
    `lock_value`     VARCHAR(20) NOT NULL,
    `expire`         BIGINT,
    primary key (`lock_key`)
) ENGINE = InnoDB
  DEFAULT CHARSET = utf8mb4;

INSERT INTO `distributed_lock` (lock_key, lock_value, expire) VALUES ('AsyncCommitting', ' ', 0);
INSERT INTO `distributed_lock` (lock_key, lock_value, expire) VALUES ('RetryCommitting', ' ', 0);
INSERT INTO `distributed_lock` (lock_key, lock_value, expire) VALUES ('RetryRollbacking', ' ', 0);
INSERT INTO `distributed_lock` (lock_key, lock_value, expire) VALUES ('TxTimeoutCheck', ' ', 0);

完成以上操作后,就可以启动seata服务了,进入到seata/seata-server-1.4.2/bin目录中,直接执行如下命令即可:

sh seata-server.sh

启动成功后,我们可以在nacos的服务列表中看到一个名为seata-tc-server的服务,查看详情时可以发现,seata服务所在集群是SH,然后默认是以8091端口运行的。
image-20211115221050793
image-20211115221155614

3.3 集成Seata

我们需要将本地项目的account-service服务、order-service服务以及storage-service服务都按照以下步骤进行依赖的引入以及配置文件的修改。

  1. 首先我们需要在各个服务中引入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>
        <version>${seata.version}</version>
    </dependency>
    

    说明:在spring-cloud-starter-alibaba-seata里面引入的seata的版本较低,因此上面排除掉了,重新引入了1.4.2版本的seata。

  2. 然后分别修改各个服务的application.yml文件,添加以下配置:

    seata:
      registry: # seata tc服务注册中心的配置,微服务根据这些信息去注册中心获取tc服务地址
        # 下面参考seata服务的registry.conf中的配置
        type: nacos
        nacos:
          server-addr: 192.168.68.11:8848
          namespace: ""
          group: DEFAULT_GROUP
          application: seata-tc-server # tc服务在nacos中的服务名称
          cluster: SH
      tx-service-group: seata-demo # 事务组,根据这个获取tc服务的cluster名称
      service:
        vgroup-mapping: # 事务组与tc服务cluster的映射关系
          seata-demo: SH
    
4. Seata的模式

Seata通过XAATTCCSAGA这四种事务模式,为我们打造了一站式的分布式解决方案,下面会分别演示这四种事务模式是如何解决分布式事务问题的。

4.1 XA模式
4.1.1 XA模式入门

XA规范是X/Open组织定义的分布式事务处理(DTP,Distributed Transaction Processing)标准,XA规范描述了全局的TM与局部的RM之间的接口,几乎所有主流的数据库都对XA规范提供了支持,它一般包含两个阶段。
image-20211116194206492

第一阶段只是做一个准备就绪的工作,并不会提交事务,第一阶段如果都成功了,才会在第二阶段进行事务的提交。但是如果第一阶段有任何一个服务是失败的,那么在第二阶段,其余服务也会进行回滚。

以上规范只是一个标准,在具体实现的时候,可能会有差别。Seata的XA模式在实现上与上面大体相似,但是也做了一些调整,主要是增加了一个事务管理器(TM):

image-20211116200446770
4.1.2 XA模式的优缺点

image-20211116201552666

4.1.3 实现XA模式

Seata的starter已经完成了XA模式的自动装配,所以实现非常简单,步骤如下:

  1. 修改所有参与事务的微服务的application.yml文件,开启XA模式:

    seata:
      data-source-proxy-mode: XA # 开启数据源代理的XA模式
    
  2. 给发起全局事务的入口类或方法添加@GlobalTransactional注解,我这边就直接将order-service服务中的全局事务入口类OrderServiceImpl之前的@Transactional注解换成了@GlobalTransactional注解:
    image-20211116203854734

  3. 重启服务并测试:

    测试前数据库表数据情况如下所示:
    image-20211116204106608
    然后以POST方式访问http://localhost:8082/order/create接口,请求体内容如下:

    {"userId":"user202109132032012","commodityCode":"100202109032041","count":20,"money":200}
    

    由于我们将商品数量设置成了20,已经超过了库存数量,所以请求一定会报错,接口调用完成之后我们再次查看数据库中各个表的数据时发现,三个表的数据都没有变化,说明分布式事务已经起作用了,并完成了回滚。我们从控制台中account-service服务打印的日志也可以看到回滚的日志:
    image-20211116204901596

说明:至此,Seata的XA模式就演示完成了。XA模式默认是等待和其他微服务一起提交事务,所以比较消耗性能,我们一般不使用这种模式,除非是对强一致性要求比较高的服务。

4.2 AT模式
4.2.1 AT模式原理

AT模式同样是分阶段提交的事务模型,不过却弥补了XA模型中资源锁定周期过长的缺陷。详细步骤如下所示:
image-20211116210412067
阶段一RM的工作:

  • 注册分支事务;
  • 记录undo-log(数据快照);
  • 执行业务sql并提交
  • 报告事务状态。

阶段二提交时RM的工作:

  • 删除undo-log。

阶段二回滚时RM的工作:

  • 根据undo-log恢复数据到更新前。

举个例子,比如一个分支业务的SQL是这样的:update tb_account set money = money - 10 where id = 1,那么两个阶段的执行逻辑就如下所示:
image-20211116214516462

说明:在AT模式中,记录、删除或者恢复快照等操作,都是由框架自动完成的,是不用我们手动操作的。

AT模式和XA模式的区别:

  • XA模式一阶段不提交事务,锁定资源;AT模式一阶段直接提交,不锁定资源。
  • XA模式依赖数据库机制实现回滚;AT模式利用数据快照实现数据回滚。
  • XA模式是强一致;AT模式是最终一致。
4.2.2 脏写问题及解决方案

由于AT模式的事务都是各自提交各自的,并不是像XA模式那样等待着一起提交,所以在并发场景下可能会存在一些问题。还是以业务执行update account set money = money - 10 where id = 1这样一条SQL为例:
image-20211117143309275
解释说明:如上图所示,线程一在第一阶段首先会记录快照,即money等于100,执行完业务sql并提交后,money的值就变成了90。提交后就会释放数据库锁,假设此时线程二过来抢到了锁,由于此时money的值是90,所以就将此时的值作为线程二的快照进行了保存,然后就开始执行业务sql并提交,执行完之后money的值就变成了80。线程二提交后会释放数据库锁,假设线程一又抢到了锁,然后就开始进行第二阶段的操作,可是如果第二阶段出错了,就会根据之前记录的快照进行回滚,然后money的值就会变成100了。但是数据库中money的值已经被线程二改过一次了,线程一失败后还是将money的值回滚成100显然是有问题的。

为了解决以上提及的问题,Seata引入了全局锁的概念。持有全局锁的事务会被记录到一张表里,这个表里面会有事务的名称、事务正在操作的表的表名以及该表的主键等信息,具体的操作流程如下所示:
image-20211117170940040
解释说明:同样是对account表的money字段进行操作,线程一在执行完业务sql后会先获取全局锁,然后才会提交事务并释放数据库锁。假设这时线程二获取到了数据库锁,然后也执行了业务sql,执行完后就会去尝试获取全局锁,但是由于此时线程一整个阶段还没执行完,全局锁还没有被释放,所以线程二无法获取到全局锁。这时就会出现线程一等待获取数据库锁,线程二等待获取全局锁的局面。不过最多300毫秒后,线程二还没获取到全局锁的话,就会超时回滚,然后释放数据库锁。这时线程一获取到数据库锁后会继续执行第二阶段的流程,一旦执行失败,就会根据之前的快照进行回滚,然后释放全局锁。由于线程一全程持有全局锁,所以不会出现脏写的情况。

注意:AT模式和XA模式一样,都使用锁来保证数据的一致性,但是AT模式的性能却比XA模式要高,是因为它们的锁是有本质区别的。XA模式使用的是数据库锁,一旦锁定,所有对数据库中相关数据的操作都要等待,所以性能较差。但是AT模式的全局锁不同,它是Seata框架提供的锁,所以不使用该框架对数据库的操作是不受全局锁的影响的,而且如果操作的是同一行数据,只要和获取到全局锁的事务操作的不是同一个字段,也是不受影响的。

正是由于不通过Seata框架直接对数据库的操作不受全局锁影响,所以虽然概率很低,但是也还是可能出现某个事务和使用了Seata框架中的事务操作同一个表的同一行的相同字段的情况。如果真出现了这种情况,Seata框架的解决办法就是触发警告,然后通过人工接入的方式来解决,如下图所示:
image-20211117180250815
解释说明:其实Seata在保存快照的时候,是会保存两份的,即不仅会把更新前的数据保存为快照,还会把更新后的数据保存为快照。所以在事务一获取到全局锁后进行数据库操作时,如果还有一个非Seata管理的事务也对同样的数据进行了操作的话,一旦事务一操作有问题要回滚,Seata就会比对之前执行业务sql后的快照,看看数据库中的数据是不是和快照的一致,一致就回滚,不一致就说明自己在操作期间还有别的事务对数据库中相同的数据进行了操作,那就发出警告通知人工介入来进行解决。

4.2.3 AT模式的优缺点

AT模式的优点:

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

AT模式的缺点:

  • 两阶段之间属于软状态,属于最终一致;
  • 框架的快照功能会影响性能,但也比XA模式要好很多。
4.2.4 实现AT模式

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

  1. 在我们自己微服务关联的数据库(库名为seata_demo)中执行以下sql:

    CREATE TABLE IF NOT EXISTS `undo_log` (
      `id` bigint(20) NOT NULL AUTO_INCREMENT,
      `branch_id` bigint(20) NOT NULL COMMENT 'branch transaction id',
      `xid` varchar(100) NOT NULL COMMENT 'global transaction id',
      `context` varchar(128) 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 NOT NULL COMMENT 'create datetime',
      `log_modified` datetime NOT NULL COMMENT 'modify datetime',
      PRIMARY KEY (`id`),
      UNIQUE KEY `ux_undo_log` (`xid`,`branch_id`)
    ) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8;
    
  2. 修改所有参与分布式事务的服务的application.yml文件,将事务模式修改为AT模式:

    seata:
      data-source-proxy-mode: AT # 开启数据源代理的AT模式
    

    说明:这里不设置seata.data-source-proxy-mode属性也可以,因为Seata默认使用的就是AT模式。

  3. 给发起全局事务的入口类或方法添加@GlobalTransactional注解:
    image-20211117213401894

  4. 重启服务进行测试:

    可以发现,当我们多个服务中有一个因为报错而回滚时,其他服务也会回滚。从account-service服务的控制台日志也可以看出确实已经进行了回滚操作:
    image-20211117214532409
    然后undo_log表里面也会增加快照的信息,不过不管事务最终是成功提交还是回滚,该表中的数据都会被立即删除掉,所以我们在表里面最终是看不到内容的。通过某种方式,我在表数据被删除前查询出来了数据,如下所示:
    image-20211117214852250

    说明:以上undo_log表中rollback_info字段的内容我给放到rollback_info.json文件中了,该文件中的beforeImage属性和afterImage属性的内容对应的就是执行业务sql前和执行业务sql后的快照信息。

4.3 TCC模式
4.3.1 TCC模式原理

TCC(Try-Confirm-Cancel)模式与AT(Automatic Transaction)模式非常相似,每阶段都是独立事务,它们不同的是TCC通过人工编码来实现数据恢复,不用像AT模式那样先生成快照,然后在提交或回滚的时候再删除快照,减少了性能的损耗。TCC模式使用起来需要实现三个方法:

  • Try:资源的检测和预留;
  • Confirm:完成资源操作业务,要求Try成功Confirm一定要能成功;
  • Cancel:预留资源释放,可以理解为Try的反向操作。

比如说,有一个扣减用户余额的业务。假设账户A原来余额是100元,需要余额扣减30元。
image-20211117230603105
阶段一(Try):检查余额是否充足,如果充足则冻结金额增加30元,可用余额扣除30元。
image-20211117230642099
阶段二:假如要提交(Confirm),则扣减掉冻结金额的30元,可用余额变成70元。
image-20211117230803210
阶段二:如果要回滚(Cancel),则扣减掉冻结金额的30元,增加到可用余额中,可用余额增加30元后再次变回100元。
image-20211117230603106
TCC的工作模型图如下所示:
image-20211117231622451

4.3.2 TCC的优缺点

TCC模式的优点:

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

TCC模式的缺点:

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

当某分支事务的try阶段阻塞时,可能导致全局事务超时而触发二阶段的cancel操作。在未执行try操作的时候却先执行了cancel操作,这时cancel做回滚,就是空回滚。对于已经空回滚的业务,如果以后继续执行try操作,就永远不可能confirm或cancel,这就是业务悬挂。应当阻止执行空回滚后的try操作,避免悬挂。

4.3.4 实现TCC模式

为了应对空回滚、防止业务悬挂以及实现幂等性要求,我们必须在数据库记录冻结金额的同时,记录当前事务id和执行状态,为此我们设计了一张account_freeze_tbl表,直接在我们的微服务所在的seata_demo库中执行即可:

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;

然后下面我们做一个业务分析,如下所示:
image-20211117235213085
TCC的Try、Confirm、Cancel方法都需要在接口中基于注解来声明,语法如下:
image-20211117235459149
下面编写详细的操作步骤:

TCC模式和AT模式是可以共存的,所以虽然现在本地项目中已经配置并使用了AT模式,但是依然还可以使用TCC模式。首先我们需要创建上面account_freeze_tbl表对应的实体类,在account-service服务中新增相关实体类,如下所示:

package com.account.entity;

import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.io.Serializable;

@Data
@TableName("account_freeze_tbl")
public class AccountFreeze implements Serializable {
    @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,如下所示:

package com.account.mapper;

import com.account.entity.AccountFreeze;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;

/**
 * @author gongsl
 */
public interface AccountFreezeMapper extends BaseMapper<AccountFreeze> {

}

然后编写一个Try、Confirm、Cancel相关方法对应的AccountTCCService接口:

package com.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;

/**
 * @Author: gongsl
 * @Date: 2021-11-18 21:40
 */
@LocalTCC
public interface AccountTCCService {

    /**
     * Try逻辑,@TwoPhaseBusinessAction中的name属性要与当前方法名一致,用于指定Try逻辑对应的方法
     */
    @TwoPhaseBusinessAction(name = "prepare", 
                            commitMethod = "confirm", 
                            rollbackMethod = "cancel")
    void prepare(@BusinessActionContextParameter(paramName = "userId") String userId,
                 @BusinessActionContextParameter(paramName = "money") int money);

    /**
     * 二阶段Confirm确认方法,可以另命名,但要保证和上面commitMethod的值一致
     * @param context 上下文,可以传递try方法的参数
     * @return boolean 执行是否成功
     */
    boolean confirm(BusinessActionContext context);

    /**
     * 二阶段回滚方法,方法名要和上面rollbackMethod的值一致
     */
    boolean cancel(BusinessActionContext context);
}

之后就是编写以上接口对应的AccountTCCServiceImpl实现类:

package com.account.service.impl;

import com.account.entity.AccountFreeze;
import com.account.mapper.AccountFreezeMapper;
import com.account.mapper.AccountMapper;
import com.account.service.AccountTCCService;
import io.seata.core.context.RootContext;
import io.seata.rm.tcc.api.BusinessActionContext;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

/**
 * @Author: gongsl
 * @Date: 2021-11-18 21:55
 */
@Service
public class AccountTCCServiceImpl implements AccountTCCService {

    @Autowired
    private AccountMapper accountMapper;

    @Autowired
    private AccountFreezeMapper freezeMapper;

    @Override
    @Transactional
    public void prepare(String userId, int money) {
        //1.获取事务id
        String xid = RootContext.getXID();
        //判断表中是否已经有冻结记录,如果有,一定是CANCEL执行过,这时要拒绝业务避免业务悬挂
        AccountFreeze freeze = freezeMapper.selectById(xid);
        if (freeze != null) {
            //CANCEL已经执行过了,所以这里要拒绝业务
            return;
        }
        //2.扣减可用余额
        accountMapper.deduct(userId, money);
        //3.记录冻结金额、事务状态
        AccountFreeze accountFreeze = new AccountFreeze();
        accountFreeze.setXid(xid);
        accountFreeze.setUserId(userId);
        accountFreeze.setFreezeMoney(money);
        accountFreeze.setState(AccountFreeze.State.TRY);
        freezeMapper.insert(accountFreeze);
    }

    /**
     * 到这里,说明第一阶段已经执行成功,既然已经成功,所以这里只需要删除冻结记录即可
     * @param context
     * @return
     */
    @Override
    public boolean confirm(BusinessActionContext context) {
        //我们也可以通过上下文对象获取事务id
        String xid = context.getXid();
        //返回值为1说明删除成功
        int count = freezeMapper.deleteById(xid);
        return count == 1;
    }

    @Override
    public boolean cancel(BusinessActionContext context) {
        String xid = context.getXid();
        //查询冻结记录
        AccountFreeze freeze = freezeMapper.selectById(xid);
        //空回滚判断,如果freeze为null,说明try没执行,需要空回滚
        if (freeze == null) {
            freeze = new AccountFreeze();
            /*
            接口中我们已经通过@BusinessActionContextParameter注解将
            userId放到上下文对象中了,所以这里直接通过上下文对象获取即可
             */
            String userId = context.getActionContext("userId").toString();
            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了,无需重复处理
            return true;
        }
        //恢复可用余额
        accountMapper.refund(freeze.getUserId(), freeze.getFreezeMoney());
        //将冻结金额清零,状态改为CANCEL
        freeze.setFreezeMoney(0);
        freeze.setState(AccountFreeze.State.CANCEL);
        int count = freezeMapper.updateById(freeze);
        return count == 1;
    }
}

最后我们需要修改AccountController类,之前该类扣款的方法是调用的AccountService接口中的扣款方法,现在需要改成调用AccountTCCService接口中的prepare方法,如下所示:

package com.account.web;

import com.account.service.AccountService;
import com.account.service.AccountTCCService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;

/**
 * @author gongsl
 */
@RestController
@RequestMapping("account")
public class AccountController {

    @Autowired
    private AccountService accountService;

    @Autowired
    private AccountTCCService accountTCCService;

    /**
     * 扣款
     * @param userId
     * @param money
     * @return
     */
    @PostMapping("/{userId}/{money}")
    public void deduct(@PathVariable("userId") String userId,
                           @PathVariable("money") Integer money){
//        accountService.deduct(userId, money);
        //修改成调用下面的方法
        accountTCCService.prepare(userId, money); 
    }
}

修改完成之后,重启服务进行测试即可。我们还是将购买的数量设置的超过库存数量,以便报错。然后我们查看数据库相关数据时会发现,所有服务都进行了回滚,通过控制台打印的日志也可以看出来:
image-20211118232817383
而且account_freeze_tbl表中还会多出下面这样一条数据:
image-20211118232341494

说明:表里的state字段的值为2就说明,TCC模式在回滚的时候确实执行了cancel方法。

4.4 SAGA模式

Saga模式是Seata提供的长事务解决方案。也分为两个阶段:

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

Saga的优点:

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

Saga的缺点:

  • 软状态持续时间不确定,时效性差;
  • 没有锁,没有事务隔离,会有脏写。
    image-20211118233229764

说明:由于Saga模式的缺点明显,并且使用场景并不多,所以这里就不再进行实战演示了。

4.5 四种模式对比

image-20211118234142239

说明:可以直接点击seata-demo下载截止到目前为止Seata章节的项目代码。

  • 11
    点赞
  • 70
    收藏
    觉得还不错? 一键收藏
  • 9
    评论
Seata的高可用模式是通过TC使用db模式共享全局事务会话信息,使用非file的seata支持的第三方注册中心和配置中心来共享全局配置的方式来实现的。 Seata支持的第三方注册中心有nacos 、eureka、redis、zk、consul、etcd3、sofa、custom,支持的第三方配置中心有nacos 、apollo、zk、consul、etcd3、custom。seata官方主推的应该是nacos(毕竟是一家的),但是由于本人平常使用的注册中心一直是zk,因此首先考虑通过zk作为注册中心来实现高可用模式。 环境准备 zk环境准备 本地已安装zk的可以忽略,如果本地未安装,先在本地安装zk,具体安装自行百度。 PS: 此处如果使用的是远程zk服务器,则本地可以只下载,不安装。 数据库环境准备 1、创建数据库seata 2、执行源码(version1.2.0)script -> server -> db 下面的mysql.sql文件,建立global_table,branch_table,lock_table表。 配置信息导入zk 1、打开本地seata源码(版本1.2.0) 2、编辑script -> config-center -> config.txt文件,修改store.mode=db,修改store.db相关的数据库连接信息,其它默认即可 3、进入script -> config-center ->zk,执行 sh zk-config.sh -h 127.0.0.1 -p 2181 -z /usr/local/zookeeper-3.4.14(-z 后面的参数为你本地zk目录) 上面命令会将config.txt中的配置信息写入zk的/seata节点下。 启动tc-server 1、编辑conf下面的registry.conf文件,修改registry.type=zk,修改config.type=zk,修改registry.zk及config.zk信息,如下: 注意:config的zk配置没有cluster属性。 2、启动server,在本地seata安装目录bin目录下执行./seata-server.sh -m db (此处也可以直接编译本地源码然后启动Server模块下的Server类)。 不出意外的话,启动会报错,错误信息是从zk读取配置的时候反序列化出问题。 错误原因:序列化问题,由于使用seata自带的zk-config.sh脚本向zk写入配置信息的时候,采用的序列化方式相当于String.getBytes(),而框架读取配置的时候使用的是org.101tec包中的Zkclient客户端,反序列化使用的是该包下面的SerializableSerializer序列化类,使用的ObjectOutputStream进行反序列化,和序列化方式不一致。 该问题在1.3.0版本中解决,解决方式是seata支持序列化方式配置,支持自定义序列化方式,同时提供默认序列化实现类DefaultZkSerializer,反序列化实现为new String()。 到此处,1.2.0版本无法进行下去,由于目前1.3.0正式版本还未出,只能拉取最新的开发分支源码,本地编译打包1.3.0-SNAPSHOT版本。 后续版本切为1.3.0-SNAPSHOT(20200701),删除原zk配置信息重新导入1.3版本的config.txt文件信息。 本地源码编译后直接Idea启动Server类。启动成功。 PS:启动日志里面会有一些getConfig失败的报错,这些不用管,这些新的配置参数是1.3版本新增的,由于当前是SNAPSHOT版本,还不完善。 PS: 如果遇到getServerCharset 空指针异常,这个主要是MySQL和MySQL驱动版本不一致引起的,看https://blog.csdn.net/zcs20082015/article/details/107087589 服务启动 配置修改 简单处理,这里不再建新的模块,直接使用zhengcs-seata-storage模块作为演示。 1、修改POM,引入zkclient,修改seata版本 2、修改application.yml,将注册和配置类型改为zk 另外需要注意的是seata.tx-service-group配置参数要和zk导入的配置信息相关参数保持一致,否则会找不到server集群 启动服务 1、引入全局事务 2、启动 测试 基本功能测试 单元测试用例: 手动插入异常 执行用例: 基本功能是没问题的,更详细全面的测试这里就不介绍了,大家自行尝试。 高可用测试 上面的单机版肯定无法满足高可用,tc-server一旦宕掉,整个全局事务会无法进行回滚,同时会在seata库下面的事务表里面留下事务记录(正常处理成功后会被删除)。 seata的高可用是通过多个tc-server实例组成的集群来实现的。 启动多个tc-server实例: 通过-p参数修改启动接口,同时勾选Allow parallel run,开启多个实例。 然后启动客服端服务: 从启动日志可以看出,客户端会同时向所有几点注册TM和RM。 执行测试用例: 那,如果在数据已提交,异常退出之前把对应的tc-server节点停掉,会怎么样呢?答案是:全局事务回滚。大家自行尝试一下。 还有一种情况,如果客户端在执行过程中中断了,会怎么样? 如果客户端是单节点部署,那么: 首先,seata库下面的事务处理表里面有遗留事务处理记录,然后你会发现tc-server端日志里面会持续刷上述日志,tc-server会持续的尝试回滚该事务。最遗憾的是:哪怕客户端服务重启,也不会回滚该事务!!! 不过还好的是,这种情况毕竟是特例,如果客户端服务是集群部署,那么seata是可以正常完成事务回滚的。 结语 从上面的情况来看,起码seata对于简单的分布式事务场景的高可用支撑是没问题的,但是seata毕竟还是一个新框架,在实际的复杂的业务场景下会否出现什么问题,其实应该说出现什么问题都是可能的,这个需要实践和时间才能出真知了。 另外,seata目前没有提供控制台,没有服务集群配套的HA机制,这个不知道什么时候会出,拭目以待,seata应该会是一个很不错的分布式事务解决方案。   参考资料 https://seata.io/zh-cn/docs/ https://github.com/seata/seata ———————————————— 版权声明:本文为CSDN博主「zhengcs已被占用」的原创文章,遵循CC 4.0 BY-SA版权协议,转载请附上原文出处链接及本声明。 原文链接:https://blog.csdn.net/zcs20082015/article/details/107092936

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值