0%

SpringCloud下基于Seata AT的分布式事务实践

Seata是Spring Cloud Alibaba中一款开源的分布式事务解决方案,本文具体就Seata的AT模式进行介绍、实践

abstract.png

基本原理

在Seata的设计架构中有三个角色,具体如下

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

TC是Seata的服务端需独立部署,而TM、RM则是作为Seata的客户端与各微服务进行集成。三者之间的流程关系如下图所示。具体地,Seata的分布式事务模型是基于 2PC(两阶段提交,Tow-Phase Commit) 协议,基本执行流程如下

  1. TM向TC申请开启一个分布式事务,事务创建成功后会生成一个全局唯一的事务ID,即所谓的XID
  2. RM向TC注册分支事务,汇报资源准备状态
  3. TM通知TC 提交/回滚 分布式事务,事务一阶段结束
  4. TC汇总各分支事务信息,决定分布式事务是提交还是回滚
  5. TC通知所有RM 提交/回滚 资源,分布式事务的二阶段结束

figure 1.png

具体地,Seata支持AT、TCC、Saga、XA四种模式。这里就AT模式进行展开说明,其是一种无侵入的分布式事务解决方案,使得开发者只需关注自己的业务SQL即可。Seata会自动进行二阶段的提交/回滚。流程如下

  • 一阶段: Seata对业务SQL进行拦截、语义解析,进而确定业务SQL需要操作的相关业务数据记录。然后在执行业务SQL前,将相关业务数据记录保存为Before Image。在执行业务SQL后,再将其保存成After Image。并最终生成行锁。上述操作会在一个数据库的本地事务内完成,以保证一阶段操作的原子性

figure 2.png

  • 二阶段提交: 二阶段提交时,因为业务SQL在一阶段已经提交至各数据库。故Seata只需将一阶段保存的快照数据和行锁删掉,完成数据清理即可

figure 3.png

  • 二阶段回滚: 二阶段回滚时,首先需要对数据库当前相关的数据与After Image进行比对,如果完全一致,这说明未发生脏写。即没有被除当前全局事务之外的其他操作修改过,可以放心进行回滚。而具体回滚则是通过Before Image生成逆向SQL来进行反向补偿,并最终删除相应快照数据和行锁

figure 4.png

基于Seata AT模式的实践

搭建Seata Server环境

基于Docker Compose的服务部署

Seata Server事实上就是上文提到的事务协调者TC。这里通过Docker Compose来进行部署,如下所示。可以看到我们不仅创建了Seata Server服务,还创建了MySQL、Nacos服务。后面会一一进行解释

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
# Compose 版本
version: '3.8'

# 定义Docker服务
services:

# Seata 服务
Seata-Service-1:
image: seataio/seata-server:1.3.0
container_name: Seata-Service-1
ports:
- "9091:8091"
networks:
seata_service_net:
ipv4_address: 120.120.120.21
depends_on:
- MySQL-Service-1
- Nacos-Service-1

# MySQL 服务
MySQL-Service-1:
image: mysql:5.7
container_name: MySQL-Service-1
ports:
- "9306:3306"
environment:
MYSQL_ROOT_PASSWORD: 12345
networks:
seata_service_net:
ipv4_address: 120.120.120.22

# Nacos 服务
Nacos-Service-1:
image: nacos/nacos-server:1.4.2
container_name: Nacos-Service-1
ports:
- "9848:8848"
environment:
MODE: standalone
networks:
seata_service_net:
ipv4_address: 120.120.120.23

# 定义网络
networks:
seata_service_net:
ipam:
config:
- subnet: 120.120.120.0/24

配置Seata Server的持久化

Seata-Server支持多种持久化方式包括文件、DB、Redis等,默认为文件File。这里我们使用刚刚部署MySQL-Service-1服务进行持久化。进入Seata-Service-1容器,修改/seata-server/resources下的file.conf文件,将存储模式修改为db,同时修改相应的数据库连接信息。如下所示,可以看到这里datasource我们选择了druid

figure 5.jpeg

然后,通过数据库客户端连接MySQL-Service-1实例。首先创建file.conf文件中所连接的数据库seataServer,然后在该数据库中执行建表语句。其中SQL脚本可通过Github进行获取,地址如下所示

1
2
# 下载地址: Seata Server使用DB进行持久化的SQL初始化脚本
https://github.com/seata/seata/blob/1.3.0/script/server/db/mysql.sql

效果如下所示

figure 6.jpeg

