人生就像骑单车,想保持平衡就得往前走。
一、什么是分布式事务
事务的作用:保证数据的完整性和一致性。
本地事务也叫做单机事务,通常指操作的数据在同一个数据库中。
在传统的数据库事务中,必须要满足四个原则:
- 原子性(Atomicity):事务是不可分割的,事务里的所有操作,要么全部成功,要么全部失败。
- 一致性(Consistency):事务提交前后,数据的状态应该是一致的,符合业务要求的。
- 隔离性(Isolation):事务并发时,理论上应该是互不干扰、相互独立的。
- 持久性(Durability):事务一旦提交,数据就永久保存到磁盘文件上,即使系统发生故障或重启,数据也不会丢失。
分布式事务:在分布式系统中,完成一个业务功能可能需要跨多个服务 、操作多个数据库,此时传统的单机事务就不可用了,因为它们通常是为了单个数据库设计的,无法处理分布式环境下数据一致性问题。
1. 演示分布式事务的问题
1.1 数据库环境
执行sql脚本:
CREATE DATABASE IF NOT EXISTS seata_demo character SET utf8mb4;
USE seata_demo;
SET NAMES utf8mb4;
SET FOREIGN_KEY_CHECKS = 0;
-- ----------------------------
-- Table structure for account_tbl
-- ----------------------------
DROP TABLE IF EXISTS `account_tbl`;
CREATE TABLE `account_tbl` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`user_id` varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL,
`money` int(11) UNSIGNED NULL DEFAULT 0,
PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 2 CHARACTER SET = utf8 COLLATE = utf8_general_ci ROW_FORMAT = COMPACT;
-- ----------------------------
-- Records of account_tbl
-- ----------------------------
INSERT INTO `account_tbl` VALUES (1, 'user202103032042012', 1000);
-- ----------------------------
-- Table structure for order_tbl
-- ----------------------------
DROP TABLE IF EXISTS `order_tbl`;
CREATE TABLE `order_tbl` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`user_id` varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL,
`commodity_code` varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL,
`count` int(11) NULL DEFAULT 0,
`money` int(11) NULL DEFAULT 0,
PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 1 CHARACTER SET = utf8 COLLATE = utf8_general_ci ROW_FORMAT = COMPACT;
-- ----------------------------
-- Records of order_tbl
-- ----------------------------
-- ----------------------------
-- Table structure for storage_tbl
-- ----------------------------
DROP TABLE IF EXISTS `storage_tbl`;
CREATE TABLE `storage_tbl` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`commodity_code` varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL,
`count` int(11) UNSIGNED NULL DEFAULT 0,
PRIMARY KEY (`id`) USING BTREE,
UNIQUE INDEX `commodity_code`(`commodity_code`) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 2 CHARACTER SET = utf8 COLLATE = utf8_general_ci ROW_FORMAT = COMPACT;
-- ----------------------------
-- Records of storage_tbl
-- ----------------------------
INSERT INTO `storage_tbl` VALUES (1, '100202003032041', 10);
SET FOREIGN_KEY_CHECKS = 1;
账户表:
订单表:
库存表:
1.2 拉取项目
git clone https://gitee.com/z3inc/seata-demo.git
业务流程:模拟下单场景,订单微服务创建订单后,通过feign远程调用账户、库存微服务,分别扣减余额、库存。
1.3 测试
1、启动Nacos
# bin目录下执行
startup.cmd -m standalone
2、debug方式启动这三个微服务
访问nacos管理界面,查看服务列表,访问地址:http://localhost:8848/nacos ,账号密码naocs
3、测试订单微服务的下单接口,模拟异常情况
因为库存表的count字段是无符号的,不能为负数,所以数据库会报错,下单一定失败。
最终:订单没有创建成功、库存没有扣减成功,但是扣用户钱了 >_<
二、CAP定理
原文地址:https://www.julianbrowne.com/article/brewers-cap-theorem/
CAP定理又被称作布鲁尔定理,它是衡量一个分布式系统是否优秀的三个指标:
- C:一致性,访问分布式系统的任意节点,它们的数据或状态必须是一致的,需要花时间进行数据同步;
- A:可用性,访问分布式系统的任意节点,都必须立即响应;
- P:分区容错性,当分布式系统出现分区(某些节点出现故障),整个系统必须仍然持续正常提供服务。
CAP的核心是:一个分布式系统最多只能同时满足两个指标,必须满足的是P分区容错性,然后A和C是矛盾的
- 如果追求A可用性:即访问节点必须立即响应,那么就不能保证数据是已经同步完成的,即不能保证一致性。
- AP :如Eureka
- 如果追求C一致性:即访问节点时必须等待数据同步完成,那么等待的时间不确定,不能保证可用性。
- CP:如zookeeper
三、Base理论
BASE理论,是对CAP定理的一种权衡后的处理方案。
- BA(Basically Available): 基本可用
- 不要求绝对可用,只要做到基本可用即可;
- 追求一致性的时候,允许响应时间略有延迟、允许非核心功能暂不可用;
- SE:S(Soft State)软状态 + E(Eventually Consistent),最终一致
- 不要求绝对的一致,只要达到最终一致即可,允许存在临时数据不一致状态。
四、Seata概述
Seata 是蚂蚁金服和阿里共同开源的分布式事务解决方案,致力于提供高性能和简单易用的分布式事务服务。Seata 提供了 AT、TCC、SAGA 和 XA 事务模式,为用户打造一站式的分布式解决方案。
官网地址:https://seata.apache.org/
在官方文档、博客中提供了大量的使用说明、源码分析。
Seata的术语:
- RM (Resource Manager) 资源管理器:在代码里指@Transactional,分支事务;
- TM (Transaction Manager) 事务管理器:在代码里指@GlobalTransactional,全局事务,决定了事务的边界与开启、结束;
- TC (Transaction Coordinator) 事务协调者:指Seata软件,用于协调各分支事务的状态,决定提交还是回滚。
Seata的事务模式:
- XA模式:强一致、弱可用,不需要编写代码;
- AT模式:弱一致,可用性较强,不需要编写代码,也是Seata的默认模式;
- TCC模式:可用性强,基于资源预留实现事务并发的隔离,需要编写代码来实现数据恢复;
- SAGA模式:长事务模式,可以让第三方系统和旧系统加入全局事务,性能强,完全不做隔离,需要编写代码来实现数据恢复。
1. Seata安装部署
软件架构:
安装步骤如下:
1、执行sql脚本,创建数据库,给seata使用。
/*
Navicat Premium Data Transfer
Source Server : local
Source Server Type : MySQL
Source Server Version : 50622
Source Host : localhost:3306
Source Schema : seata
Target Server Type : MySQL
Target Server Version : 50622
File Encoding : 65001
Date: 20/06/2021 12:38:37
*/
CREATE DATABASE IF NOT EXISTS seata CHARACTER SET utf8mb4;
USE seata;
SET NAMES utf8mb4;
SET FOREIGN_KEY_CHECKS = 0;
-- ----------------------------
-- Table structure for branch_table
-- ----------------------------
DROP TABLE IF EXISTS `branch_table`;
CREATE TABLE `branch_table` (
`branch_id` bigint(20) NOT NULL,
`xid` varchar(128) CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL,
`transaction_id` bigint(20) NULL DEFAULT NULL,
`resource_group_id` varchar(32) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL,
`resource_id` varchar(256) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL,
`branch_type` varchar(8) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL,
`status` tinyint(4) NULL DEFAULT NULL,
`client_id` varchar(64) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL,
`application_data` varchar(2000) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL,
`gmt_create` datetime(6) NULL DEFAULT NULL,
`gmt_modified` datetime(6) NULL DEFAULT NULL,
PRIMARY KEY (`branch_id`) USING BTREE,
INDEX `idx_xid`(`xid`) USING BTREE
) ENGINE = InnoDB CHARACTER SET = utf8 COLLATE = utf8_general_ci ROW_FORMAT = Compact;
-- ----------------------------
-- Records of branch_table
-- ----------------------------
-- ----------------------------
-- Table structure for global_table
-- ----------------------------
DROP TABLE IF EXISTS `global_table`;
CREATE TABLE `global_table` (
`xid` varchar(128) CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL,
`transaction_id` bigint(20) NULL DEFAULT NULL,
`status` tinyint(4) NOT NULL,
`application_id` varchar(32) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL,
`transaction_service_group` varchar(32) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL,
`transaction_name` varchar(128) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL,
`timeout` int(11) NULL DEFAULT NULL,
`begin_time` bigint(20) NULL DEFAULT NULL,
`application_data` varchar(2000) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL,
`gmt_create` datetime NULL DEFAULT NULL,
`gmt_modified` datetime NULL DEFAULT NULL,
PRIMARY KEY (`xid`) USING BTREE,
INDEX `idx_gmt_modified_status`(`gmt_modified`, `status`) USING BTREE,
INDEX `idx_transaction_id`(`transaction_id`) USING BTREE
) ENGINE = InnoDB CHARACTER SET = utf8 COLLATE = utf8_general_ci ROW_FORMAT = Compact;
-- ----------------------------
-- Records of global_table
-- ----------------------------
-- ----------------------------
-- Table structure for lock_table
-- ----------------------------
DROP TABLE IF EXISTS `lock_table`;
CREATE TABLE `lock_table` (
`row_key` varchar(128) CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL,
`xid` varchar(96) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL,
`transaction_id` bigint(20) NULL DEFAULT NULL,
`branch_id` bigint(20) NOT NULL,
`resource_id` varchar(256) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL,
`table_name` varchar(32) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL,
`pk` varchar(36) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL,
`gmt_create` datetime NULL DEFAULT NULL,
`gmt_modified` datetime NULL DEFAULT NULL,
PRIMARY KEY (`row_key`) USING BTREE,
INDEX `idx_branch_id`(`branch_id`) USING BTREE
) ENGINE = InnoDB CHARACTER SET = utf8 COLLATE = utf8_general_ci ROW_FORMAT = Compact;
-- ----------------------------
-- Records of lock_table
-- ----------------------------
SET FOREIGN_KEY_CHECKS = 1
2、下载seata安装包
#seata1.4.2版下载地址
https://github.com/apache/incubator-seata/releases/download/v1.4.2/seata-server-1.4.2.zip
3、将seata-server压缩包,解压到一个不含中文、空格、特殊字符的路径下。
4、进入conf文件夹,修改registry.conf配置:
# ==================================注册中心=====================================
registry {
# file 、nacos 、eureka、redis、zk、consul、etcd3、sofa
type = "nacos" #表示使用Nacos作为注册中心
nacos {
application = "seata-server" #服务名称
serverAddr = "127.0.0.1:8848" #nacos地址
group = "DEFAULT_GROUP" #分组
namespace = "" #名称空间
cluster = "BJ" #集群
username = "nacos" #账号
password = "nacos" #密码
}
}
# ==============================配置中心===============================
config {
# file、nacos 、apollo、zk、consul、etcd3
type = "nacos" #表示使用nacos作为配置中心
nacos {
serverAddr = "127.0.0.1:8848"
namespace = ""
group = "SEATA_GROUP"
username = "nacos"
password = "nacos"
dataId = "seataServer.properties" #要拉取配置中心的文件
}
}
5、启动Naocs,在Naocs管理界面中,创建配置文件
-
Data ID:seataServer.properties
-
Group:SEATA_GROUP
-
配置描述:随便写
-
配置内容:
# 数据存储方式,db代表数据库 store.mode=db store.db.datasource=druid store.db.dbType=mysql store.db.driverClassName=com.mysql.cj.jdbc.Driver store.db.url=jdbc:mysql://127.0.0.1:3306/seata?useUnicode=true&rewriteBatchedStatements=true&serverTimezone=UTC store.db.user=root store.db.password=123321 store.db.minConn=5 store.db.maxConn=30 store.db.globalTable=global_table store.db.branchTable=branch_table store.db.queryLimit=100 store.db.lockTable=lock_table store.db.maxWait=5000 # 事务、日志等配置 server.recovery.committingRetryPeriod=1000 server.recovery.asynCommittingRetryPeriod=1000 server.recovery.rollbackingRetryPeriod=1000 server.recovery.timeoutRetryPeriod=1000 server.maxCommitRetryTimeout=-1 server.maxRollbackRetryTimeout=-1 server.rollbackRetryTimeoutUnlockEnable=false server.undo.logSaveDays=7 server.undo.logDeletePeriod=86400000 # 客户端与服务端传输方式 transport.serialization=seata transport.compressor=none # 关闭metrics功能,提高性能 metrics.enabled=false metrics.registryType=compact metrics.exporterList=prometheus metrics.exporterPrometheusPort=9898
6、进入bin文件夹,双击执行seata-server.bat脚本
7、查看Nacos的服务列表,看看Seata服务是否注册成功
后续Seata使用:先启动Nacos、再启动Seata。
8、微服务整合Seata:(三个微服务都需要加依赖改配置)
(1)添加依赖:
<!--seata-->
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-seata</artifactId>
<exclusions>
<!-- springcloudalibaba中的seata版本较低,1.3.0,因此排除-->
<exclusion>
<artifactId>seata-spring-boot-starter</artifactId>
<groupId>io.seata</groupId>
</exclusion>
<exclusion>
<groupId>com.alibaba</groupId>
<artifactId>druid</artifactId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>io.seata</groupId>
<artifactId>seata-spring-boot-starter</artifactId>
<!-- 使用与软件对应的版本 seata starter 1.4.2-->
<version>1.4.2</version>
</dependency>
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>druid</artifactId>
<version>1.2.9</version>
</dependency>
(2)修改seata配置:
seata:
registry:
type: nacos
nacos:
server-addr: localhost:8848
namespace: "" #名称空间,如果没设置,默认用""
group: DEFAULT_GROUP #分组,没有设置,默认用DEFAULT_GROUP
application: seata-server #seata服务名称
username: nacos
password: nacos
tx-service-group: seata-demo #微服务所属的组名,自定义一个组名
service:
vgroup-mapping: #设置微服务组关联的seata集群
seata-demo: BJ #这里seata-demo服务组,要使用BJ集群的seata服务
(3)重启订单、账户、库存微服务。
五、Seata的四种事务模式
1. XA模式
1.1 XA模式的使用
1、修改参与事务操作的微服务配置文件,开启XA事务模式:
seata:
data-source-proxy-mode: XA #开启XA模式
2、全局事务的入口方法上加 @GlobalTransactional (如 下单方法),所有分支事务方法上加@Transactional
@Override
@GlobalTransactional
public Long create(Order order) {
// 创建订单
orderMapper.insert(order);
try {
// 扣用户余额
accountClient.deduct(order.getUserId(), order.getMoney());
// 扣库存
storageClient.deduct(order.getCommodityCode(), order.getCount());
} catch (FeignException e) {
log.error("下单失败,原因:{}", e.contentUTF8(), e);
throw new RuntimeException(e.contentUTF8(), e);
}
return order.getId();
}
3、重启所有微服务,再次测试
1.2 XA模式的原理
-
XA 规范 是 X/Open 组织定义的分布式事务处理(DTP,Distributed Transaction Processing)标准;
-
XA规范描述了全局的TM(事务管理器)与局部的RM(资源管理器)之间的接口;
-
目前主流数据库都对 XA 规范 提供了支持。
Seata-XA模式两阶段提交:(Seata对原始XA的模式做了调整,但大体相似)
一阶段:执行、汇报但不提交
- 开启全局事务,各分支事务注册到TC
- 各分支事务执行SQL,但不提交
- 各分支事务向TC汇报事务状态
二阶段:最终的提交或回滚
-
全局事务要结束,通知TC做最终决策
- 如果所有分支事务都成功,就通知所有分支事务一起提交
- 如果任意分支事务失败了,就通知所有分支事务一起回滚
-
各分支事务:执行TC的最终决策,提交或回滚
1.3 XA模式的优缺点
- 优点:
- 强一致性,各个分支事务之间是完全一致的,一起提交,一起回滚
- 隔离性好:借助于单机事务实现多全局事务的隔离。多全局事务并发不会受影响
- 实现简单:没有代码入侵,只要添加注解就能实现XA模式的事务
- 缺点:
- 可用性弱:因为一阶段开启事务执行SQL但不提交,数据库底层会对数据长时间加锁
- NoSQL参与不进来:依赖于关系型数据库的事务机制实现
2. AT模式
AT模式:是对XA的一种增强,增强的是性能(可用性)
- 是最终一致:存在数据的临时不一致状态
- 多事务并发的隔离性:通过全局锁实现的隔离
- 但是:AT仍然依赖于关系数据库本身的机制,所以NoSQL参与不进来
2.1 AT模式的使用
1、在微服务的数据库里准备一张undo_log表。给seata用的,我们的代码用不上
-- ----------------------------
-- Table structure for undo_log
-- ----------------------------
DROP TABLE IF EXISTS `undo_log`;
CREATE TABLE `undo_log` (
`branch_id` bigint(20) NOT NULL COMMENT 'branch transaction id',
`xid` varchar(100) CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL COMMENT 'global transaction id',
`context` varchar(128) CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL COMMENT 'undo_log context,such as serialization',
`rollback_info` longblob NOT NULL COMMENT 'rollback info',
`log_status` int(11) NOT NULL COMMENT '0:normal status,1:defense status',
`log_created` datetime(6) NOT NULL COMMENT 'create datetime',
`log_modified` datetime(6) NOT NULL COMMENT 'modify datetime',
UNIQUE INDEX `ux_undo_log`(`xid`, `branch_id`) USING BTREE
) ENGINE = InnoDB CHARACTER SET = utf8 COLLATE = utf8_general_ci COMMENT = 'AT transaction mode undo table' ROW_FORMAT = Compact;
2、修改所有微服务的配置文件,设置事务模式为AT。
seata:
data-source-proxy-mode: AT #seata默认使用AT模式,可以不加这行配置参数
3、全局事务入口方法上加 @GlobalTransactional;所有分支事务方法上加@Transactional
2.2 AT模式的原理
AT模式同样是分阶段提交的事务模型,不过缺弥补了XA模型中资源锁定周期过长的缺陷。
Seata的AT模式两阶段提交过程:
-
一阶段:
-
开启全局事务,各分支事务注册到TC
-
各分支事务执行SQL提交,并备份数据(备份变更前后的数据)
-
各分支事务向TC汇报状态
-
-
二阶段:
- TC根据各分支事务状态做最终的决策,通知所有分支事务
- 各分支事根据TC的通知:
- 如果要提交:直接清除undo_log备份
- 如果要回滚:就拿undo_log备份的数据进行恢复,然后再删除undo_log备份。
2.3 AT模式的优缺点
-
优点:
-
一阶段提交事务,性能比较好
-
事务并发时的隔离利用了全局锁
-
实现简单,没有代码侵入
-
-
缺点:
- 存在数据的临时不一致状态
- 使用了数据库的快照机制进行备份,会影响性能,但比XA模式要好很多
2.4 AT模式的脏写问题
脏写:一个事务修改了另一个事务未提交的数据,就发生了脏写现象。
解决方案:使用了全局锁。
在释放DB锁之前,先拿到全局锁,避免同一时刻有另外一个事务来操作当前数据。
具体的过程是:
一阶段:全程持有数据的DB锁
开启事务:抢DB锁,锁定要修改的数据
执行SQL并备份:备份的是变更前后的数据。比如 余额之前100,之后90
加全局锁:对数据加全局锁===> 事务xx对xx数据持有全局锁
提交事务:释放DB锁
一和二阶段之间:持有数据的全局锁
在这个阶段,其它全局事务不可能抢到数据的全局锁,不可能对数据进行修改
但是其它 非Seata事务仍然可以修改数据
二阶段:如果要回滚,全程持有数据的DB锁
开启事务:抢DB锁,锁定要修改的数据
释放全局锁
恢复数据:拿备份的数据进行恢复
先判断:数据库里当前数据,和自己之前修改后的数据是否相同
如果不同:说明在我一阶段到二阶段之间,数据被其他人修改了,Seata会报错,等待人工干预
如果相同:说明在我一阶段一二阶段之间,数据没有被别人修改,直接恢复数据即可
提交事务:释放DB锁
2.5 AT与XA模式的区别
- XA模式一阶段不提交事务,锁定资源;AT模式一阶段直接提交,不锁定资源。
- XA模式依赖数据库机制实现回滚;AT模式利用数据快照实现数据回滚。
- XA模式强一致;AT模式最终一致
3. TCC模式
TCC:
对比AT模式,TCC不需要全局锁也能实现事务隔离,NoSQL也能参与进来,数据备份是基于资源的预留预扣实现的,而不是数据的备份快照方式。
不同的是TCC需要编写代码来实现数据恢复。需要实现三个方法:
- Try:尝试执行业务,并进行资源的预留预扣(冻结); 比如 余额原始100,要扣掉10元==>余额90,冻结金额10。
- Confirm:确认事务执行成功,相当于提交;比如 把冻结的金额直接清除即可。
- Cancel:取消事务的执行,相当于回滚;比如 把冻结的金额重新加回到余额里:余额+10。
TCC适合的场景:资源的加减操作,比如 账户余额的操作。
实际开发中:通常是AT+TCC混用
越傻瓜式底层就没有过多处理使用上就简单(如AT、XA),越灵活越麻烦(如TCC、SAGA)。
3.1 TCC模式的使用
1、执行sql脚本,创建一张冻结表,用于存储冻结的数据
use seata-demo;
CREATE TABLE `account_freeze_tbl` (
`xid` VARCHAR(128) CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL,
`user_id` VARCHAR(255) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL,
`freeze_money` INT(11) UNSIGNED NULL DEFAULT 0,
`state` INT(1) NULL DEFAULT NULL COMMENT '事务状态,0:try,1:confirm,2:cancel',
PRIMARY KEY (`xid`) USING BTREE
) ENGINE = INNODB CHARACTER SET = utf8 COLLATE = utf8_general_ci ROW_FORMAT = COMPACT;
- xid:全局事务的id
- user_id:用户id,即 哪个用户的数据
- freeze_money:冻结金额
- state:事务状态
2、在账户微服务,创建这张表对应的实体类、Mapper等等
//实体类
@Data
@TableName("account_freeze_tbl")
public class AccountFreeze {
@TableId(type = IdType.INPUT)
private String xid;
private String userId;
private Integer freezeMoney;
private Integer state;
public static abstract class State {
public final static int TRY = 0;
public final static int CONFIRM = 1;
public final static int CANCEL = 2;
}
}
//mapper接口
public interface AccountFreezeMapper extends BaseMapper<AccountFreeze> {
}
3、 编写TCC接口:Service层的接口,后续会让调用者不走之前的分支事务代码让他走tcc的业务逻辑
package cn.aopmin.account.service;
import io.seata.rm.tcc.api.BusinessActionContext;
import io.seata.rm.tcc.api.LocalTCC;
import io.seata.rm.tcc.api.TwoPhaseBusinessAction;
/**
* 一个接口要实现TCC模式的分支事务,需要:
* 1.接口上加@LocalTCC注解,告诉seata这个接口是TCC配置的接口
* 2.在接口里面添加三个方法
* (1)执行业务预留资源的try方法,方法名随意
* (2)相当于提交事务的Confirm方法,方法名随意
* (3)相当于回滚事务的Cancel方法,方法名随意
*
* @author 白豆五
* @since 2024/5/12
*/
@LocalTCC
public interface AccountTCCService {
/**
* Try方法: 在方法的实现里,需要扣除userId的money,冻结起来
* 当全局事务调用到此分支事务时,调用的就是这个try方法:seata知道那个方法是try,但不知道哪个是confirm和cance
* 所以需要加@TwoPhaseBusinessAction注解,指定二阶段要调用的方法,属性有:
* name唯一标识
* commitMethod指定confirm方法
* rollbackMethod指定cancel方
*
* @param userId
* @param money
*/
@TwoPhaseBusinessAction(name = "deduct", commitMethod = "confirm", rollbackMethod = "cancel")
void deduct(String userId, int money);
/**
* 相当于提交事务的Confirm方法
*/
void confirm(BusinessActionContext actionContext);
/**
* 相当于回滚事务的Cancel方法
*/
void cancel(BusinessActionContext actionContext);
}
package cn.aopmin.account.service.impl;
import cn.aopmin.account.mapper.AccountFreezeMapper;
import cn.aopmin.account.mapper.AccountMapper;
import cn.aopmin.account.pojo.AccountFreeze;
import cn.aopmin.account.service.AccountTCCService;
import io.seata.core.context.RootContext;
import io.seata.rm.tcc.api.BusinessActionContext;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
/**
* TCC接口实现类
*
* @author 白豆五
* @since 2024/5/12
*/
@Service
@RequiredArgsConstructor
public class AccountTCCServiceImpl implements AccountTCCService {
private final AccountMapper accountMapper;
private final AccountFreezeMapper freezeMapper;
/**
* 扣除余额,添加到冻结金额里
* 业务中要防止业务悬挂
*
* @param userId
* @param money
*/
@Override
@Transactional
public void deduct(String userId, int money) {
// 获取当前全局事务的id,在try方法里可以使用RootContext.getXID()
String xid = RootContext.getXID();
/**
* 要防止业务悬挂:可能是Cancel方法已执行,Try方法才开始执行。不允许执行Try了,因为会造成业务悬挂
*/
AccountFreeze freeze = freezeMapper.selectById(xid);
if (freeze != null && freeze.getState() == AccountFreeze.State.CANCEL) {
// 已经执行过Cancel方法,不允许再执行Try
return;
}
// 扣除用户的余额
accountMapper.deduct(userId, money);
// 添加到冻结金额里
freeze = new AccountFreeze();
freeze.setXid(xid); // 全局事务ID
freeze.setFreezeMoney(money); // 冻结金额
freeze.setState(AccountFreeze.State.TRY);// 状态
freeze.setUserId(userId); // 用户ID
freezeMapper.insert(freeze);
}
/**
* 相当于提交事务,直接清除冻结的数据
*/
@Override
@Transactional // 多次数据库操作加事务注解
public void confirm(BusinessActionContext actionContext) {
// 在confirm和cancel方法里,如何获取当前全局事务id?
// 当seata调用confirm和cancel方法时,会把xid投递过来,封装成BusinessActionContext对象
freezeMapper.deleteById(actionContext.getXid());
}
/**
* 相当于回滚事务,把冻结的数据加回到余额里
* 业务中要允许空回滚
*/
@Override
@Transactional
public void cancel(BusinessActionContext actionContext) {
// 1.查询当前事务冻结的数据
String xid = actionContext.getXid();
AccountFreeze freeze = freezeMapper.selectById(xid);
/**
* 允许空回滚,记录一条冻结数据,金额为0,状态为cancal已回滚
* 如果当前事务因为某些原因一直没有被调用到try方法,直到事务超时进行全局回滚:seata会调用这个cancel方法
*/
if (freeze == null) {
freeze = new AccountFreeze();
freeze.setXid(xid); // 全局事务ID
freeze.setUserId(actionContext.getActionContext("userId").toString());// 从上下文中取用户ID
freeze.setFreezeMoney(0);// 冻结金额
freeze.setState(AccountFreeze.State.CANCEL);// 状态
freezeMapper.insert(freeze);
return;
}
/**
* 幂等性:一个方法被重复调用,不应该产生不良结果
*/
// 如果已经有冻结记录,状态是已回滚:直接结束
if (freeze.getState() == AccountFreeze.State.CANCEL) {
return;
}
// 2.把冻结的数据加回给余额
accountMapper.refund(freeze.getUserId(), freeze.getFreezeMoney());
// 3.把冻结的数据状态设置为cancel已回滚
freeze.setState(AccountFreeze.State.CANCEL);
freeze.setFreezeMoney(0);
freezeMapper.updateById(freeze);
}
}
- 解决了:TCC的空回滚、业务悬挂和幂等性问题
4、修改账户微服务的controller代码,让Controller调用AccountTCCService的deduct方法
package cn.aopmin.account.controller;
import cn.aopmin.account.service.AccountService;
import cn.aopmin.account.service.AccountTCCService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("account")
public class AccountController {
@Autowired
// private AccountService accountService;
private AccountTCCService accountTCCService;
@PutMapping("/{userId}/{money}")
public ResponseEntity<Void> deduct(@PathVariable("userId") String userId, @PathVariable("money") Integer money) {
// accountService.deduct(userId, money);
accountTCCService.deduct(userId, money);
return ResponseEntity.noContent().build();
}
}
5、重启所有微服务,用postman测试
数据库表数据:
3.2 TCC模式的几个问题
空回滚:
当某分支事务的try阶段阻塞时,可能导致全局事务超时而触发二阶段的cancel操作。在未执行try操作时先执行了cancel操作,这时就要允许空回滚。
业务悬挂:
对于已经空回滚的业务,之前被阻塞的try操作恢复,继续执行try,就永远不可能confirm或cancel ,事务一直处于中间状态,这就是业务悬挂。执行try操作时,应当判断cancel是否已经执行过了,如果已经执行,应当阻止空回滚后的try操作,避免悬挂。
幂等性:
当TC通知RM提交或回滚时,如果RM明明已经提交或回滚,但是因为某些原因(例如网络拥堵)导致没有给TC返回结果,TC会重复通知RM提交或回滚,直到收到结果为止。
为了避免Try或Confirm业务的重复执行,Try和Confirm需要实现幂等:判断一下事务的状态,如果已经处理过,就直接返回成功,结束即可。
3.3 TCC模式的原理
Seata的TCC模式两阶段提交过程:
- 一阶段:
- 开启全局事务,各分支事务注册到TC
- 各分支事务执行Try方法,直接提交
- 向TC上报自己的状态
- 二阶段:
- TC根据各分支事务的状态做最终的决策,然后通知给所有的分支事务
- 各分支事务根据TC决策:
- 如果要提交:就执行Confirm方法
- 如果要回滚:就执行Cancel方法
3.4 TCC模式的优缺点
优点:
- 一阶段完成直接提交事务,释放数据库资源,性能好
- 基于资源预留实现数据隔离;相比AT模型,无需生成快照,无需使用全局锁,性能更强
- 不依赖数据库事务,而是依赖补偿操作,可以用于非关系型数据库
缺点:
- 有代码侵入,需要人工编写try、Confirm和Cancel接口,太麻烦
- 软状态,事务是最终一致
- 需要考虑Confirm和Cancel的失败情况,做好幂等处理
4. SAGA模式
Saga 模式是 Seata 开源的长事务解决方案,将由蚂蚁金服主要贡献。
其理论基础是Hector & Kenneth 在1987年发表的论文Sagas。
Seata官网对于Saga的指南:https://seata.io/zh-cn/docs/user/saga.html
适用场景:
- 业务流程长、业务流程多
- 参与者包含其它公司或遗留系统服务(如 屎山代码),无法提供 TCC 模式要求的三个接口
4.1 原理说明
在 Saga 模式下,分布式事务内有多个参与者,每一个参与者都是一个冲正补偿服务,需要用户根据业务场景实现其正向操作和逆向回滚操作。
分布式事务执行过程中,依次执行各参与者的正向操作,如果所有正向操作均执行成功,那么分布式事务提交。如果任何一个正向操作执行失败,那么分布式事务会去退回去执行前面各参与者的逆向回滚操作,回滚已提交的参与者,使分布式事务回到初始状态。
·
Saga也分为两个阶段:
- 一阶段:直接提交本地事务
- 二阶段:成功则什么都不做;失败则通过编写补偿业务来回滚
4.2 SAGA模式的优缺点
优点:
- 事务参与者可以基于事件驱动实现异步调用,吞吐高
- 一阶段直接提交事务,无锁,性能好
- 不用编写TCC中的三个阶段,实现简单
缺点:
-
软状态持续时间不确定,时效性差
-
没有锁,没有事务隔离,会有脏写。
某一个环节给你转账过去了,后边的环节出错要补偿撤消。但是转给你的钱,已经被你花掉了
做法:
- 宁可长款,不可短款:商家宁可多收你钱,如果出错最后退给你;也不能先给你钱,出错后找你要
- 具体实现:扣你钱的操作放到前边,给你加钱的操作放到最后的环节