文章目录
1、分布式事务
1.1 事务的ACID原则
原子性
事务中的所有操作,要么全部成功,要么全部失败
一致性
要保证数据库内部完整性约束、声明性约束
隔离性
对同一资源操作的事务不能同时发生
持久性
对数据库做的一切修改将永久保存,不管是否出现故障
1.2 分布式事务的问题
在单体架构中,往往只有1个服务,1个数据库。在这种情况下,基于数据库本身提供的特性,已经可以实现ACID了。但是,现在需要面对微服务架构,在微服务架构中,每个微服务都有可能有自己的数据库,这个时候就不能只依靠数据库本身提供的特性了来保证ACID了。
示例
我们来看下面这个示例
微服务下单业务,在下单时会调用订单服务,创建订单并写入数据库。然后订单服务调用账户服务和库存服务:
- 账户服务负责扣减用户余额
- 库存服务负责扣减商品库存
首先,我们请求:http://localhost:8082/order?userId=user202103032042012&commodityCode=100202003032041&count=2&money=200,因为目前库存是够的,所以正常创建订单、扣减余额,扣减商品库存。
然后我们请求:http://localhost:8082/order?userId=user202103032042012&commodityCode=100202003032041&count=10&money=200,显然,此时库存是不够的,库存服务的调用抛出了异常,但是我们发现余额仍然被扣除了,订单没有生成。这样就出现了数据库不一致的情况。
在以上的过程中,我们发现如下的问题:
-
每1个服务都是独立的,当某个服务抛出了异常,其它服务并不能感知到;
-
每1个服务都是独立的,所以它们的事务也都是独立的,其中业务处理成功的服务都各自把自己的事务提交了,因此撤销不了;
因此,没有达成事务状态的一致。
代码
准备环境
1. seata_demo数据库
seata_demo的数据库脚本,用于创建account_tbl(账户表)、order_tbl(订单表)、storage_tbl(库存表),并初始化数据。
/*
Navicat Premium Data Transfer
Source Server : local
Source Server Type : MySQL
Source Server Version : 50622
Source Host : localhost:3306
Source Schema : seata_demo
Target Server Type : MySQL
Target Server Version : 50622
File Encoding : 65001
Date: 24/06/2021 19:55:35
*/
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;
账户表
订单表
库存表
2. 启动nacos
步骤简述:下载nacos-server-1.4.1.zip包,并创建nacos需要的表,然后修改配置,单机启动即可
1.解压nacos-server-1.4.1.zip包如下
2.找到conf下的nacos建表语句
执行语句后,建表如下
3. 修改配置文件application.properties
4. 单机启动nacos
seata-demo父工程
pom.xml
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>cn.itcast.demo</groupId>
<artifactId>seata-demo</artifactId>
<version>1.0-SNAPSHOT</version>
<modules>
<module>storage-service</module>
<module>account-service</module>
<module>order-service</module>
</modules>
<packaging>pom</packaging>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.3.9.RELEASE</version>
<relativePath/>
</parent>
<properties>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
<java.version>1.8</java.version>
<spring-cloud.version>Hoxton.SR8</spring-cloud.version>
<mybatis.plus.version>3.3.0</mybatis.plus.version>
<mysql.version>5.1.47</mysql.version>
<alibaba.version>2.2.5.RELEASE</alibaba.version>
<seata.version>1.4.2</seata.version>
</properties>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-alibaba-dependencies</artifactId>
<version>${alibaba.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
<!-- springCloud -->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-dependencies</artifactId>
<version>${spring-cloud.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
<!-- mysql驱动 -->
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<version>${mysql.version}</version>
</dependency>
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>${mybatis.plus.version}</version>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-lang3</artifactId>
<version>3.4</version>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</dependency>
<!--单元测试-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
</project>
order-service
pom.xml
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
http://maven.apache.org/xsd/maven-4.0.0.xsd">
<parent>
<artifactId>seata-demo</artifactId>
<groupId>cn.itcast.demo</groupId>
<version>1.0-SNAPSHOT</version>
</parent>
<modelVersion>4.0.0</modelVersion>
<artifactId>order-service</artifactId>
<dependencies>
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
</dependency>
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-openfeign</artifactId>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
application.yml
server:
port: 8082
spring:
application:
name: order-service
datasource:
driver-class-name: com.mysql.jdbc.Driver
url: jdbc:mysql:///seata_demo?useUnicode=true&characterEncoding=utf8&allowMultiQueries=true&useSSL=false
username: root
password: root
cloud:
nacos:
server-addr: localhost:8848
mybatis-plus:
global-config:
db-config:
insert-strategy: not_null
update-strategy: not_null
id-type: auto
logging:
level:
org.springframework.cloud.alibaba.seata.web: debug
cn.itcast: debug
pattern:
dateformat: MM-dd HH:mm:ss:SSS
OrderApplication
@MapperScan("cn.itcast.order.mapper")
@EnableFeignClients
@SpringBootApplication
public class OrderApplication {
public static void main(String[] args) {
SpringApplication.run(OrderApplication.class, args);
}
}
OrderServiceImpl
@Slf4j
@Service
public class OrderServiceImpl implements OrderService {
private final AccountClient accountClient;
private final StorageClient storageClient;
private final OrderMapper orderMapper;
public OrderServiceImpl(AccountClient accountClient,
StorageClient storageClient,
OrderMapper orderMapper) {
this.accountClient = accountClient;
this.storageClient = storageClient;
this.orderMapper = orderMapper;
}
@Override
@Transactional
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();
}
}
AccountClient
@FeignClient("account-service")
public interface AccountClient {
@PutMapping("/account/{userId}/{money}")
void deduct(@PathVariable("userId") String userId, @PathVariable("money") Integer money);
}
StorageClient
@FeignClient("storage-service")
public interface StorageClient {
@PutMapping("/storage/{code}/{count}")
void deduct(@PathVariable("code") String code, @PathVariable("count") Integer count);
}
OrderController
创建订单
@RestController
@RequestMapping("order")
public class OrderController {
private final OrderService orderService;
public OrderController(OrderService orderService) {
this.orderService = orderService;
}
@PostMapping
public ResponseEntity<Long> createOrder(Order order){
Long orderId = orderService.create(order);
return ResponseEntity.status(HttpStatus.CREATED).body(orderId);
}
}
account-service
pom.xml
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
http://maven.apache.org/xsd/maven-4.0.0.xsd">
<parent>
<artifactId>seata-demo</artifactId>
<groupId>cn.itcast.demo</groupId>
<version>1.0-SNAPSHOT</version>
</parent>
<modelVersion>4.0.0</modelVersion>
<artifactId>account-service</artifactId>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
</dependency>
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
application.yml
server:
port: 8083
spring:
application:
name: account-service
datasource:
driver-class-name: com.mysql.jdbc.Driver
url: jdbc:mysql:///seata_demo?useUnicode=true&characterEncoding=utf8&allowMultiQueries=true&useSSL=false
username: root
password: root
cloud:
nacos:
server-addr: localhost:8848
mybatis-plus:
global-config:
db-config:
insert-strategy: not_null
update-strategy: not_null
id-type: auto
logging:
level:
org.springframework.cloud.alibaba.seata.web: debug
cn.itcast: debug
pattern:
dateformat: MM-dd HH:mm:ss:SSS
AccountApplication
@MapperScan("cn.itcast.account.mapper")
@SpringBootApplication
public class AccountApplication {
public static void main(String[] args) {
SpringApplication.run(AccountApplication.class, args);
}
}
AccountServiceImpl
@Slf4j
@Service
public class AccountServiceImpl implements AccountService {
@Autowired
private AccountMapper accountMapper;
@Override
@Transactional
public void deduct(String userId, int money) {
log.info("开始扣款");
try {
accountMapper.deduct(userId, money);
} catch (Exception e) {
throw new RuntimeException("扣款失败,可能是余额不足!", e);
}
log.info("扣款成功");
}
}
AccountController
@RestController
@RequestMapping("account")
public class AccountController {
@Autowired
private AccountService accountService;
@PutMapping("/{userId}/{money}")
public ResponseEntity<Void> deduct(@PathVariable("userId") String userId,
@PathVariable("money") Integer money){
accountService.deduct(userId, money);
return ResponseEntity.noContent().build();
}
}
storage-service
pom.xml
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
http://maven.apache.org/xsd/maven-4.0.0.xsd">
<parent>
<artifactId>seata-demo</artifactId>
<groupId>cn.itcast.demo</groupId>
<version>1.0-SNAPSHOT</version>
</parent>
<modelVersion>4.0.0</modelVersion>
<artifactId>storage-service</artifactId>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
</dependency>
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
application.yml
server:
port: 8081
spring:
application:
name: storage-service
datasource:
driver-class-name: com.mysql.jdbc.Driver
url: jdbc:mysql:///seata_demo?useUnicode=true&characterEncoding=utf8&allowMultiQueries=true&useSSL=false
username: root
password: root
cloud:
nacos:
server-addr: localhost:8848
mybatis-plus:
global-config:
db-config:
insert-strategy: not_null
update-strategy: not_null
id-type: auto
logging:
level:
org.springframework.cloud.alibaba.seata.web: debug
cn.itcast: debug
pattern:
dateformat: MM-dd HH:mm:ss:SSS
StorageApplication
@MapperScan("cn.itcast.storage.mapper")
@SpringBootApplication
public class StorageApplication {
public static void main(String[] args) {
SpringApplication.run(StorageApplication.class, args);
}
}
StorageServiceImpl
@Slf4j
@Service
public class StorageServiceImpl implements StorageService {
@Autowired
private StorageMapper storageMapper;
@Transactional
@Override
public void deduct(String commodityCode, int count) {
log.info("开始扣减库存");
try {
storageMapper.deduct(commodityCode, count);
} catch (Exception e) {
throw new RuntimeException("扣减库存失败,可能是库存不足!", e);
}
log.info("扣减库存成功");
}
}
StorageController
@RestController
@RequestMapping("storage")
public class StorageController {
private final StorageService storageService;
public StorageController(StorageService storageService) {
this.storageService = storageService;
}
/**
* 扣减库存
* @param code 商品编号
* @param count 要扣减的数量
* @return 无
*/
@PutMapping("/{code}/{count}")
public ResponseEntity<Void> deduct(@PathVariable("code") String code,
@PathVariable("count") Integer count){
storageService.deduct(code, count);
return ResponseEntity.noContent().build();
}
}
2、理论基础
2.1 CAP定理
1998年,加州大学的计算机科学家 Eric Brewer 提出,分布式系统有三个指标:
-
Consistency(一致性)
-
用户访问分布式系统中的任意节点,得到的数据必须一致
-
-
Availability(可用性)
- 用户访问集群中的任意健康节点,必须能得到响应,而不是超时或拒绝
-
Partition tolerance (分区容错性)
-
Partition(分区):因为网络故障或其它原因导致分布式系统中的部分节点与其它节点失去连接,形成独立分区。
-
Tolerance(容错):在集群出现分区时,整个系统也要持续对外提供服务(而一旦对外提供服务的话,那么由于形成了独立分区,它并未与其它节点进行数据同步,那么如果此时它提供给外界服务,就会达不到一致性的要求,如果它阻塞外界的请求,等待网络恢复数据同步完成,又会达不到可用性的要求)
-
Eric Brewer 说,分布式系统无法同时满足这三个指标。这个结论就叫做 CAP 定理。
简述CAP定理内容?
- 分布式系统节点通过网络连接,一定会出现分区问题(P)
- 当分区出现时,系统的一致性(C)和可用性(A)就无法同时满足
思考:elasticsearch集群是CP还是AP?
- ES集群出现分区时,故障节点会被剔除集群,数据分片会重新分配到其它节点,保证数据一致。因此是低可用性,高一致性,属于CP
2.2 BASE理论
BASE理论是对CAP的一种解决思路,包含三个思想:
- Basically Available (基本可用):分布式系统在出现故障时,允许损失部分可用性,即保证核心可用。
- Soft State(软状态):在一定时间内,允许出现中间状态,比如临时的不一致状态。
- Eventually Consistent(最终一致性):虽然无法保证强一致性,但是在软状态结束后,最终达到数据一致。
而分布式事务最大的问题是各个子事务的一致性问题,因此可以借鉴CAP定理和BASE理论:
- AP模式:各子事务分别执行和提交,允许出现结果不一致,然后采用弥补措施恢复数据即可,实现最终一致。
- CP模式:各个子事务执行后互相等待,同时提交,同时回滚,达成强一致。但事务等待过程中,处于弱可用状态。
2.3 分布式事务模型
解决分布式事务,各个子系统之间必须能感知到彼此的事务状态,才能保证状态一致,因此需要一个事务协调者来协调每一个事务的参与者(子系统事务)。
这里的子系统事务,称为分支事务;有关联的各个分支事务在一起称为全局事务
简述BASE理论三个思想:
- 基本可用
- 软状态
- 最终一致
解决分布式事务的思想和模型:
- 全局事务:整个分布式事务
- 分支事务:分布式事务中包含的每个子系统的事务
- 最终一致思想:各分支事务分别执行并提交,如果有不一致的情况,再想办法恢复数据
- 强一致思想:各分支事务执行完业务不要提交,等待彼此结果。而后统一提交或回滚
3、seata
Seata的架构
初识Seata
Seata是 2019 年 1 月份蚂蚁金服和阿里巴巴共同开源的分布式事务解决方案。致力于提供高性能和简单易用的分布式事务服务,为用户打造一站式的分布式解决方案。
官网地址:http://seata.io/,其中的文档、播客中提供了大量的使用说明、源码分析。
Seata事务管理中有三个重要的角色
- TC (Transaction Coordinator) - 事务协调者:维护全局和分支事务的状态,协调全局事务提交或回滚。
- TM (Transaction Manager) - 事务管理器:定义全局事务的范围、开始全局事务、提交或回滚全局事务。
- RM (Resource Manager) - 资源管理器:管理分支事务处理的资源,与TC交谈以注册分支事务和报告分支事务的状态,并驱动分支事务提交或回滚。
Seata提供了四种不同的分布式事务解决方案
- XA模式:强一致性分阶段事务模式,牺牲了一定的可用性,无业务侵入
- AT模式:最终一致的分阶段事务模式,无业务侵入,也是Seata的默认模式
- TCC模式:最终一致的分阶段事务模式,有业务侵入
- SAGA模式:长事务模式,有业务侵入
部署TC服务
1.下载
首先我们要下载seata-server包,地址在http😕/seata.io/zh-cn/blog/download.html
当然,课前资料也准备好了:
2.解压
在非中文目录解压缩这个zip包,其目录结构如下:
3.修改配置
修改conf目录下的registry.conf文件:
修改后的内容如下(registry.conf中有默认的配置方式,我们将默认的registry.conf文件修改成如下的配置,其中将type由原来的file改为nacos):
# 注册中心配置
registry {
# tc服务的注册中心类,这里选择nacos,也可以是eureka、zookeeper等
type = "nacos"
nacos {
# seata tc 服务注册到 nacos的服务名称,可以自定义
application = "seata-tc-server"
serverAddr = "127.0.0.1:8848"
group = "DEFAULT_GROUP"
# 配置为空字符串就是public
namespace = ""
# 集群的名字, 此处意为上海的缩写
cluster = "SH"
username = "nacos"
password = "nacos"
}
}
# 配置中心配置
config {
# 读取tc服务端的配置文件的方式,这里是从nacos配置中心读取,这样如果tc是集群,可以共享配置
type = "nacos"
# 配置nacos地址等信息
nacos {
serverAddr = "127.0.0.1:8848"
# 配置为空字符串就是public
namespace = ""
group = "SEATA_GROUP"
username = "nacos"
password = "nacos"
dataId = "seataServer.properties"
}
}
4.在nacos添加配置
特别注意,为了让tc服务的集群可以共享配置,我们选择了nacos作为统一配置中心。因此,服务端配置文件seataServer.properties文件需要在nacos中配好。
格式如下(因为上面我们设置的是SEATA_GROUP,所以下面的Group:应该要配置为SEATA_GROUP):
配置内容如下:
# 数据存储方式,db代表数据库
store.mode=db
store.db.datasource=druid
store.db.dbType=mysql
store.db.driverClassName=com.mysql.jdbc.Driver
store.db.url=jdbc:mysql://127.0.0.1:3306/seata?useUnicode=true&rewriteBatchedStatements=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
其中的数据库地址、用户名、密码都需要修改成你自己的数据库信息。
5.创建数据库表
特别注意:tc服务在管理分布式事务时,需要记录事务相关数据到数据库中,你需要提前创建好这些表。
新建一个名为seata的数据库,运行课前资料提供的sql文件:
这些表主要记录全局事务、分支事务、全局锁信息(包括:branch_table、global_table):
SET NAMES utf8mb4;
SET FOREIGN_KEY_CHECKS = 0;
-- ----------------------------
-- 分支事务表
-- ----------------------------
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;
-- ----------------------------
-- 全局事务表
-- ----------------------------
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;
SET FOREIGN_KEY_CHECKS = 1;
6.启动TC服务
进入bin目录,运行其中的seata-server.bat即可(cmd窗口打开此目录,然后输入seata-server.bat,按enter,启动后的默认端口是8091):
启动的端口默认是8091
启动成功后,seata-server应该已经注册到nacos注册中心了。
打开浏览器,访问nacos地址:http://localhost:8848,然后进入服务列表页面,可以看到seata-tc-server的信息(这说明seata的tc服务启动成功,并成功注册到了nacos注册中心):
微服务集成seata
1.引入依赖
首先,我们需要在微服务中引入seata依赖:
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-seata</artifactId>
<exclusions>
<!--版本较低,1.3.0,因此排除-->
<exclusion>
<artifactId>seata-spring-boot-starter</artifactId>
<groupId>io.seata</groupId>
</exclusion>
</exclusions>
</dependency>
<!--seata starter 采用1.4.2版本-->
<dependency>
<groupId>io.seata</groupId>
<artifactId>seata-spring-boot-starter</artifactId>
<!-- 引用了父pom中的seata.version -->
<version>${seata.version}</version>
</dependency>
2.修改配置文件
需要修改application.yml文件,添加一些配置:
seata:
# 寻找seata的tc服务相关配置
registry: # TC服务注册中心的配置,微服务根据这些信息去注册中心获取tc服务地址
# 参考tc服务自己的registry.conf中的配置
type: nacos
nacos:
server-addr: 127.0.0.1:8848
namespace: "" # 配置为空字符串就是public
group: DEFAULT_GROUP # seata的registry.conf配置文件中regitry选项配置中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
nacos服务名称组成包括?
- namespace + group + serviceName + cluster
seata客户端获取tc的cluster名称方式?
- 以tx-group-service的值为key到vgroupMapping中查找
storage-service修改示例
以修改storage-service服务为例,为它配置seata的tc服务相关配置
修改pom.xml
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
http://maven.apache.org/xsd/maven-4.0.0.xsd">
<parent>
<artifactId>seata-demo</artifactId>
<groupId>cn.itcast.demo</groupId>
<version>1.0-SNAPSHOT</version>
</parent>
<modelVersion>4.0.0</modelVersion>
<artifactId>storage-service</artifactId>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
</dependency>
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
</dependency>
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-seata</artifactId>
<exclusions>
<!--版本较低,1.3.0,因此排除-->
<exclusion>
<artifactId>seata-spring-boot-starter</artifactId>
<groupId>io.seata</groupId>
</exclusion>
</exclusions>
</dependency>
<!--seata starter 采用1.4.2版本-->
<dependency>
<groupId>io.seata</groupId>
<artifactId>seata-spring-boot-starter</artifactId>
<!-- 引用了父pom中的seata.version -->
<version>${seata.version}</version>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
修改application.yml
server:
port: 8081
spring:
application:
name: storage-service
datasource:
driver-class-name: com.mysql.jdbc.Driver
url: jdbc:mysql:///seata_demo?useUnicode=true&characterEncoding=utf8&allowMultiQueries=true&useSSL=false
username: root
password: root
cloud:
nacos:
server-addr: localhost:8848
mybatis-plus:
global-config:
db-config:
insert-strategy: not_null
update-strategy: not_null
id-type: auto
logging:
level:
org.springframework.cloud.alibaba.seata.web: debug
cn.itcast: debug
pattern:
dateformat: MM-dd HH:mm:ss:SSS
seata:
# 寻找seata的tc服务相关配置
# (TC服务注册中心的配置,微服务根据这些信息去注册中心获取tc服务地址)
registry:
# 这里可参考tc服务自己的registry.conf中的配置
type: nacos
nacos:
# nacos注册中心的地址
server-addr: 127.0.0.1:8848
# 配置为空字符串就是public
namespace: ''
# seata的registry.conf配置文件中regitry选项配置中group为DEFAULT_GROUP,即所属组
group: DEFAULT_GROUP
# tc服务在nacos中的服务名称
application: seata-tc-server
username: nacos
password: nacos
# 事务组,根据这个获取tc服务的cluster名称
tx-service-group: seata-demo
service:
# 事务组与TC服务cluster的映射关系
vgroup-mapping:
seata-demo: SH
4、分布式事务模式
4.1 XA模式
XA模式原理
XA 规范 是 X/Open 组织定义的分布式事务处理(DTP,Distributed Transaction Processing)标准,XA 规范 描述了全局的TM与局部的RM之间的接口,几乎所有主流的数据库都对 XA 规范 提供了支持。
在XA标准中,RM通常是由数据库来实现的,在第一阶段中:事务协调者通知各个数据库执行自己的业务,但是各个数据库在执行完自己的业务后,不提交事务,而是把事务先放在那里等待,然后各自把执行的结果告知事务协调者。如果是就绪了,就代表成功了。如果失败了,就代表fail了。事务协调者根据各个RM的结果,来决定下一个阶段的动作。
如果在第一阶段中都成功了,那么通知各个RM提交事务,各个RM此时就可以提交事务了,至此,事务就可以结束了。
如果在第一阶段中,有任意一个服务失败了,那么事务协调者就会认为这次的事务是有问题的,那么所有RM的事务都要回滚。此时,TC就会发起事务回滚的请求到已经执行的服务(如果已经失败的,就不用发了),RM在收到这个请求后,就会回滚对应的事务,此时数据就会恢复之前的状态了。
所以XA模式就是基于数据库本身的特性来实现分布式事务,在第一阶段并不提交事务,而是在第二阶段由TC根据各个RM的执行结果,来提交或回滚事务。因此,能够满足ACID这些特性,属于强一致性的事务。
seata的XA模式简介
seata的XA模式做了一些调整,但大体相似(如下图,1.开头的是第一阶段;2.开头的是第二阶段),所以seata在实现XA时,是在数据库的XA模式的基础上,做了一层简单的封装。
seata的XA模式与XA模式原理大致上是差不多的,只不过多了TM这个角色来统一管理全局事务,如果去掉TM这个角色,其实与XA模式原理是一致的。它们都是如下的步骤:
-
RM一阶段的工作:
-
注册分支事务到TC
-
执行分支业务sql,但不提交
-
报告执行状态到TC
-
-
TC二阶段的工作:
- TC检测各分支事务执行状态
- 如果都成功,通知所有RM提交事务
- 如果有失败,通知所有RM回滚事务
- TC检测各分支事务执行状态
-
RM二阶段的工作:
- 接收TC指令,提交或回滚事务
XA模式的优缺点
XA模式的优点
- 事务的强一致性,满足ACID原则。
- 常用数据库都支持,实现简单,并且没有代码侵入
XA模式的缺点
- 因为一阶段需要锁定数据库资源,等待二阶段结束才释放(需要等待其它分支事务执行结果),性能较差
- 需要依赖关系型数据库实现事务
实现XA模式
步骤概要
Seata的starter已经完成了XA模式的自动装配,实现非常简单,步骤如下:
-
修改application.yml文件(每个参与事务的微服务),开启XA模式:
seata: # 开启数据源代理的XA模式 data-source-proxy-mode: XA
-
给发起全局事务的入口方法添加@GlobalTransactional注解,本例中是OrderServiceImpl中的create方法:
@Override @GlobalTransactional public Long create(Order order) { // 创建订单 orderMapper.insert(order); // 扣余额 ...略 // 扣减库存 ...略 return order.getId(); }
-
重启服务并测试
seata的XA模式示例
微服务都集成seata
第一步:改造所有的微服务,添加连接seata的TC服务配置。这里将account-service、order-service,参照storage-service修改示例中的步骤都要集成seata
修改数据源代理模式配置
第二步:对order-service、account-service、storage-service的application.yml配置文件(开启数据源代理的XA模式)作同样的修改。以storage-service的application.yml配置文件示例,修改如下
server:
port: 8081
spring:
application:
name: storage-service
datasource:
driver-class-name: com.mysql.jdbc.Driver
url: jdbc:mysql:///seata_demo?useUnicode=true&characterEncoding=utf8&allowMultiQueries=true&useSSL=false
username: root
password: root
cloud:
nacos:
server-addr: localhost:8848
mybatis-plus:
global-config:
db-config:
insert-strategy: not_null
update-strategy: not_null
id-type: auto
logging:
level:
org.springframework.cloud.alibaba.seata.web: debug
cn.itcast: debug
pattern:
dateformat: MM-dd HH:mm:ss:SSS
seata:
# 寻找seata的tc服务相关配置
# (TC服务注册中心的配置,微服务根据这些信息去注册中心获取tc服务地址)
registry:
# 这里可参考tc服务自己的registry.conf中的配置
type: nacos
nacos:
# nacos注册中心的地址
server-addr: 127.0.0.1:8848
# 配置为空字符串就是public
namespace: ''
# seata的registry.conf配置文件中regitry选项配置中group为DEFAULT_GROUP,即所属组
group: DEFAULT_GROUP
# tc服务在nacos中的服务名称
application: seata-tc-server
username: nacos
password: nacos
# 事务组,根据这个获取tc服务的cluster名称
tx-service-group: seata-demo
service:
# 事务组与TC服务cluster的映射关系
vgroup-mapping:
seata-demo: SH
# 开启数据源代理的XA模式(会由代理调用数据库提供的XA标准协议相关的接口功能)
data-source-proxy-mode: XA
使用@GlobalTransaction注解
在order-service服务的createOrder方法中添加@GlobalTransaction注解
@Slf4j
@Service
public class OrderServiceImpl implements OrderService {
private final AccountClient accountClient;
private final StorageClient storageClient;
private final OrderMapper orderMapper;
public OrderServiceImpl(AccountClient accountClient,
StorageClient storageClient,
OrderMapper orderMapper) {
this.accountClient = accountClient;
this.storageClient = storageClient;
this.orderMapper = orderMapper;
}
@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();
}
}
重启并测试
重启order-service、account-service、storage-service服务(注意:此时需要开启NACOS服务和seata的TC服务)
首先,我们请求:http://localhost:8082/order?userId=user202103032042012&commodityCode=100202003032041&count=2&money=200,因为目前库存是够的,所以正常创建订单、扣减余额,扣减商品库存。
然后我们请求:http://localhost:8082/order?userId=user202103032042012&commodityCode=100202003032041&count=10&money=200,显然,此时库存是不够的,库存服务的调用抛出了异常,此时,我们发现订单并没有创建成功、余额也没有扣除、库存也没有减少。因此,分布式事务的一致性得到了保证。
在上面的第2次请求中,首先订单服务自己在自己的数据源创建的订单,然后订单服务对账户服务发起扣款的远程调用,并且这次的扣款远程调用是成功的,然后订单服务对库存服务发起远程调用,由于库存不够,因此库存服务抛出异常,订单服务的这次远程调用失败,因此,对TC服务发起回滚各个分支事务的请求,因此,各个服务内部的RM就回滚相关的事务了。
4.2 AT模式
AT模式原理
AT模式同样是分阶段提交的事务模型,不过缺弥补了 XA模型中资源锁定周期过长 的缺陷。
原理图
- 阶段一RM的工作:
- 注册分支事务
- 记录undo-log(数据快照)
- 执行业务sql,并直接提交(为了解决资源锁定过长的问题,因此直接提交事务。因此,此时可能会出现数据的短暂不一致,即处于软状态。)
- 报告事务状态
- 阶段二提交时RM的工作:
- 删除undo-log即可
- 阶段二回滚时RM的工作:
- 根据undo-log恢复数据到更新前(如果业务执行失败,可以根据快照恢复到之前的数据。至于快照的生成、使用、删除由seata框架自动完成)
原理图示例
例如,一个分支业务的SQL是这样的:update tb_account set money = money - 10 where id = 1
(如下图:在执行业务sql之前,rm会拦截执行业务sql执行,并生成快照。在后面的阶段:如果需要回滚,则使用快照恢复数据,然后删除快照即可;如果需要提交,则直接删除快照即可)
AT模式与XA模式的区别
- XA模式一阶段不提交事务,锁定资源;AT模式一阶段直接提交,不锁定资源。
- XA模式依赖数据库机制实现回滚;AT模式利用数据快照实现数据回滚。
- XA模式强一致;AT模式最终一致
AT模式的脏写问题
事务1在第一阶段,将money由100改为了90。然后由于是直接提交事务,所以释放了DB锁,但是还没开始第二阶段。假设此时事务2开始执行,由于事务1在第1阶段完成后释放了DB锁,因此事务2就可以获取到DB锁,获取到DB锁之后,保存快照,由90改为了80,然后事务2提交事务。可是这个时候,事务1在执行第二阶段的时候,需要根据快照恢复数据,而快照中记录的数据为100,因此,就将80改为了100,这样的话,事务2的修改就失效了(这叫丢失更新,因为事务1在回滚之前,事务2更新了数据,因此一旦事务1回滚,其它事务对数据的修改都相当于失效了),因此,就出现了脏写的问题。
其中原因就是:各个事务没有做到隔离,事务1提交了事务,释放了DB锁,则此时事务2就能够插入进来获取DB锁,执行业务sql了。
全局锁
为了解决AT模式的脏写问题,seata引入了全局锁的方式解决这个问题。
全局锁:由TC服务记录当前正在操作某行数据的事务,该事务持有全局锁,具备执行权。(全局锁由TC服务管理,全局锁所在的表有xid->事务id,table->表名,pk->数据主键这些字段,它们记录了当前哪个事务对哪张表的哪行数据有操作权限)
全局锁生效过程:事务1在第一阶段中:获取DB锁,保存快照之后,然后执行业务sql将money由原来的100改为90,然后就去获取全局锁,获取到全局锁之后,就提交事务,释放DB锁(注意此时,DB锁虽然释放了,但TC服务已经在全局锁所在的表中记录了当前事务对id为1的这行数据有操作权)。恰巧这个时候,事务2见缝插针,获取到了DB锁(由于事务1已经释放了DB锁,因此事务2能够获取到DB锁),保存快照之后,然后执行业务sql将90改为了80,然后就去获取全局锁,但是,此时account表的这行数据已经被TC服务标记为事务1才有操作权限,因此,事务2获取全局锁就会失败(内部有重试机制),但是事务2此时是持有DB锁的,而此时的事务1持有的是全局锁,现在,事务1持有全局锁,事务1开始第二阶段,所以事务1需要获取对应的DB锁,但是事务2正拿着DB锁,正在重试去拿全局锁,此时就出现了死锁的情况。而全局锁的获取是有重试机制的(默认30次,间隔10ms),因此事务2最多重试300ms后,由于获取不到全局锁,而出现任务超时的情况,此时事务2就回滚事务并释放DB锁,这个时候,事务1就可以获取到DB锁了,在事务1获取到DB锁之后,就可以提交事务,或者根据快照恢复数据,然后释放全局锁。
我们发现在全局锁生效的过程中,其实就是各个事务在执行完业务sql之后,就要去向TC服务获取全局锁,由于TC服务记录了某个事务具备操作某行数据的权限,因此,其它尝试去获取已经被某个事务获取了某行数据的操作权限的事务就会失败,一旦事务失败,这个失败的事务就会回滚,并释放DB锁。
全局锁也会锁定资源,这与XA模式中对资源锁定的区别:
全局锁是由TC服务去记录的,记录的是哪个事务对哪行数据具备操作权限。而XA模式获取的是执行完业务之后不提交,获取的锁是DB锁。因此全局锁与XA获取的DB锁是有粒度的差别。数据库的锁只要不释放,任何人都无法访问这条数据(增删改查都不行)。但是TC的锁是由seata通过表来记录的(是由seata来管理的),其它不由seata管理的事务去操作这条数据是可以的,因此,全局锁的力度上就小很多。
但也由此引发出1个问题:其它不由seata管理的事务并不需要去获取全局锁就能够操作这条数据,因此,又会出现脏写问题,但是这种出现的概率本身就非常的低。首先,事务1的第二阶段大部分情况下都是提交事务,少部分才会回滚事务;其二,分布式事务业务耗时比较长,并发比较低,这种见缝插针的 事务执行的情况就更少了;其三,我们只要把不由seata管理的事务交给seata管理就可以了。
AT模式的写隔离
过程分析:事务1在获取DB锁之后,保存的快照是有2个的,1个是在执行业务sql之前称为before-image,1个是在执行业务sql之后称为after-image,在执行完业务sql将100改为90之后,就会提交事务1,然后释放DB锁。此时,事务2(它不由seata管理,因此不会去获取全局锁)获取DB锁,将money由90改为80,然后提交事务2,释放DB锁。此时,事务1开始第二阶段,假设此时事务1需要回滚事务,因此它需要根据快照恢复数据,但是此时事务1发现当前id为1的数据为80,而不是之前的90了,因此,判定肯定有其它不由seata管理的事务对这条数据做了修改,因此,此时就需要记录异常,发送警告,由人工介入。
AT模式的优缺点
AT模式的优点:
- 一阶段完成直接提交事务,释放数据库资源,性能比较好
- 利用全局锁实现读写隔离
- 没有代码侵入,框架自动完成回滚和提交
AT模式的缺点:
- 两阶段之间属于软状态,属于最终一致
- 框架的快照功能会影响性能,但比XA模式要好很多
实现AT模式
步骤概要
AT模式中的快照生成、回滚等动作都是由框架自动完成,没有任何代码侵入,因此实现非常简单。
-
导入课前资料提供的Sql文件:seata-at.sql,其中lock_table(全局锁 表)导入到TC服务关联的数据库,undo_log(快照 表)导入到微服务关联的数据库:
/* Navicat Premium Data Transfer Source Server : local Source Server Type : MySQL Source Server Version : 50622 Source Host : localhost:3306 Source Schema : seata_demo Target Server Type : MySQL Target Server Version : 50622 File Encoding : 65001 Date: 20/06/2021 12:39:03 */ SET NAMES utf8mb4; SET FOREIGN_KEY_CHECKS = 0; -- ---------------------------- -- 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; -- ---------------------------- -- Records of undo_log -- ---------------------------- -- ---------------------------- -- 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; SET FOREIGN_KEY_CHECKS = 1;
-
修改application.yml文件,将事务模式修改为AT模式即可:
seata: # 开启数据源代理的AT模式 data-source-proxy-mode: AT
-
重启服务并测试
seata的AT模式示例
建表lock_table&undo_log
lock_table(全局锁 表)导入到TC服务关联的数据库,undo_log(快照 表)导入到微服务关联的数据库
修改数据源代理模式配置
第二步:对order-service、account-service、storage-service的application.yml配置文件(开启数据源代理的XA模式)作同样的修改。以storage-service的application.yml配置文件示例,修改如下
server:
port: 8081
spring:
application:
name: storage-service
datasource:
driver-class-name: com.mysql.jdbc.Driver
url: jdbc:mysql:///seata_demo?useUnicode=true&characterEncoding=utf8&allowMultiQueries=true&useSSL=false
username: root
password: root
cloud:
nacos:
server-addr: localhost:8848
mybatis-plus:
global-config:
db-config:
insert-strategy: not_null
update-strategy: not_null
id-type: auto
logging:
level:
org.springframework.cloud.alibaba.seata.web: debug
cn.itcast: debug
pattern:
dateformat: MM-dd HH:mm:ss:SSS
seata:
# 寻找seata的tc服务相关配置
# (TC服务注册中心的配置,微服务根据这些信息去注册中心获取tc服务地址)
registry:
# 这里可参考tc服务自己的registry.conf中的配置
type: nacos
nacos:
# nacos注册中心的地址
server-addr: 127.0.0.1:8848
# 配置为空字符串就是public
namespace: ''
# seata的registry.conf配置文件中regitry选项配置中group为DEFAULT_GROUP,即所属组
group: DEFAULT_GROUP
# tc服务在nacos中的服务名称
application: seata-tc-server
username: nacos
password: nacos
# 事务组,根据这个获取tc服务的cluster名称
tx-service-group: seata-demo
service:
# 事务组与TC服务cluster的映射关系
vgroup-mapping:
seata-demo: SH
# 开启数据源代理的XA模式
data-source-proxy-mode: AT
使用@GlobalTransaction注解
在order-service服务的createOrder方法中添加@GlobalTransaction注解
@Slf4j
@Service
public class OrderServiceImpl implements OrderService {
private final AccountClient accountClient;
private final StorageClient storageClient;
private final OrderMapper orderMapper;
public OrderServiceImpl(AccountClient accountClient,
StorageClient storageClient,
OrderMapper orderMapper) {
this.accountClient = accountClient;
this.storageClient = storageClient;
this.orderMapper = orderMapper;
}
@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();
}
}
重启并测试
重启order-service、account-service、storage-service服务(注意:此时需要开启NACOS服务和seata的TC服务)
首先,我们请求:http://localhost:8082/order?userId=user202103032042012&commodityCode=100202003032041&count=2&money=200,因为目前库存是够的,所以正常创建订单、扣减余额,扣减商品库存。
然后我们请求:http://localhost:8082/order?userId=user202103032042012&commodityCode=100202003032041&count=10&money=200,显然,此时库存是不够的,库存服务的调用抛出了异常,此时,我们发现订单并没有创建成功、余额也没有扣除、库存也没有减少。因此,分布式事务的一致性得到了保证。
在上面的第2次请求中,首先订单服务自己在自己的数据源创建的订单,然后订单服务对账户服务发起扣款的远程调用,并且这次的扣款远程调用是成功的,然后订单服务对库存服务发起远程调用,由于库存不够,因此库存服务抛出异常,订单服务的这次远程调用失败。因此,TC服务就会通知各个微服务第二阶段恢复数据,各个服务就会根据快照恢复数据。
(XA模式和AT模式都可以实现隔离性和一致性,XA是强一致,AT是最终一致。在隔离性上,XA是一阶段不提交,然后基于数据库事务本身的特性来完成隔离。而AT是加了全局锁,锁定资源,去隔离事务,从本质上来看,XA和AT都是加了锁来实现的,只要加了锁就会有性能的损耗,如果要追求极致的性能就要想其它办法,而TCC模式则是性能的表现,它不需要加锁)
4.3 TCC模式
TCC模式与AT模式非常相似,每阶段都是独立事务,不同的是TCC通过人工编码来实现数据恢复。需要实现三个方法:
- Try:资源的检测和预留;
- Confirm:完成资源操作业务;要求 Try 成功 ,Confirm 一定要能成功。
- Cancel:预留资源释放,可以理解为try的反向操作。
TCC模式原理
- TCC模式属于最终一致,中间会存在软状态,只有在阶段二完成了,confirm或cancel后,才能保证数据的一致性
- 从隔离性上来看,AT模式需要加锁来实现隔离,AT模式需要在一阶段和二阶段中间过程中持有全局锁,这样在这2个阶段中间,其它事务是不能操作这个资源的,从而确保安全。但是TCC模式不需要隔离,因为TCC模式在一阶段,冻结了部分余额,假设此时存在其它事务也需要冻结部分余额,这些事务之间冻结的资源本身就是相互不受影响的,并且在二阶段提交或回滚都是操作各自预留的资源实现的。
原理图
TCC模式的每个阶段是做什么的?
- Try:资源检查和预留
- Confirm:业务执行和提交
- Cancel:预留资源的释放
TCC的优点是什么?
- 一阶段完成直接提交事务,释放数据库资源,性能好
- 相比AT模型,无需生成快照,无需使用全局锁,性能最强
- 不依赖数据库事务,而是依赖补偿操作,可以用于非事务型数据库
TCC的缺点是什么?
- 有代码侵入,需要人为编写try、Confirm和Cancel接口,太麻烦
- 软状态,事务是最终一致
- 需要考虑Confirm和Cancel的失败情况(在失败的情况下,seata会重试),做好幂等处理
实现TCC模式
TCC模式的关键在于对try、confirm、cancel逻辑的编写,这里仅对account-service服务
中的扣减余额这个业务进行改造,利用TCC实现分布式事务。(并不是所有的事务都适合基于TCC来实现;在1次分布式事务过程中,可以既存在AT模式,又存在TCC模式,因为都是存在2个阶段,一阶段直接提交,二阶段回滚或提交;)
需求如下:
- 修改account-service,编写try、confirm、cancel逻辑
- try业务:添加冻结金额,扣减可用金额
- confirm业务:删除冻结金额
- cancel业务:删除冻结金额,恢复可用金额
- 保证confirm、cancel接口的幂等性
- 允许空回滚
- 拒绝业务悬挂
TCC的空回滚和业务悬挂
空回滚:当某分支事务的try阶段阻塞时,可能导致全局事务超时而触发二阶段的cancel操作。在未执行try操作时先执行了cancel操作,这时cancel不能做回滚,就是空回滚(意思就是没有进行try操作,但是全局事务由于某个分支事务由于某种原因阻塞而超时,tc服务就会通知各分支事务作回滚操作,因此,在tcc模式下,各分支事务就会调用cancel,但是有的分支事务就是由于阻塞,而没有执行try操作也执行了cancel,这样就存在问题了,但是我们在这种情况下的此时的cancel方法中也不能报错,因此,这种情况下,需要做空回滚,这就是空回滚)。
业务悬挂:对于已经空回滚的业务,如果以后继续执行try,就永远不可能confirm或cancel,这就是业务悬挂。应当阻止执行空回滚后的try操作,避免悬挂(前面由于某个分支事务由于某种原因阻塞而超时,而已经执行了cancel空回滚了,但是现在不阻塞了,又去执行try操作,但是后续又没有后面的confirm或者cancel了)
业务分析
account_freeze_tbl表
为了实现空回滚、防止业务悬挂,以及幂等性要求。我们必须在数据库记录冻结金额的同时,记录当前事务id和执行状态,为此我们设计了一张表(account_freeze_tbl,在业务库中创建这张表):
/*
Navicat Premium Data Transfer
Source Server : local
Source Server Type : MySQL
Source Server Version : 50622
Source Host : localhost:3306
Source Schema : seata_demo
Target Server Type : MySQL
Target Server Version : 50622
File Encoding : 65001
Date: 23/06/2021 16:23:20
*/
SET NAMES utf8mb4;
SET FOREIGN_KEY_CHECKS = 0;
-- ----------------------------
-- Table structure for account_freeze_tbl
-- ----------------------------
DROP TABLE IF EXISTS `account_freeze_tbl`;
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;
-- ----------------------------
-- Records of account_freeze_tbl
-- ----------------------------
SET FOREIGN_KEY_CHECKS = 1;
账户余额冻结表:
账户余额表:
TCC实现分析
声明TCC接口- AccountTCCService
TCC的Try、Confirm、Cancel方法都需要在接口中基于注解来声明,语法如下:
@LocalTCC
public interface AccountTCCService {
/**
* Try逻辑,@TwoPhaseBusinessAction中的name属性要与当前方法名一致,用于指定Try逻辑对应的方法
*/
@TwoPhaseBusinessAction(
name = "deduct",
commitMethod = "confirm",
rollbackMethod = "cancel"
)
void deduct(@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实现
TCC核心重点都在这里
@Service
@Slf4j
@SuppressWarnings("all")
public class AccountTCCServiceImpl implements AccountTCCService {
@Autowired
private AccountMapper accountMapper;
@Autowired
private AccountFreezeMapper freezeMapper;
@Override
@Transactional
public void deduct(String userId, int money) {
// 资源的检测和预留
// (因为account_tbl表的money字段是unsigned,
// 因此如果将这个字段修改为负数会报错,因此不需要检测)
// 0.获取事务id
String xid = RootContext.getXID();
// 判断freeze中是否有冻结记录, 如果有, 一定是CANCEL执行过了, 我要拒绝(防止业务悬挂)
AccountFreeze oldFreeze = freezeMapper.selectById(xid);
if (oldFreeze != null) {
// CANCEL执行过了, 我要拒绝
return;
}
// 1.扣减可用余额(如果这里扣减的money超过数据库中的money值, 将会抛出异常)
accountMapper.deduct(userId, money);
// 2.记录冻结金额,事务状态
AccountFreeze freeze = new AccountFreeze();
freeze.setUserId(userId);
freeze.setFreezeMoney(money);
freeze.setState(AccountFreeze.State.TRY);
freeze.setXid(xid);
freezeMapper.insert(freeze);
}
@Override
public boolean confirm(BusinessActionContext ctx) {
// 1.获取事务id
String xid = ctx.getXid();
// 2.根据id删除冻结记录
int count = freezeMapper.deleteById(xid);
// 因为这里执行的是删除逻辑, 本身就是幂等的, 因此, 不需要做幂等判断
return count == 1;
}
@Override
public boolean cancel(BusinessActionContext ctx) {
// 0.查询冻结记录
String xid = ctx.getXid();
AccountFreeze freeze = freezeMapper.selectById(xid);
// 1. 空回滚的判断, 判断freeze是否欧维null, 为null证明try没执行, 需要空回滚
if (freeze == null) {
// 证明try没执行, 需要空回滚(不是啥也不干, 而是需要记录一下哦)
freeze = new AccountFreeze();
// 这里的userId在try中, 有被@BusinessActionContextParameter注解所标记, 因此这里可以使用
freeze.setUserId(ctx.getActionContext("userId").toString());
freeze.setFreezeMoney(0);
freeze.setState(AccountFreeze.State.CANCEL);
freeze.setXid(xid);
// 空回滚也会有记录
freezeMapper.insert(freeze);
return true;
}
// 2. 幂等判断
if (freeze.getState() == AccountFreeze.State.CANCEL) {
// 已经处理过一次CANCEL了, 无需重复处理
return true;
}
// 1.恢复可用余额
accountMapper.refund(freeze.getUserId(), freeze.getFreezeMoney());
// 2.将冻结金额清零,状态改为CANCEL
freeze.setFreezeMoney(0);
freeze.setState(AccountFreeze.State.CANCEL);
int count = freezeMapper.updateById(freeze);
return count == 1;
}
}
AccountController
将原来的AccountService切换为AccountTCCService服务
@RestController
@RequestMapping("account")
public class AccountController {
@Autowired
private AccountService accountService;
@Autowired
private AccountTCCService accountTCCService;
@PutMapping("/{userId}/{money}")
public ResponseEntity<Void> deduct(@PathVariable("userId") String userId,
@PathVariable("money") Integer money){
accountTCCService.deduct(userId, money);
return ResponseEntity.noContent().build();
}
}
account_freeze_tbl表所对应的实体类
@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;
}
}
AccountMapper接口
public interface AccountMapper extends BaseMapper<Account> {
@Update("update account_tbl set money = money - ${money} where user_id = #{userId}")
int deduct(@Param("userId") String userId, @Param("money") int money);
@Update("update account_tbl set money = money + ${money} where user_id = #{userId}")
int refund(@Param("userId") String userId, @Param("money") int money);
}
OrderServiceImpl
orderServiceImpl并未作任何修改
@Slf4j
@Service
public class OrderServiceImpl implements OrderService {
private final AccountClient accountClient;
private final StorageClient storageClient;
private final OrderMapper orderMapper;
public OrderServiceImpl(AccountClient accountClient,
StorageClient storageClient,
OrderMapper orderMapper) {
this.accountClient = accountClient;
this.storageClient = storageClient;
this.orderMapper = orderMapper;
}
@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();
}
}
重启并测试
保持与前面的AT模式的配置不动(不需要修改配置文件;在一次分布式事务的过程中,AT模式可以与TCC模式共存),重启order-service、account-service(我们仅使用了TCC模式修改了account-service,其它服务的模式仍然是AT模式)、storage-service服务(注意:此时需要开启NACOS服务和seata的TC服务)
首先,我们请求:http://localhost:8082/order?userId=user202103032042012&commodityCode=100202003032041&count=2&money=200,因为目前库存是够的,所以正常创建订单、扣减余额,扣减商品库存。
然后我们请求:http://localhost:8082/order?userId=user202103032042012&commodityCode=100202003032041&count=10&money=200,显然,此时库存是不够的,库存服务的调用抛出了异常,此时,我们发现订单并没有创建成功、余额也没有扣除、库存也没有减少。因此,分布式事务的一致性得到了保证。
我们从这里可以看出TCC模式实现起来还是比较麻烦的。
4.4 Saga模式
Saga模式是SEATA提供的长事务解决方案。也分为两个阶段:
- 一阶段:直接提交本地事务
- 二阶段:成功则什么都不做;失败则通过编写补偿业务来回滚
Saga模式优点:
- 事务参与者可以基于事件驱动实现异步调用,吞吐高
- 一阶段直接提交事务,无锁,性能好
- 不用编写TCC中的三个阶段,实现简单
Saga模式缺点:
- 软状态持续时间不确定,时效性差
- 没有锁,没有事务隔离,会有脏写
4.5 四种模式对比
5、seata高可用
高可用集群结构
TC的异地多机房容灾架构
TC服务作为Seata的核心服务,一定要保证 高可用 和 异地容灾(异地容灾就是:1个机房内(比如上海的机房)的多个TC服务挂了,还有另外1个机房(比如杭州的机房)的TC服务可用)。