基于docker整合seata
# 前言
在之前的文章微服务远程调用整合 (opens new window),我们完成服务之间的调用,但是这种架构下也诞生了新的问题——分布式事务问题。
如下代码所示,这就是order-service的下单代码,下单步骤也很简单,大概分为以下这三步:
- 创建订单
- 扣减用户钱包
- 扣减库存

代码如下所示,这种代码在本地事务情况下是没有问题的,因为任何一个服务的保存都会被感知,这就使得任何一处错误的触发就会让事务回滚。
但是我们的服务涉及到了远程调用,本地事务自然不生效,例如我们的accountFeign这个远程服务完成金额扣减并,即使productFeign报错会不使得accountFeign回滚,因为他们根本不在一个事务中。
@Transactional(rollbackFor = RuntimeException.class)
public void createOrder(OrderDto orderDTO) {
Order order = new Order();
BeanUtils.copyProperties(orderDTO, order);
//创建订单
save(order);
//扣减金额
accountFeign.reduceAccount(orderDTO.getAccountCode(), orderDTO.getPrice());
//扣减商品
productFeign.deduct(orderDTO.getProductCode(), orderDTO.getCount());
}
2
3
4
5
6
7
8
9
10
11
12
13
所以,我们就需要基于seata实现微服务架构下的分布式事务。需要强调的是,因为笔者环境都是基于docker-compose搭建的,所以seata也同样是基于docker-compose完成容器的编排与创建。
# 整合步骤
# 容器编排和基础环境配置
既然要用到docker-compose,所以我们就需要创建一个yml文件,以笔者为例创建一个名为seata-compose.yaml的文件,笔者都已给出注释,内容如下:
version: "3"
services:
seata-server:
image: seataio/seata-server:1.4.2
ports:
# 内外部端口映射
- "8091:8091"
environment:
# 端口号和seata的ip地址
- SEATA_PORT=8091
- SEATA_IP=x.x.x.x
volumes:
# 宿主和容器之间registry.conf文件映射地址
- "/usr/local/seata/seata-config/registry.conf:/seata-server/resources/registry.conf"
# 宿主和容器之间file.conf文件映射地址
- "/usr/local/seata/seata-config/file.conf:/seata-server/resources/file.conf"
expose:
# 暴露端口号
- 8091
# 容器名称
container_name: seata-server
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
可以看到笔者上文配置中registry.conf宿主存放位置在/usr/local/seata/seata-config/,所以我们需要在这个位置创建registry.conf,以笔者为例,这个registry.conf内容如下,可以看到笔者指明了注册中心的地址、命名空间id以及分组名。
registry {
# 将seata注册到nacos上
type = "nacos"
nacos {
# nacos地址
serverAddr = "ip:8848"
# 命名空间id
namespace = "63f0dbe6-ac91-4a2e-a88e-82b76f8187b6"
# 组名
group = "DEFAULT_GROUP"
# 集群节点名称
cluster = "default"
}
}
config {
# 通过nacos获取配置
type = "nacos"
nacos {
serverAddr = "ip:8848"
namespace = "63f0dbe6-ac91-4a2e-a88e-82b76f8187b6"
group = "DEFAULT_GROUP"
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
# 将seata常规配置存到nacos中
完成上述步骤后,我们的seata已经可以注册到nacos上了。只不过我们还需要在上述的命名空间(63f0dbe6-ac91-4a2e-a88e-82b76f8187b6)创建一个seataServer.properties的配置文件,将seata存储设置为MySQL存储,并设置事务分组的名称。内容如下(读者只需按照需要修改数据库地址即可):
store.mode=db
#-----db-----
store.db.datasource=druid
store.db.dbType=mysql
# 需要根据mysql的版本调整driverClassName
# mysql8及以上版本对应的driver:com.mysql.cj.jdbc.Driver
# mysql8以下版本的driver:com.mysql.jdbc.Driver
store.db.driverClassName=com.mysql.jdbc.Driver
store.db.url=jdbc:mysql://ip:3306/seata?useUnicode=true&characterEncoding=utf8&connectTimeout=1000&socketTimeout=3000&autoReconnect=true&useSSL=false
store.db.user= 用户
store.db.password= 数据库密码
# 数据库初始连接数
store.db.minConn=1
# 数据库最大连接数
store.db.maxConn=20
# 获取连接时最大等待时间 默认5000,单位毫秒
store.db.maxWait=5000
# 全局事务表名 默认global_table
store.db.globalTable=global_table
# 分支事务表名 默认branch_table
store.db.branchTable=branch_table
# 全局锁表名 默认lock_table
store.db.lockTable=lock_table
# 查询全局事务一次的最大条数 默认100
store.db.queryLimit=100
# undo保留天数 默认7天,log_status=1(附录3)和未正常清理的undo
server.undo.logSaveDays=7
# undo清理线程间隔时间 默认86400000,单位毫秒
server.undo.logDeletePeriod=86400000
# 二阶段提交重试超时时长 单位ms,s,m,h,d,对应毫秒,秒,分,小时,天,默认毫秒。默认值-1表示无限重试
# 公式: timeout>=now-globalTransactionBeginTime,true表示超时则不再重试
# 注: 达到超时时间后将不会做任何重试,有数据不一致风险,除非业务自行可校准数据,否者慎用
server.maxCommitRetryTimeout=-1
# 二阶段回滚重试超时时长
server.maxRollbackRetryTimeout=-1
# 二阶段提交未完成状态全局事务重试提交线程间隔时间 默认1000,单位毫秒
server.recovery.committingRetryPeriod=1000
# 二阶段异步提交状态重试提交线程间隔时间 默认1000,单位毫秒
server.recovery.asynCommittingRetryPeriod=1000
# 二阶段回滚状态重试回滚线程间隔时间 默认1000,单位毫秒
server.recovery.rollbackingRetryPeriod=1000
# 超时状态检测重试线程间隔时间 默认1000,单位毫秒,检测出超时将全局事务置入回滚会话管理器
server.recovery.timeoutRetryPeriod=1000
# 指定事务组
seata.service.vgroupMapping.seata-demo=default
# 指定SeaTa的命名空间
seata.config.nacos.namespace=63f0dbe6-ac91-4a2e-a88e-82b76f8187b6
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
# 创建数据库
上文配置中我们指明一个名为seata的数据库,所以我们就需要到创建一个名为seata的数据库并刷入下面这几张表:
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;
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
# 启动seata将其注册到nacos中
完成这些步骤之后,我们就可以启动seata容器查看是否注册到容器中,我们在seata-compose.yaml文件所在的路径键入这条命令
docker-compose -f seata-compose.yaml up -d
2
3
完成这条命令后,我们通过docker ps获取到seata的id值,以笔者为例,容器的id为8c48c75d07ad,所以我们键入:
docker logs 8c48c75d07ad
如下图所示,如果正常读取到registry.conf文件以及输出端口号,就说明启动成功了。

查看nacos对应命名空间的服务列表,可以看到seata-server已经成功注册了,自此我们的seata就已经部署成功了。

# 服务注册到seata
完成这些步骤后,我们就可以将本地服务注册到seata中,首先服务必须引入依赖seata-spring-boot-starter,只有引入这个依赖才会自动装配seata相关组件确保服务可以注册到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>
<version>1.4.2</version>
</dependency>
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
然后修改微服务的yml文件配置,如下所示,这里需要注意一点,因为笔者在上文seataServer.properties指定事务分组名称为seata-demo,所以我们这里的tx-service-group也是seata-demo。然后vgroup-mapping也指明seata-demo和我们nacos集群(笔者在上文registry.conf将cluster配置为default)的映射关系。

具体配置如下所示:
seata:
# TC服务注册中心的配置,微服务根据这些信息去注册中心获取tc服务地址
registry:
# 注册中心类型 nacos
type: nacos
nacos:
# nacos地址
server-addr: ip:8848
# namespace,默认为空
namespace: 63f0dbe6-ac91-4a2e-a88e-82b76f8187b6
# 配置组
group: DEFAULT_GROUP
# seata服务名称
application: seata-server
username: nacos
password: 密码
config:
type: nacos
nacos:
server-addr: ip:8848
group : "DEFAULT_GROUP"
namespace: "63f0dbe6-ac91-4a2e-a88e-82b76f8187b6"
dataId: "seataServer.properties"
username: "nacos"
password: "密码"
# 事务组名称
tx-service-group: seata-demo
service:
vgroup-mapping: # 事务组与cluster的映射关系
seata-demo: default
grouplist.seata-server: ip:8091
data-source-proxy-mode: AT
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
注意yml文件对缩进的格式要求很高,读者可以参考笔者的配置进行修改,笔者本次部署时遇到服务始终无法注册到seata中,控制台持续输出can not get cluster name in registry config xxxx, please make sure registry config correct
经过查阅源码NettyClientChannelManager的代码段,大抵推测yml配置没有生效,排查半天得出yml缩进有问题。
//笔者这里debug进去发现group取的SEATA-GROUP和我们的指定的DEFAULT-GROUP不一样
String clusterName = registryService.getServiceGroup(transactionServiceGroup);
if (StringUtils.isBlank(clusterName)) {
LOGGER.error("can not get cluster name in registry config '{}{}', please make sure registry config correct",
ConfigurationKeys.SERVICE_GROUP_MAPPING_PREFIX,
transactionServiceGroup);
return;
}
2
3
4
5
6
7
8
9
笔者查阅github一些issue发现,上面这个问题可能还需要补充这样一个步骤:
在上文配置的命名空间中增加一条配置,data-id为service.vgroupMapping.事务分组名称,以笔者为例就是service.vgroupMapping.seata-demo,内容为default

# 启动服务将其注册到seata中
完成后启动服务,以笔者的order-service为例,启动后如果seata日志中输出这样一段话,则说明启动成功了。
16:31:15.596 INFO --- [rverHandlerThread_1_1_500] i.s.c.r.processor.server.RegRmProcessor : RM register success,message:RegisterRMRequest{resourceIds='jdbc:mysql://ip:3306/cloud_alibaba', applicationId='order-service', transactionServiceGroup='seata-demo'},channel:[id: 0x25ec75ef, L:/172.23.0.5:8091 - R:/220.200.39.1:30735],client version:1.4.2
16:31:15.732 INFO --- [rverHandlerThread_1_2_500] i.s.c.r.processor.server.RegRmProcessor : RM register success,message:RegisterRMRequest{resourceIds='jdbc:mysql://ip:3306/cloud_alibaba', applicationId='order-service', transactionServiceGroup='seata-demo'},channel:[id: 0x25ec75ef, L:/172.23.0.5:8091 - R:/220.200.39.1:30735],client version:1.4.2
2
3

其他服务同理,都完成后,我们的下单服务代码加一个GlobalTransactional注解,即可完成分布式事务了
@Override
@GlobalTransactional
@Transactional(rollbackFor = RuntimeException.class)
public void createOrder(OrderDto orderDTO) {
Order order = new Order();
BeanUtils.copyProperties(orderDTO, order);
//创建订单
save(order);
//扣减金额
accountFeign.reduceAccount(orderDTO.getAccountCode(), orderDTO.getPrice());
//扣减商品
productFeign.deduct(orderDTO.getProductCode(), orderDTO.getCount());
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
接口名:
http://ip/order/order/create
测试参数
{
"orderNo": "121321321",
"accountCode": "demoData",
"productCode": "P001",
"count": 1,
"amount": 1,
"price": 1
}
2
3
4
5
6
7
8
9
如下,可以看到我们调用接口首先会创建全局事务,笔者在productFeign故意设置了一个报错,结果发现productFeign报错后就会rollback
2023-02-02 13:22:39,525 INFO DefaultGlobalTransaction:109 - Begin new global transaction [xxxxxx:8091:45391680118218764]
2023-02-02 13:22:53,176 INFO DefaultGlobalTransaction:188 - Suspending current transaction, xid = xxxxxx:8091:45391680118218764
2023-02-02 13:22:53,177 INFO DefaultGlobalTransaction:178 - [xxxxxx:8091:45391680118218764] rollback status: Rollbacked
2
3

# 更多关于seata
关于seata更多理论知识可以参考笔者这篇文章
分布式事务Seata实践 (opens new window)
# 参考文献
SpringCloud Alibaba微服务实战九 - Seata 容器化 (opens new window)
Seata 番外篇:使用 docker-compose 部署 Seata Server(TC)及 K8S 部署 Seata 高可用 (opens new window)
分布式事务解决方案之 Seata(二):Seata AT 模式 (opens new window)