配置Seata Server的注册中心、配置中心

前面提到,我们还创建了一个Nacos容器,即Nacos-Service-1实例。其是用于作为整个分布式环境的配置中心、注册中心。同样进入Seata-Service-1容器,修改/seata-server/resources下的registry.conf文件。将注册中心、配置中心均设置Nacos。详细配置如下所示

figure 7.jpeg

导入配置信息至Nacos

事实上对于Seata而言,其配置信息支持两种形式:本地文件、配置中心。对于后者而言,我们需要将Seata的相关配置项导入到配置中心。同样,我们需要通过Github来下载配置文件config.txt及相应的导入脚本nacos-config.sh

1
2
3
4
5
# 下载地址: 配置中心的配置项
https://github.com/seata/seata/blob/1.3.0/script/config-center/config.txt

# 下载地址: 用于将配置项导入至Nacos的脚本
https://github.com/seata/seata/blob/1.3.0/script/config-center/nacos/nacos-config.sh

对于配置文件config.txt而言,有以下两点需要注意

  1. 将配置项store.mode存储模式修改为db,同时修改以store.db为前缀的相关配置项,保证其与file.conf文件中相关数据库的配置一致
  2. 配置项service.vgroupMapping.my_test_tx_group=default的含义是,事务分组my_test_tx_group使用名为default的Seata Server集群。换言之,my_test_tx_group即为事务分组的名称,支持自定义。这里我们直接使用默认的事务分组名。而Seata Server集群名default实际上就是来自registry.conf文件的cluster配置项

figure 8.jpeg

在完成配置文件config.txt的修改后,即可利用Shell脚本导入至Nacos中。值得一提的是,配置文件config.txt应与Shell脚本的上一级目录保持平行。然后在Shell脚本所在目录中执行如下命令即可

1
2
# 执行Shell脚本
sh nacos-config.sh -h localhost -p 9848

该Shell脚本支持的选项如下所示

  • -h: Nacos服务的IP地址,默认为localhost
  • -p: Nacos服务的Port端口,默认为8848
  • -g: Nacos分组名,默认为SEATA_GROUP
  • -t: Nacos命名空间ID。默认为“”,即使用public命名空间
  • -u: Nacos服务的用户名
  • -w: Nacos服务的密码

效果如下所示

figure 9.jpeg

至此,Seata server相关环境及配置就完成了。最后,重启Seata-Service-1容器以让修改生效即可。通过Nacos的Web管理页面可以看到,Seata服务已经注册到Nacos

figure 10.jpeg

搭建order服务

POM依赖

这里通过SpringBoot搭建一个微服务——order服务。这里给出关键性的依赖及版本,如下所示

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
<dependencyManagement>
<dependencies>

<!--Spring Boot-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-dependencies</artifactId>
<version>2.3.2.RELEASE</version>
<type>pom</type>
<scope>import</scope>
</dependency>

<!--Spring Cloud-->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-dependencies</artifactId>
<version>Hoxton.SR8</version>
<type>pom</type>
<scope>import</scope>
</dependency>

<!--Spring Cloud Alibaba-->
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-alibaba-dependencies</artifactId>
<version>2.2.3.RELEASE</version>
<type>pom</type>
<scope>import</scope>
</dependency>

</dependencies>
</dependencyManagement>

<dependencies>

<!--Spring Cloud Alibaba Seata -->
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-seata</artifactId>
<exclusions>
<exclusion>
<groupId>io.seata</groupId>
<artifactId>seata-spring-boot-starter</artifactId>
</exclusion>
</exclusions>
</dependency>
<!--Seata版本与Seata Server保持一致-->
<dependency>
<groupId>io.seata</groupId>
<artifactId>seata-spring-boot-starter</artifactId>
<version>1.3.0</version>
</dependency>

<!--Spring Cloud Alibaba Nacos Discovery -->
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId>
</dependency>

<!--Spring Cloud Alibaba Nacos Config -->
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-nacos-config</artifactId>
</dependency>

<!-- Fastjson -->
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>fastjson</artifactId>
<version>1.2.76</version>
</dependency>

<!--Mybatis Plus-->
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>3.4.1</version>
</dependency>

</dependencies>

服务配置

order服务的配置文件application.yml,如下所示。这里关于Seata数据源的代理,我们选择自动代理的方式。此外配置文件中的相关IP、端口信息均为容器内部的IP、Port。因为对于SpringBoot服务我们也会通过Docker的方式进行构建、打包及部署

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
server:
port: 89

