1. 分布式事务入门

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

下面进行分布式服务的案例演示,看看没有分布式事务时可能会引发的问题。项目工程是seata-demo,主要包含以下三个服务,分别是order-service(订单服务)、account-service(账户服务)以及storage-service(库存服务):

项目的初始化数据库脚本如下所示,或者也可以直接下载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。项目的主要业务逻辑是,用户通过订单服务创建订单,然后订单服务会调用账户服务进行用户余额扣减,还会调用库存服务进行商品库存扣减。

执行完初始化数据库脚本后,三个表的数据情况如下所示:

然后依次启动三个微服务项目:

启动成功后,使用工具访问http://localhost:8082/order/create,请求方式为POST,请求体内容如下:

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

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

出现如下结果,表示订单创建成功:

成功创建订单后数据库中各个表的数据情况如下:

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

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

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

返回的应答如下所示:

由返回的应答可知,创建订单接口显然已经调用异常了,这时候我们再看看数据库中各个表的数据情况:

由数据库中各个表的数据可知,在微服务项目中,其中一个服务出现了问题,并不会让所以服务都进行回滚,所以就出现了订单创建失败,但是用户余额依然进行扣减了的情况。而分布式事务就是用来解决以上问题的。

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

2. 理论基础

2.1 CAP定理

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

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

    分布式系统节点通过网络连接,一定会出现分区问题(P),当分区出现时,系统的一致性(C)和可用性(A)就无法同时满足了,举例如下:

    如上图所示,由于网络连接问题出现了分区,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 分布式事务模型

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

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

3. Seata的入门

3.1 初识Seata

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

  • TC(Transaction Coordinator) - 事务协调者:维护全局和分支事务的状态,协调全局事务提交或回滚;
  • TM(Transaction Manager) - 事务管理器:定义全局事务的范围、开始全局事务、提交或回滚全局事务;
  • RM(Resource Manager) - 资源管理器:管理分支事务处理的资源,与TC交谈以注册分支事务和报告分支事务的状态,并驱动分支事务提交或回滚。

    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文件:

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 = InnoDBDEFAULT 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 = InnoDBDEFAULT 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 = InnoDBDEFAULT 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 = InnoDBDEFAULT 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端口运行的。

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: nacosnacos:server-addr: 192.168.68.11:8848namespace: ""group: DEFAULT_GROUPapplication: seata-tc-server # tc服务在nacos中的服务名称cluster: SHtx-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规范提供了支持,它一般包含两个阶段。

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

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

4.1.2 XA模式的优缺点

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注解:

  3. 重启服务并测试:

    测试前数据库表数据情况如下所示:

    然后以POST方式访问http://localhost:8082/order/create接口,请求体内容如下:

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

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

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

4.2 AT模式
4.2.1 AT模式原理

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

阶段一RM的工作:

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

阶段二提交时RM的工作:

  • 删除undo-log。

阶段二回滚时RM的工作:

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

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

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

AT模式和XA模式的区别:

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

由于AT模式的事务都是各自提交各自的,并不是像XA模式那样等待着一起提交,所以在并发场景下可能会存在一些问题。还是以业务执行update account set money = money - 10 where id = 1这样一条SQL为例:

解释说明:如上图所示,线程一在第一阶段首先会记录快照,即money等于100,执行完业务sql并提交后,money的值就变成了90。提交后就会释放数据库锁,假设此时线程二过来抢到了锁,由于此时money的值是90,所以就将此时的值作为线程二的快照进行了保存,然后就开始执行业务sql并提交,执行完之后money的值就变成了80。线程二提交后会释放数据库锁,假设线程一又抢到了锁,然后就开始进行第二阶段的操作,可是如果第二阶段出错了,就会根据之前记录的快照进行回滚,然后money的值就会变成100了。但是数据库中money的值已经被线程二改过一次了,线程一失败后还是将money的值回滚成100显然是有问题的。

为了解决以上提及的问题,Seata引入了全局锁的概念。持有全局锁的事务会被记录到一张表里,这个表里面会有事务的名称、事务正在操作的表的表名以及该表的主键等信息,具体的操作流程如下所示:

