事务必须具有的四个特性分别是:原子性
(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框架就是分布式事务框架。
1998年,加州大学的计算机科学家Eric Brewer提出,分布式系统有三个指标:
Consistency(一致性)
:用户访问分布式系统中的任意节点,得到的数据必须一致;Availability(可用性)
:用户访问集群中的任意健康节点,必须能得到响应,而不是超时或拒绝;Partition tolerance(分区容错性)
:如果因为网络故障或其它原因导致分布式系统中的部分节点与其它节点失去连接,就会形成独立分区,在集群出现分区时,整个系统也要持续对外提供服务。P
),当分区出现时,系统的一致性(C
)和可用性(A
)就无法同时满足了,举例如下:如果这时候对node01节点上的数据进行了修改,是可以将数据同步到node02节点的,但是由于出现了分区,所以数据无法同步到node03节点,这样三个节点的数据就不一致了。
如果我们想要满足一致性,那就需要阻塞或拒绝访问node03节点的请求,等网络连接问题恢复后,数据同步也完成了,再恢复对node03节点的访问。但是node03节点明明是一个健康节点,却拒绝请求访问,这样就不满足可用性了。
说明:由此可见,我们总是无法同时满足一致性、可用性和分区容错性的。如果想要在分区的时候保持一致性,就必须牺牲一部分的可用性;如果不想牺牲可用性,就会面临一致性的问题。
BASE理论是对CAP的一种解决思路,包含三个思想:
Basically Available(基本可用)
:分布式系统在出现故障时,允许损失部分可用性,即保证核心可用;Soft State(软状态)
:在一定时间内,允许出现中间状态,比如临时的不一致状态;Eventually Consistent(最终一致性)
:虽然无法保证强一致性,但是在软状态结束后,最终达到数据一致。AP模式
:各子事务分别执行和提交,允许出现结果不一致,然后采用弥补措施恢复数据即可,实现最终一致。CP模式
:各个子事务执行后互相等待,同时提交,同时回滚,达成强一致。但事务等待过程中,处于弱可用状态。解决分布式事务,各个子系统之间必须能感知到彼此的事务状态,才能保证状态一致,因此需要一个事务协调者来协调每一个事务的参与者(子系统事务)。
这里的子系统事务,称为分支事务
,有关联的各个分支事务在一起称为全局事务
。
Seata是2019年1月份蚂蚁金服和阿里巴巴共同开源的分布式事务解决方案,致力于提供高性能和简单易用的分布式事务服务,为用户打造一站式的分布式解决方案。
Seata事务管理中有三个重要的角色:
TC(Transaction Coordinator) - 事务协调者
:维护全局和分支事务的状态,协调全局事务提交或回滚;TM(Transaction Manager) - 事务管理器
:定义全局事务的范围、开始全局事务、提交或回滚全局事务;RM(Resource Manager) - 资源管理器
:管理分支事务处理的资源,与TC交谈以注册分支事务和报告分支事务的状态,并驱动分支事务提交或回滚。XA模式
:强一致性分阶段事务模式,牺牲了一定的可用性,无业务侵入;TCC模式
:最终一致的分阶段事务模式,有业务侵入;AT模式
:最终一致的分阶段事务模式,无业务侵入,也是Seata的默认模式;SAGA模式
:长事务模式,有业务侵入。说明:一般最常用的是AT模式,如果某些接口对于性能的要求非常高的话,这些接口可以使用TCC模式,其他接口还是使用AT模式,多种分布式事务模式是可以共存的。
这里部署的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 = 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端口运行的。
我们需要将本地项目的account-service服务、order-service服务以及storage-service服务都按照以下步骤进行依赖的引入以及配置文件的修改。
首先我们需要在各个服务中引入seata依赖:
<dependency>
<groupId>com.alibaba.cloudgroupId>
<artifactId>spring-cloud-starter-alibaba-seataartifactId>
<exclusions>
<exclusion>
<artifactId>seata-spring-boot-starterartifactId>
<groupId>io.seatagroupId>
exclusion>
exclusions>
dependency>
<dependency>
<groupId>io.seatagroupId>
<artifactId>seata-spring-boot-starterartifactId>
<version>${seata.version}version>
dependency>
说明:在
spring-cloud-starter-alibaba-seata
里面引入的seata的版本较低,因此上面排除掉了,重新引入了1.4.2版本的seata。
然后分别修改各个服务的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
Seata通过XA
、AT
、TCC
和SAGA
这四种事务模式,为我们打造了一站式的分布式解决方案,下面会分别演示这四种事务模式是如何解决分布式事务问题的。
XA规范是X/Open组织定义的分布式事务处理(DTP,Distributed Transaction Processing)标准,XA规范描述了全局的TM与局部的RM之间的接口,几乎所有主流的数据库都对XA规范提供了支持,它一般包含两个阶段。
第一阶段只是做一个准备就绪的工作,并不会提交事务,第一阶段如果都成功了,才会在第二阶段进行事务的提交。但是如果第一阶段有任何一个服务是失败的,那么在第二阶段,其余服务也会进行回滚。
以上规范只是一个标准,在具体实现的时候,可能会有差别。Seata的XA模式在实现上与上面大体相似,但是也做了一些调整,主要是增加了一个事务管理器(TM):
Seata的starter已经完成了XA模式的自动装配,所以实现非常简单,步骤如下:
修改所有参与事务的微服务的application.yml文件,开启XA模式:
seata:
data-source-proxy-mode: XA # 开启数据源代理的XA模式
给发起全局事务的入口类或方法添加@GlobalTransactional
注解,我这边就直接将order-service服务中的全局事务入口类OrderServiceImpl之前的@Transactional
注解换成了@GlobalTransactional
注解:
重启服务并测试:
测试前数据库表数据情况如下所示:
然后以POST方式访问http://localhost:8082/order/create
接口,请求体内容如下:
{"userId":"user202109132032012","commodityCode":"100202109032041","count":20,"money":200}
由于我们将商品数量设置成了20,已经超过了库存数量,所以请求一定会报错,接口调用完成之后我们再次查看数据库中各个表的数据时发现,三个表的数据都没有变化,说明分布式事务已经起作用了,并完成了回滚。我们从控制台中account-service服务打印的日志也可以看到回滚的日志:
说明:至此,Seata的XA模式就演示完成了。XA模式默认是等待和其他微服务一起提交事务,所以比较消耗性能,我们一般不使用这种模式,除非是对强一致性要求比较高的服务。
AT模式同样是分阶段提交的事务模型,不过却弥补了XA模型中资源锁定周期过长的缺陷。详细步骤如下所示:
阶段一RM的工作:
阶段二提交时RM的工作:
阶段二回滚时RM的工作:
举个例子,比如一个分支业务的SQL是这样的:update tb_account set money = money - 10 where id = 1
,那么两个阶段的执行逻辑就如下所示:
说明:在AT模式中,记录、删除或者恢复快照等操作,都是由框架自动完成的,是不用我们手动操作的。
AT模式和XA模式的区别:
由于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后的快照,看看数据库中的数据是不是和快照的一致,一致就回滚,不一致就说明自己在操作期间还有别的事务对数据库中相同的数据进行了操作,那就发出警告通知人工介入来进行解决。
AT模式的优点:
AT模式的缺点:
AT模式中的快照生成、回滚等动作都是由框架自动完成,没有任何代码侵入,因此实现非常简单。
在我们自己微服务关联的数据库(库名为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;
修改所有参与分布式事务的服务的application.yml文件,将事务模式修改为AT模式:
seata:
data-source-proxy-mode: AT # 开启数据源代理的AT模式
说明:这里不设置
seata.data-source-proxy-mode
属性也可以,因为Seata默认使用的就是AT模式。
重启服务进行测试:
可以发现,当我们多个服务中有一个因为报错而回滚时,其他服务也会回滚。从account-service服务的控制台日志也可以看出确实已经进行了回滚操作:
然后undo_log表里面也会增加快照的信息,不过不管事务最终是成功提交还是回滚,该表中的数据都会被立即删除掉,所以我们在表里面最终是看不到内容的。通过某种方式,我在表数据被删除前查询出来了数据,如下所示:
说明:以上undo_log表中rollback_info字段的内容我给放到rollback_info.json文件中了,该文件中的
beforeImage
属性和afterImage
属性的内容对应的就是执行业务sql前和执行业务sql后的快照信息。
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的工作模型图如下所示:
TCC模式的优点:
TCC模式的缺点:
当某分支事务的try阶段阻塞时,可能导致全局事务超时而触发二阶段的cancel操作。在未执行try操作的时候却先执行了cancel操作,这时cancel做回滚,就是空回滚
。对于已经空回滚的业务,如果以后继续执行try操作,就永远不可能confirm或cancel,这就是业务悬挂
。应当阻止执行空回滚后的try操作,避免悬挂。
为了应对空回滚、防止业务悬挂以及实现幂等性要求,我们必须在数据库记录冻结金额的同时,记录当前事务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 {
@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);
}
}
修改完成之后,重启服务进行测试即可。我们还是将购买的数量设置的超过库存数量,以便报错。然后我们查看数据库相关数据时会发现,所有服务都进行了回滚,通过控制台打印的日志也可以看出来:
而且account_freeze_tbl表中还会多出下面这样一条数据:
说明:表里的
state
字段的值为2就说明,TCC模式在回滚的时候确实执行了cancel方法。
Saga模式是Seata提供的长事务解决方案。也分为两个阶段:
Saga的优点:
Saga的缺点:
说明:由于Saga模式的缺点明显,并且使用场景并不多,所以这里就不再进行实战演示了。
说明:可以直接点击seata-demo下载截止到目前为止Seata章节的项目代码。