spring:
application:
name: order
datasource:
type: com.alibaba.druid.pool.DruidDataSource
driver-class-name: com.mysql.jdbc.Driver
url: jdbc:mysql://120.120.120.42:3306/order?allowPublicKeyRetrieval=true&useSSL=false
username: root
password: 12345
cloud:
nacos:
discovery:
# 注册中心 Nacos 地址信息
server-addr: 120.120.120.23:8848
alibaba:
seata:
# 配置所使用的事务分组名称
tx-service-group: my_test_tx_group

# Mybatis-Plus 配置
mybatis-plus:
mapper-locations: classpath:mapper/*.xml

# Seata Server配置
seata:
# Seata服务端所在注册中心的配置信息
registry:
# 注册中心类型
type: nacos
nacos:
# Seata服务端的服务名
application: seata-server
# Seata服务端所在的注册中心信息
server-addr: 120.120.120.23:8848
username: nacos
password: nacos
group: SEATA_GROUP
# Seata服务端所在配置中心的配置信息
config:
type: nacos
nacos:
server-addr: 120.120.120.23:8848
username: nacos
password: nacos
group: SEATA_GROUP
# 使能Seata自动代理数据源
enable-auto-data-source-proxy: true

# Actuator配置: 开启所有端点
management:
endpoints:
web:
exposure:
include: "*"
base-path: /actuator

Controller层

在order服务中通过添加一个Controller类用于进行测试,核心代码实现如下。addRecord方法逻辑很简单。首先向自己的数据库插入一条记录,然后再调用另外一个服务pyament的接口。由于该方法是作为分布式事务的发起者,故需要在方法上添加 @GlobalTransactional 注解,以开启一个分布式事务

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
@RestController
@RequestMapping("order2")
public class OrderController2 {

// 使用 注册中心的服务名
public static final String PAYMENT_URL = "http://payment";

@Autowired
private RestTemplate restTemplate;

@Autowired
private OrderRecordMapper orderRecordMapper;

@GlobalTransactional
@GetMapping("/addRecord")
public String addRecord(@RequestParam String name, @RequestParam Integer total) {
OrderRecord orderRecord = OrderRecord.builder()
.name(name)
.total(total)
.build();

// save方法通过MybatisPlus中的自定义SQL实现
orderRecordMapper.save(orderRecord);
String msg = restTemplate.getForObject(PAYMENT_URL +"/pay3/test1?name={1}", String.class, name);
return "OK";
}
}

...

@Configuration
public class RestTemplateConfig {
@Bean
@LoadBalanced
public RestTemplate restTemplate() {
return new RestTemplate();
}
}
...

@Data
@Builder
@AllArgsConstructor
@NoArgsConstructor
@TableName("orderRecord") // 指定数据库的表名
public class OrderRecord {
@TableId
private int id;
private String name;
private int total;
}

服务部署

首先将SpringBoot服务打包为Docker镜像,然后通过Docker Compose进行服务部署。为保证各服务、容器间的网络互通互联,这里order服务的容器同样需要使用Seata Server所在的名为seata_service_net的自定义网络。由于docker-compose.yml中自定义网络在创建后,其最终的网络名称是包含项目名的。故首先用docker network ls查看该网络的全名。如下所示,即该网络全名为seata-service_seata_service_net

figure 11.jpeg

在分布式环境下,每个微服务都是使用自己的数据库。这一点在order服务的application.yml配置文件中也可以看到。故在docker-compose.yml中我们同样需要为order服务创建一个MySQL实例。如下所示

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
# Compose 版本
version: '3.8'

# 定义Docker服务
services:

# Web服务
Order-Service:
image: aaron1995/spring_boot_order:1.0
container_name: Order-Service
ports:
- "8089:89"
networks:
seata-service_seata_service_net:
ipv4_address: 120.120.120.41
depends_on:
- Order-MySQL

# MySQL 服务
Order-MySQL:
image: mysql:5.7
container_name: Order-MySQL
ports:
- "9307:3306"
environment:
MYSQL_ROOT_PASSWORD: 12345
networks:
seata-service_seata_service_net:
ipv4_address: 120.120.120.42

# 定义网络
networks:
# 声明名为seata-service_seata_service_net的网络是一个已存在的网络
seata-service_seata_service_net:
external: true

数据库初始化

通过数据库客户端连接Order服务的数据库,即Order-MySQL容器。首先order服务所连接的数据库order,然后在该数据库中执行相关业务的建表语句

1
2
3
4
5
6
7
8
9
# 建库建表
create database `order`;
use `order`;
create table orderRecord (
id int not null auto_increment,
name varchar(255) null,
total int null,
primary key (id)
);

当然上述这些并无什么特别,只是业务方面需要。而为了保证Seata在事务出现异常时可以实现对业务数据进行回滚,我们还需要在业务的数据库中建立undo_log表。类似地,该SQL脚本也可通过Github进行获取,下载地址如下所示

1
2
# 下载地址: 业务数据库中undo_log表的建表SQL脚本
https://github.com/seata/seata/blob/1.3.0/script/client/at/db/mysql.sql

效果如下所示

figure 12.jpeg

搭建payment服务

为了验证分布式事务,自然不能只有一个微服务。故这里类似地我们再搭建一个payment服务。当然基本搭建过程与order服务并无明显差异。首先在POM依赖方面,payment服务的POM依赖与order服务一致,同样也需要引入Seata、Nacos等相关依赖。其次在服务配置方面,payment服务的application.yml配置文件中关于Seata、Nacos相关的配置自然与order服务并无二致。但需调整修改其所连接的数据库信息,如下所示。即使用自身的数据库

1
2
3
4
5
6
7
8
9
10
11
12
server:
port: 8011

spring:
application:
name: payment
datasource:
type: com.alibaba.druid.pool.DruidDataSource
driver-class-name: com.mysql.jdbc.Driver
url: jdbc:mysql://120.120.120.52:3306/payment?allowPublicKeyRetrieval=true&useSSL=false
username: root
password: 12345

在payment服务中添加相应的Controller方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
@RestController
@RequestMapping("pay3")
public class PaymentController3 {

@Autowired
private PayRecordMapper payRecordMapper;

@GetMapping("/test1")
public String test1(@RequestParam String name) {
// 更新自身数据库中id为1的记录
PayRecord payRecord = PayRecord.builder()
.id(1)
.serial( name +", "+ UUID.randomUUID().toString() )
.build();
payRecordMapper.updateById(payRecord);
if(name.equals("Tony")) {
throw new RuntimeException("发生业务异常");
}
return "OK";
}
}

...

@Data
@Builder
@AllArgsConstructor
@NoArgsConstructor
@TableName("payRecord") // 指定数据库的表名
public class PayRecord {
private int id;
private String serial;
}

类似地,将payment打包为Docker镜像后,通过docker compose进行部署,如下所示

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
# Compose 版本
version: '3.8'

# 定义Docker服务
services:

# Web服务
Payment-Service:
image: aaron1995/spring_boot_payment:1.0
container_name: Payment-Service
ports:
- "8015:8011"
networks:
seata-service_seata_service_net:
ipv4_address: 120.120.120.51
depends_on:
- Payment-MySQL

# MySQL 服务
Payment-MySQL:
image: mysql:5.7
container_name: Payment-MySQL
ports:
- "9308:3306"
environment:
MYSQL_ROOT_PASSWORD: 12345
networks:
seata-service_seata_service_net:
ipv4_address: 120.120.120.52

# 定义网络
networks:
# 声明名为seata-service_seata_service_net的网络是一个已存在的网络
seata-service_seata_service_net:
external: true

最后,在payment服务所使用的数据库Payment-MySQL容器上完成建库建表操作。不仅包含业务表,也包含上文提到的undo_log表。如下所示,由于PaymentController3的test1方法的业务逻辑是更新id为1记录,故这里也提前插入便于后续演示

figure 13.jpeg

测试验证

现在各服务部署完成后,从Nacos页面可以看到Seata Server、order、payment服务均已注册上线

figure 14.jpeg

当向order服务的接口发送HTTP请求时,由于name不为Tony未抛出异常。order的表中新增了一条记录。而payment表id为1的数据也被正确地更新了

figure 15.jpeg

而当HTTP请求的name参数为Tony时,payment服务发生异常。不仅payment表未发生更新,而且order的表中也没有新增数据。即被正常回滚

figure 16.jpeg

Note

  • 在本次实践过程中,发现通过Mybatis Plus Mapper内置的insert方法进行插入的数据在发生异常时无法进行回滚,故在order服务中添加记录是通过在相应的xml文件自定义SQL实现的。后者在发生异常时,可以对插入的数据进行回滚
请我喝杯咖啡捏~

欢迎关注我的微信公众号:青灯抽丝