解释说明:同样是对account表的money字段进行操作,线程一在执行完业务sql后会先获取全局锁,然后才会提交事务并释放数据库锁。假设这时线程二获取到了数据库锁,然后也执行了业务sql,执行完后就会去尝试获取全局锁,但是由于此时线程一整个阶段还没执行完,全局锁还没有被释放,所以线程二无法获取到全局锁。这时就会出现线程一等待获取数据库锁,线程二等待获取全局锁的局面。不过最多300毫秒后,线程二还没获取到全局锁的话,就会超时回滚,然后释放数据库锁。这时线程一获取到数据库锁后会继续执行第二阶段的流程,一旦执行失败,就会根据之前的快照进行回滚,然后释放全局锁。由于线程一全程持有全局锁,所以不会出现脏写的情况。

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

正是由于不通过Seata框架直接对数据库的操作不受全局锁影响,所以虽然概率很低,但是也还是可能出现某个事务和使用了Seata框架中的事务操作同一个表的同一行的相同字段的情况。如果真出现了这种情况,Seata框架的解决办法就是触发警告,然后通过人工接入的方式来解决,如下图所示:

解释说明:其实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注解:

  4. 重启服务进行测试:

    可以发现,当我们多个服务中有一个因为报错而回滚时,其他服务也会回滚。从account-service服务的控制台日志也可以看出确实已经进行了回滚操作:

    然后undo_log表里面也会增加快照的信息,不过不管事务最终是成功提交还是回滚,该表中的数据都会被立即删除掉,所以我们在表里面最终是看不到内容的。通过某种方式,我在表数据被删除前查询出来了数据,如下所示:

    说明:以上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元。

阶段一(Try):检查余额是否充足,如果充足则冻结金额增加30元,可用余额扣除30元。

阶段二:假如要提交(Confirm),则扣减掉冻结金额的30元,可用余额变成70元。

阶段二:如果要回滚(Cancel),则扣减掉冻结金额的30元,增加到可用余额中,可用余额增加30元后再次变回100元。

TCC的工作模型图如下所示:

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;

然后下面我们做一个业务分析,如下所示:

TCC的Try、Confirm、Cancel方法都需要在接口中基于注解来声明,语法如下:

下面编写详细的操作步骤:

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 {@Autowiredprivate AccountMapper accountMapper;@Autowiredprivate AccountFreezeMapper freezeMapper;@Override@Transactionalpublic void prepare(String userId, int money) {//1.获取事务idString 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*/@Overridepublic boolean confirm(BusinessActionContext context) {//我们也可以通过上下文对象获取事务idString xid = context.getXid();//返回值为1说明删除成功int count = freezeMapper.deleteById(xid);return count == 1;}@Overridepublic 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());//将冻结金额清零,状态改为CANCELfreeze.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 {@Autowiredprivate AccountService accountService;@Autowiredprivate 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); }
}

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

而且account_freeze_tbl表中还会多出下面这样一条数据:

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

4.4 SAGA模式

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

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

Saga的优点:

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

Saga的缺点:

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

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

4.5 四种模式对比

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

分布式事务框架Seata的使用相关推荐

  1. 阿里分布式事务框架Seata原理解析

    阿里分布式事务框架Seata原理解析 作者:伊凡的一天 链接:https://www.jianshu.com/p/044e95223a17 Seata框架是一个业务层的XA(两阶段提交)解决方案.在理 ...

  2. 分布式事务框架seata介绍

    分布式事务框架seata介绍 一 . 事务特性(ACID) 1.原子性(A) 在整个事务中的所有操作,要么全部完成,要么全部不做,没有中间状态.对于事务在执行中发生错误,所有的操作都会被回滚,整个事务 ...

  3. 阿里开源一站式分布式事务框架seata源码分析(AT模式下TM与RM分析)

    序言: 对于阿里开源分布式事务框架seata的详细了解可以参考官网,这里不会详细介绍.本章只会介绍seata中AT模式的源码分析(对阿seata有一定了解或者成功完成过demo). seata中一个事 ...

  4. 分布式事务——分布式事务简介、分布式事务框架 Seata(AT模式、Tcc模式、Tcc Vs AT)、分布式事务—MQ

    分布式事务--分布式事务简介.分布式事务框架 Seata(AT模式.Tcc模式.Tcc Vs AT).分布式事务--MQ 一.分布式事务简介 如果不是分布式环境的话一般不会接触到这种,一旦是微服务这种 ...

  5. 老板现在喊我大哥,原因是我用阿里分布式事务框架Seata解决了长久以来困扰公司的分布式事务问题

    大家好,我是曹尼玛 从大学毕业5年,一直努力学习,努力工作,追求新技术,不保守. 上个月我来到一家新公司上班,月薪20K,这家公司老板人很好,对员工很关爱,公司氛围不错,同事们也努力把公司项目搞搞好. ...

  6. 阿里分布式事务框架Seata集成详情

    大家好,我是曹尼玛 从大学毕业5年,一直努力学习,努力工作,追求新技术,不保守. 上个月我来到一家新公司上班,月薪20K,这家公司老板人很好,对员工很关爱,公司氛围不错,同事们也努力把公司项目搞搞好. ...

  7. 分布式事务框架Seata

    分布式事务框架Seata     sei达 一. 分布式事务前言 1. 数据库管理系统中事务(transaction)的四个特性:简称ACID(这种特性简称刚性事物) 原子性(Atomicity):原 ...

  8. 分布式事务框架Seata原理详解

    本文来深度解析下分布式事务框架Seata原理,知其然知其所以然. 文章目录 Seata概述 什么是2PC二阶段提交协议 MySQL XA方案 Seata核心知识 Seata设计目标 Seata组成结构 ...

  9. 关于分布式事务: 阿里开源的分布式事务框架 Seata 和 LCN的分析

    之前使用过LCN分布式事务, 最近看到面试者简历中另一种方案 Seata, 通过它来在实战中解决分布式事务的问题.故 去简单了解了一下Seata是什么, 和LCN的区别在哪里, 如果是你 你怎么选择解 ...

  10. 阿里分布式事务框架Seata

    Seata 是一款开源的分布式事务解决方案,致力于提供高性能和简单易用的分布式事务服务.Seata 将为用户提供了 AT.TCC.SAGA 和 XA 事务模式,为用户打造一站式的分布式解决方案. 中文 ...

最新文章

  1. ip地址子网划分工具_大型视频监控网络系统如何规划ip地址?
  2. Loadrunner 性能测试服务器监控指标
  3. 听说有人解决了三体问题,我去做了一点微小的调查
  4. 【semantic】本体和语义网的研究方向
  5. 2台xenserver组成的资源池开启HA存在的问题
  6. 大批物联网设备扎堆CES 物联网产业化提速
  7. classpath: spring 中的查找方式
  8. 怎么查看WINDOWS端口被哪个程序占用
  9. c++ idea 插件_IDEA的基本使用:让你的IDEA有飞一般的感觉
  10. 免费文件分发服务器,文件分发服务器 AWS CloudFront(CDN)使用入门-以S3为例 Lebal:Research...
  11. 魔兽世界插件编写-第一个插件-空插件 EmptyAddOns
  12. 操作员或系统管理员_什么是系统管理员?
  13. 《计算机应用基础》课程计划,计算机应用基础课程教学计划
  14. 语言处理 之 melgan
  15. Zabbix监控配置详解
  16. oracle pga的作用,Oracle PGA作用
  17. Windows系统解决谷歌翻译不能用的问题
  18. [附源码]Python计算机毕业设计电子病历系统
  19. MT6582 KK版本加入modem
  20. C语言——指针(入门详解)

热门文章

  1. 计算机老师教育叙事,信息技术教育叙事
  2. I love you three thousand times
  3. 树莓派实现USB存储设备自动挂载
  4. linux qq rpm最新版,腾讯重新发布QQ Linux版,完美适配新支点国产操作系统
  5. 972真题 | 基于ChatGpt提供2021-2023年部分927真题分析题思路
  6. 链游的困境与出路,一场暴风雨正在酝酿!
  7. Maya的Deform功能简写
  8. 看了很多经验贴后我发现kaggle比赛越早开始越好
  9. gorm.open mysql_golang gorm 操作mysql
  10. Smaller, faster, cheaper, lighter: Introducing DistilBERT, a distilled version of BERT 翻译