Seata TCC模式

1)TCC介绍

TCC 基于分布式事务中的二阶段提交协议实现,它的全称为 Try-Confirm-Cancel,即资源预留(Try)、确认操作(Confirm)、取消操作(Cancel),他们的具体含义如下:

  • Try:对业务资源的检查,确定资源足够操作并预留。
    • 注意:一旦try成功,就一定会commit成功,try失败了就进行cancel回滚。
  • Confirm:对业务处理进行提交,即 commit 操作,只要 Try 成功,那么该步骤一定成功;
  • Cancel:对业务处理进行取消,即回滚操作,该步骤回对 Try 预留的资源进行释放。

1.1 TCC与XA的区别

TCC与XA的对比

  • XA是资源层面的分布式事务,强一致性,在两阶段提交的整个过程中,一直会持有资源的锁。
    • 在整个全局事务没结束之前,XA会一直持有数据库的锁,不会释放掉
  • TCC是业务层面的分布式事务,最终一致性,不会一直持有资源的锁。
    • TCC在第一步try后,就会释放资源的锁

TCC 是一种侵入式的分布式事务解决方案,以上三个操作都需要业务系统自行实现,对业务系统有着非常大的入侵性,设计相对复杂,但优点是 TCC 完全不依赖数据库,能够实现跨数据库、跨应用资源管理,更好地解决了在各种复杂业务场景下的分布式事务问题。

2)TCC 三个步骤

2.1 try-commit 尝试预留并提交

try 阶段首先进行检查资源是否足够操作,然后预留资源,然后在 commit 阶段扣除资源

注意:一旦try成功,就一定会commit成功,如果try失败了就会执行cancel回滚。

如下图:

TCC流程图

2.2 try-cancel 尝试预留失败回滚

try 阶段首先进行预留资源,预留资源时扣减库存失败导致全局事务回滚,在 cancel 阶段释放资源。如下图:

try-cancel

3)TCC模式

TCC模式

TCC模式下一样有TC、TM、RM,而他们的执行流程如下:

  1. RM告知TC发起全局事务,TC返回XID给 事务发起者

  2. 一阶段

    1. RM向TC 发起Prepare 预备请求,注册本地事务到TC,注册成功后报告本地分支状态(Branch Status Report)
  3. 二阶段

    1. TM判断所有的RM 第一阶段都提交成功的话,TM就会告知TC,让TC驱动所有RM对本地事务进行commit
    2. 如果TM判断第一阶段内有RM出异常了,TM就会告知TC,让TC驱动所有RM对本地事务进行rollback

在Seata中,AT模式与TCC模式事实上都是两阶段提交的模式,他们的区别在于:

AT 模式基于 支持本地 ACID 事务的关系型数据库实现:

  • 一阶段 prepare 行为:在本地事务中,提交数据更新同时也会更新回滚日志记录。
  • 二阶段commit行为:commit成功后,通过异步批量清理回滚日志。
  • 二阶段rollback行为:通过一阶段的回滚日志 完成数据回滚。

TCC模式下的一阶段prepare、二阶段commit、rollback都需要自己通过业务代码去实现,无法在依赖于数据库和undo_log表。

UseTCCFence=true,该字段用于记录TCC全局事务执行到了哪个阶段。

4)TCC模式下常见的异常问题

在TCC模式下可能会出现各种异常问题,最常见的有 空回滚、幂等、空悬挂,而在seata框架在1.5版本完美解决这些问题。

4.1 空回滚

空回滚指的是在分布式事务中,因为某种原因导致参与者第一阶段try没有执行的情况下,TM驱动二阶段导致参与者调用了cancel方法。

4.1.1 空回滚是如何产生的

空回滚逻辑图

在全局事务开启的情况下,参与者A注册完本地分支后的就会进入一阶段的try,但是此时由于宕机等异常导致一阶段没有执行,而TM检测到参与者一阶段异常的话就会通知TC,由TC会驱动所有参与者进行cancel,从而导致参与者A的空回滚。

4.1.2 seata如何防止空回滚

如果想要防止空回滚,在cancel之前我们要先判断try是否正常执行,如果try没有执行的话,是不允许进行cancel的。

Seata 的做法是新增一个 TCC 事务控制表,包含事务的 XID 和 BranchID 信息,在 Try 方法执行时插入一条记录,表示一阶段执行了,执行 Cancel 方法时读取这条记录,如果记录不存在,说明 Try 方法没有执行。

4.2 幂等问题

幂等问题指的是TC 重复进行二阶段提交,如Confirm/Cancel接口 都需要支持幂等,确保资源不会重复提交或释放。

4.2.1 幂等问题是如何产生的

幂等逻辑

当我们发起全局事务后,参与者A执行完二阶段后,由于网络或者其他问题,TC收不到参与者A二阶段的执行结果,TC会重复驱动参与者A进行二阶段操作,直到参与者返回二阶段执行成功。

4.2.2 seata如何解决幂等

我们在解决空回滚时,新增了一张TCC事务控制表,而解决幂等则是在该表中添加一个状态字段 status;

  • tried:1
  • committed:2
  • rollbacked:3

二阶段 Confirm/Cancel 方法执行后,将状态改为 committed 或 rollbacked 状态。当重复调用二阶段 Confirm/Cancel 方法时,判断事务状态即可解决幂等问题。

4.3 空悬挂问题

悬挂指的是二阶段 Cancel 方法比 一阶段 Try 方法优先执行,由于允许空回滚的原因,在执行完二阶段 Cancel 方法之后直接空回滚返回成功,此时全局事务已结束,但是由于 Try 方法随后执行,这就会造成一阶段 Try 方法预留的资源永远无法提交和释放了。

4.3.1 空悬挂是如何产生的

空悬挂

参与者A在执行try 一阶段时超时了,而TM基于seata的超时机制,TM会通知TC,TC驱动所有参与者回滚。当参与者A回滚成功之后,之前的try才开始执行,就会导致资源被预留,从而导致悬挂。

4.3.2 seata如何防止空悬挂

TCC事务控制表的status字段中新增一个状态:

  • suspended:4

当执行二阶段 Cancel 方法时,如果发现 TCC 事务控制表有相关记录,说明二阶段 Cancel 方法优先一阶段 Try 方法执行,因此插入一条 status=4 状态的记录,当一阶段 Try 方法后面执行时,判断 status=4 ,则说明有二阶段 Cancel 已执行,并返回 false 以阻止一阶段 Try 方法执行成功。

Spring Cloud Alibaba整合Seata TCC实战

TCC实战

本次实验主要模拟商城下单逻辑,由三个微服务组成:

订单服务:用户访问下单接口后,开启全局事务,try 创建订单,commit 修改订单状态为支付成功,rollback 订单状态为 支付失败

库存服务:订单服务通过feign调用库存扣减服务,try 扣减库存,commit 直接返回true,rollback 退回库存

账户服务:订单服务通过feign调用账户扣减服务,try 扣减余额,commit 直接返回true,rollback 退回库存

搭建流程:

  1. 搭建父工程,父工程指定springCloudAlibaba版本和springBoot版本,mysql和mybatis plus,seata 等依赖。
  2. 搭建订单微服务、账户微服务、库存微服务,并且在订单微服务上面创建账户、库存微服务对应的feign service
  3. 在订单、账户、库存微服务上面,定义TCC接口,接口包括:try、commit、rollback。
  4. 测试成功情况:try余额扣减成功,进行commit,commit成功。
  5. 测试失败情况:try余额扣减失败,进行rollback,rollback成功。

1)前置环境

1.1 父工程环境

image-20230620215044780

实验工程Demo:https://files.javaxing.com/seata/spring-cloud-seata-demo.zip

实验用到的nacos,seata 版本和搭建方法 在下面我会贴出来,具体可以往下翻。

  • 父pom指定微服务版本
Spring Cloud Alibaba Version Spring Cloud Version Spring Boot Version Seata Version
2.2.8.RELEASE Spring Cloud Hoxton.SR12 2.3.12.RELEASE 1.5.1
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
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
<?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>

<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.3.12.RELEASE</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>

<groupId>com.javaxing</groupId>
<artifactId>spring-cloud-seata-demo</artifactId>
<version>1.0-SNAPSHOT</version>
<packaging>pom</packaging>
<modules>
<module>spring-cloud-seata-order</module>
<module>spring-cloud-seata-account</module>
<module>spring-cloud-seata-stock</module>
</modules>

<properties>
<java.version>1.8</java.version>
<spring-cloud.version>Hoxton.SR12</spring-cloud.version>
<spring-cloud-alibaba.version>2.2.8.RELEASE</spring-cloud-alibaba.version>
<seata.version>1.5.1</seata.version>
<mysql-jdbc.version>8.0.29</mysql-jdbc.version>
<mybatis.version>2.1.1</mybatis.version>
</properties>

<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>

<dependency>
<groupId>com.alibaba</groupId>
<artifactId>druid-spring-boot-starter</artifactId>
<version>1.2.8</version>
</dependency>


<!-- 引入seata依赖-->
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-seata</artifactId>
</dependency>

<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</dependency>

<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>3.3.1</version>
</dependency>

<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
</dependency>

<!-- 引入nacos依赖-->
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId>
</dependency>


<!-- 引入nacos 配置中心-->
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-nacos-config</artifactId>
</dependency>
</dependencies>

<!-- 配置版本依赖 -->
<dependencyManagement>
<dependencies>

<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-dependencies</artifactId>
<version>${spring-cloud.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-alibaba-dependencies</artifactId>
<version>${spring-cloud-alibaba.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>

<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<scope>runtime</scope>
<version>${mysql-jdbc.version}</version>
</dependency>

<dependency>
<groupId>org.mybatis.spring.boot</groupId>
<artifactId>mybatis-spring-boot-starter</artifactId>
<version>${mybatis.version}</version>
</dependency>
</dependencies>
</dependencyManagement>
</project>

1.2 启动nacos

nacos怎么安装,这里可以看之前的文章《Nacos配置中心使用之Nacos 微服务注册中心搭建》

地址:https://www.javaxing.com/2023/04/01/%E5%BE%AE%E6%9C%8D%E5%8A%A1%E6%9E%B6%E6%9E%84%E9%80%89%E5%9E%8B%E5%92%8CNacos%E9%9B%86%E7%BE%A4%E6%90%AD%E5%BB%BA/

1
[root@S1 bin]# sh startup.sh -m standalone

1.3 seata tc端

启动seata TC端,并且指定使用nacos作为配置中心和注册中心,具体的启动和配置过程,可以看本文章的《Seata AT模式实战之Seata Server(TC)环境搭建》。

配置中心

image-20230620215737443

注册中心

  • seata服务已经成功注册到nacos

image-20230620215729306

2)订单微服务

image-20230621150906600

order-pom.xml

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
<?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>
<parent>
<groupId>com.javaxing</groupId>
<artifactId>spring-cloud-seata-demo</artifactId>
<version>1.0-SNAPSHOT</version>
</parent>

<artifactId>spring-cloud-seata-order</artifactId>
<packaging>pom</packaging>
<modules>
<module>spring-cloud-seata-order-api</module>
<module>spring-cloud-seata-order-biz</module>
</modules>

<properties>
<maven.compiler.source>8</maven.compiler.source>
<maven.compiler.target>8</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>

</project>

mysql数据库

订单表

1
2
3
4
5
6
7
8
9
10
11
CREATE TABLE `order_tbl` (
`id` bigint NOT NULL,
`user_id` varchar(255) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci DEFAULT NULL,
`commodity_code` varchar(255) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci DEFAULT NULL,
`count` int DEFAULT '0',
`money` decimal(10,2) DEFAULT '0.00',
`status` int DEFAULT NULL,
PRIMARY KEY (`id`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3;

INSERT INTO `tcc_account`.`account_tbl` (`id`, `user_id`, `money`) VALUES (1, '10000', 100.00);

TccFenceLog表

TCC 模式中存在的三大问题是幂等、悬挂和空回滚。在 Seata1.5.1 版本中,增加了一张事务控制表,表名是 tcc_fence_log 来解决这个问题。在@TwoPhaseBusinessAction 注解中提到的属性 useTCCFence 就是来指定是否开启这个机制,这个属性值默认是 false。

1
2
3
4
5
6
7
8
9
10
11
CREATE TABLE `tcc_fence_log` (
`xid` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT 'global id',
`branch_id` bigint NOT NULL COMMENT 'branch id',
`action_name` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT 'action name',
`status` tinyint NOT NULL COMMENT 'status(tried:1;committed:2;rollbacked:3;suspended:4)',
`gmt_create` datetime(3) NOT NULL COMMENT 'create time',
`gmt_modified` datetime(3) NOT NULL COMMENT 'update time',
PRIMARY KEY (`xid`,`branch_id`) USING BTREE,
KEY `idx_gmt_modified` (`gmt_modified`) USING BTREE,
KEY `idx_status` (`status`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;

注意:未避免幂等、悬挂、空回滚,在每个微服务中,都要有一张这样的表,因为每个微服务都会涉及到try,commit,rollback。

2.1 order-api

image-20230621151233024

pom.xml

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<?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>
<parent>
<groupId>com.javaxing</groupId>
<artifactId>spring-cloud-seata-order</artifactId>
<version>1.0-SNAPSHOT</version>
</parent>

<artifactId>spring-cloud-seata-order-api</artifactId>

<properties>
<maven.compiler.source>8</maven.compiler.source>
<maven.compiler.target>8</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>

</project>

entity

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
package com.javaxing.order.entity;


public enum OrderStatus {
/**
* INIT
*/
INIT(0),
/**
* SUCCESS
*/
SUCCESS(1),
/**
* FAIL
*/
FAIL(-1);

private final int value;

OrderStatus(int value) {
this.value = value;
}

public int getValue() {
return value;
}
}

vo

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
package com.javaxing.order.vo;

import lombok.Data;

import java.math.BigDecimal;

@Data
public class OrderVO {
private Long orderId;
private String userId;
private String commodityCode;
private Integer count;

private BigDecimal money;
}

2.2 order-biz

pom.xml

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
<?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 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>com.javaxing</groupId>
<artifactId>spring-cloud-seata-order</artifactId>
<version>1.0-SNAPSHOT</version>
</parent>

<groupId>com.javaxing</groupId>
<artifactId>spring-cloud-seata-order-biz</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>spring-cloud-seata-order-biz</name>
<description>spring-cloud-seata-order-biz</description>
<properties>
<java.version>1.8</java.version>
</properties>

<dependencies>
<!-- openfeign 远程调用 -->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-openfeign</artifactId>
</dependency>
<dependency>
<groupId>com.javaxing</groupId>
<artifactId>spring-cloud-seata-order-api</artifactId>
<version>1.0-SNAPSHOT</version>
</dependency>
</dependencies>

<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>

微服务bootstrap.yml中添加seata配置

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
server:
port: 8002
spring:
application:
name: order
cloud:
nacos:
discovery:
server-addr: 10.211.55.12:8848
# ????IP
config:
server-addr: 10.211.55.12:8848
# ???yaml???????????nacos-config.yaml
file-extension: yaml
datasource:
type: com.alibaba.druid.pool.DruidDataSource
druid:
driver-class-name: com.mysql.cj.jdbc.Driver
username: root
password: 123123
url: jdbc:mysql://10.211.55.12:3306/tcc_order?characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=false&useJDBCCompliantTimezoneShift=true&useLegacyDatetimeCode=false&serverTimezone=America/Los_Angeles&allowMultiQueries=true&allowPublicKeyRetrieval=true

mybatis-plus:
mapper-locations: classpath:/mapperxml/*Mapper.xml

seata:
application-id: ${spring.application.name}
# seata 服务分组,要与服务端配置service.vgroup_mapping的后缀对应
tx-service-group: default_tx_group
registry:
# 指定nacos作为注册中心
type: nacos
nacos:
application: seata-server
server-addr: 10.211.55.12:8848
namespace:
group: SEATA_GROUP

config:
# 指定nacos作为配置中心
type: nacos
nacos:
server-addr: 10.211.55.12:8848
namespace: e56de5d4-a37e-49c0-b613-f85a7f67cb36
group: SEATA_GROUP
data-id: seataServer.properties

注意:请确保client与server的注册中心和配置中心namespace和group一致。

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
package com.javaxing.order.controller;

import com.javaxing.order.service.BussinessService;
import com.javaxing.order.vo.OrderVO;
import com.javaxing.order.entity.OrderTbl;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import javax.annotation.Resource;

@RestController
@RequestMapping("/order")
public class OrderController {

@Resource
private BussinessService bussinessService;

/**
* 创建订单
* @param vo
*/
@PostMapping("/addOrder")
public OrderTbl addOrder(@RequestBody OrderVO vo){
return bussinessService.addOrder(vo);
}
}

entity

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
package com.javaxing.order.entity;

import lombok.Data;

import java.io.Serializable;
import java.math.BigDecimal;

@Data
public class OrderTbl implements Serializable {
private Long id;
private String userId;
private String commodityCode;
private Integer count;
private BigDecimal money;
private Integer status;
private static final long serialVersionUID = 1L;
}

service

BussinessService:

1
2
3
4
5
6
7
8
9
10
11
12
13
package com.javaxing.order.service;

import com.javaxing.order.entity.OrderTbl;
import com.javaxing.order.vo.OrderVO;

public interface BussinessService {

/**
* 创建订单
* @param vo
*/
OrderTbl addOrder(OrderVO vo);
}

OrderTblService:

TCC相关注解如下:

  • @LocalTCC 适用于SpringCloud+Feign模式下的TCC,@LocalTCC一定需要注解在接口上,此接口可以是寻常的业务接口,只要实现了TCC的两阶段提交对应方法便可
  • @TwoPhaseBusinessAction 注解try方法,其中name为当前tcc方法的bean名称,写方法名便可(全局唯一),commitMethod指向提交方法,rollbackMethod指向事务回滚方法
    • 指定好三个方法之后,seata会根据全局事务的成功或失败,去帮我们自动调用提交方法或者回滚方法。
  • @BusinessActionContextParameter 注解可以将参数传递到二阶段(commitMethod/rollbackMethod)的方法。
  • BusinessActionContext 便是指TCC事务上下文
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
package com.javaxing.order.service;

import com.javaxing.order.vo.OrderVO;
import com.javaxing.order.entity.OrderTbl;
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;

@LocalTCC
public interface OrderTblService{
/**
* TCC的try方法:保存订单信息,状态为待支付
*
* 定义两阶段提交,在try阶段通过@TwoPhaseBusinessAction注解定义了分支事务的 resourceId,commit和 cancel 方法
* name = 该tcc的bean名称,全局唯一
* commitMethod = commit 为二阶段确认方法
* rollbackMethod = rollback 为二阶段取消方法
* BusinessActionContextParameter注解 传递参数到二阶段中
* useTCCFence seata1.5.1的新特性,用于解决TCC幂等,悬挂,空回滚问题,需增加日志表tcc_fence_log
*/
@TwoPhaseBusinessAction(name = "prepareSaveOrder", commitMethod = "commit", rollbackMethod = "rollback", useTCCFence = true)
OrderTbl prepareSaveOrder(OrderVO vo,@BusinessActionContextParameter(paramName = "orderId") Long orderId);

/**
*
* TCC的confirm方法:订单状态改为支付成功
*
* 二阶段确认方法可以另命名,但要保证与commitMethod一致
* context可以传递try方法的参数
*
* @param actionContext
* @return
*/
boolean commit(BusinessActionContext actionContext);

/**
* TCC的cancel方法:订单状态改为支付失败
* 二阶段取消方法可以另命名,但要保证与rollbackMethod一致
*
* @param actionContext
* @return
*/
boolean rollback(BusinessActionContext actionContext);
}

accountFeignService 账户feign接口

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
package com.javaxing.order.feign;

import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;

import java.math.BigDecimal;

@FeignClient(name = "account",path = "/account")
public interface AccountFeignService {

/**
* 扣减账户余额
* @param userId
* @param money
*/
@GetMapping("/deduct")
public void deduct (@RequestParam("userId") String userId, @RequestParam("money") BigDecimal money);
}

stockFeignService 库存feign接口

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
package com.javaxing.order.feign;

import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;

@FeignClient(name = "stock",path = "/stock")
public interface StockFeignService {

/**
* 扣减库存
* @param commodityCode
* @param count
*/
@GetMapping("/deduct")
public void deduct (@RequestParam("commodityCode") String commodityCode,@RequestParam("count") Integer count);
}

serviceImpl

BusinessServiceImpl:

在全局事务发起者中添加@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
package com.javaxing.order.service.impl;

import com.javaxing.order.entity.OrderTbl;
import com.javaxing.order.feign.AccountFeignService;
import com.javaxing.order.feign.StockFeignService;
import com.javaxing.order.service.BussinessService;
import com.javaxing.order.service.OrderTblService;
import com.javaxing.order.vo.OrderVO;
import io.seata.core.context.RootContext;
import io.seata.spring.annotation.GlobalTransactional;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

@Service
@Slf4j
public class BusinessServiceImpl implements BussinessService {
@Autowired
private OrderTblService orderTblService;
@Autowired
private AccountFeignService accountFeignService;
@Autowired
private StockFeignService stockFeignService;

/**
* 创建订单
* @param vo
*/
@Override
@GlobalTransactional(name="addOrder",rollbackFor=Exception.class)
public OrderTbl addOrder(OrderVO vo) {
log.info("=============用户下单=================");
log.info("当前 XID: {}", RootContext.getXID());
// 创建订单
//获取全局唯一订单号 测试使用
Long orderId = System.currentTimeMillis();
OrderTbl order = orderTblService.prepareSaveOrder(vo,orderId);
// 扣减库存
stockFeignService.deduct(vo.getCommodityCode(),vo.getCount());
// 扣减余额
accountFeignService.deduct(vo.getUserId(),vo.getMoney());
return order;
}
}

OrderTblServiceImpl:

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
package com.javaxing.order.service.impl;
import java.util.List;

import com.javaxing.order.entity.OrderStatus;
import com.javaxing.order.feign.AccountFeignService;
import com.javaxing.order.feign.StockFeignService;
import com.javaxing.order.vo.OrderVO;
import io.seata.rm.tcc.api.BusinessActionContext;
import io.seata.rm.tcc.api.BusinessActionContextParameter;
import io.seata.spring.annotation.GlobalTransactional;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import javax.annotation.Resource;
import com.javaxing.order.entity.OrderTbl;
import com.javaxing.order.mapper.OrderTblMapper;
import com.javaxing.order.service.OrderTblService;
import org.springframework.transaction.annotation.Transactional;

@Service
@Slf4j
public class OrderTblServiceImpl implements OrderTblService{

@Resource
private OrderTblMapper orderTblMapper;

/**
* 新增订单记录
* @param vo
* @param orderId
* @return
*/
@Override
@Transactional(rollbackFor=Exception.class)
public OrderTbl prepareSaveOrder(OrderVO vo, @BusinessActionContextParameter(paramName = "orderId") Long orderId) {
OrderTbl orderTbl = new OrderTbl();
orderTbl.setId(orderId);
orderTbl.setUserId(vo.getUserId());
orderTbl.setCount(vo.getCount());
orderTbl.setMoney(vo.getMoney());
orderTbl.setStatus(OrderStatus.INIT.getValue());
orderTbl.setCommodityCode(vo.getCommodityCode());
int i = orderTblMapper.insertSelective(orderTbl);

log.info("保存订单{}", i > 0 ? "成功" : "失败");
return orderTbl;
}

@Override
public boolean commit(BusinessActionContext actionContext) {
orderTblMapper.updateStatusById(OrderStatus.SUCCESS.getValue(),Long.parseLong(actionContext.getActionContext("orderId").toString()));
log.info("commit成功,订单状态为成功");
return true;
}

@Override
public boolean rollback(BusinessActionContext actionContext) {
orderTblMapper.updateStatusById(OrderStatus.FAIL.getValue(),Long.parseLong(actionContext.getActionContext("orderId").toString()));
log.info("回滚成功,订单状态为失败");
return true;
}
}

值得注意的是,切勿在impl实现类里面调用本类的service接口来实现TCC,否则不会生效,如:在orderImpl实现类里面注入orderService调用TCC的try和commit、rollback。

3)账户微服务

image-20230621155733628

account-pom.xml

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
<?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>
<parent>
<groupId>com.javaxing</groupId>
<artifactId>spring-cloud-seata-demo</artifactId>
<version>1.0-SNAPSHOT</version>
</parent>

<artifactId>spring-cloud-seata-account</artifactId>
<packaging>pom</packaging>
<modules>
<module>spring-cloud-seata-account-api</module>
<module>spring-cloud-seata-account-biz</module>

</modules>

<properties>
<maven.compiler.source>8</maven.compiler.source>
<maven.compiler.target>8</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>

</project>

mysql数据库

账户表

1
2
3
4
5
6
CREATE TABLE `account_tbl` (
`id` int NOT NULL AUTO_INCREMENT,
`user_id` varchar(255) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci DEFAULT NULL,
`money` decimal(10,2) DEFAULT '0.00',
PRIMARY KEY (`id`) USING BTREE
) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8mb3;

TccFenceLog表

TCC 模式中存在的三大问题是幂等、悬挂和空回滚。在 Seata1.5.1 版本中,增加了一张事务控制表,表名是 tcc_fence_log 来解决这个问题。在@TwoPhaseBusinessAction 注解中提到的属性 useTCCFence 就是来指定是否开启这个机制,这个属性值默认是 false。

1
2
3
4
5
6
7
8
9
10
11
CREATE TABLE `tcc_fence_log` (
`xid` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT 'global id',
`branch_id` bigint NOT NULL COMMENT 'branch id',
`action_name` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT 'action name',
`status` tinyint NOT NULL COMMENT 'status(tried:1;committed:2;rollbacked:3;suspended:4)',
`gmt_create` datetime(3) NOT NULL COMMENT 'create time',
`gmt_modified` datetime(3) NOT NULL COMMENT 'update time',
PRIMARY KEY (`xid`,`branch_id`) USING BTREE,
KEY `idx_gmt_modified` (`gmt_modified`) USING BTREE,
KEY `idx_status` (`status`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;

注意:未避免幂等、悬挂、空回滚,在每个微服务中,都要有一张这样的表,因为每个微服务都会涉及到try,commit,rollback。

3.1 account-api

pom.xml

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<?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>
<parent>
<groupId>com.javaxing</groupId>
<artifactId>spring-cloud-seata-account</artifactId>
<version>1.0-SNAPSHOT</version>
</parent>

<artifactId>spring-cloud-seata-account-api</artifactId>

<properties>
<maven.compiler.source>8</maven.compiler.source>
<maven.compiler.target>8</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>

</project>

3.2 account-biz

pom.xml

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
<?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 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>com.javaxing</groupId>
<artifactId>spring-cloud-seata-account</artifactId>
<version>1.0-SNAPSHOT</version>
</parent>

<groupId>com.javaxing</groupId>
<artifactId>spring-cloud-seata-account-biz</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>spring-cloud-seata-account-biz</name>
<description>spring-cloud-seata-account-biz</description>
<properties>
<java.version>1.8</java.version>
</properties>

<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>

微服务bootstrap.yml中添加seata配置

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
server:
port: 8001
spring:
datasource:
type: com.alibaba.druid.pool.DruidDataSource
druid:
driver-class-name: com.mysql.cj.jdbc.Driver
username: root
password: 123123
url: jdbc:mysql://10.211.55.12:3306/tcc_account?characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=false&useJDBCCompliantTimezoneShift=true&useLegacyDatetimeCode=false&serverTimezone=America/Los_Angeles&allowMultiQueries=true&allowPublicKeyRetrieval=true

application:
name: account
cloud:
nacos:
discovery:
server-addr: 10.211.55.12:8848
# 配置中心IP
config:
server-addr: 10.211.55.12:8848
# 读取以yaml结尾的配置文件,那就是nacos-config.yaml
file-extension: yaml

mybatis-plus:
mapper-locations: classpath:/mapperxml/*Mapper.xml

seata:
application-id: ${spring.application.name}
# seata 服务分组,要与服务端配置service.vgroup_mapping的后缀对应
tx-service-group: default_tx_group
registry:
# 指定nacos作为注册中心
type: nacos
nacos:
application: seata-server
server-addr: 10.211.55.12:8848
namespace:
group: SEATA_GROUP

config:
# 指定nacos作为配置中心
type: nacos
nacos:
server-addr: 10.211.55.12:8848
namespace: e56de5d4-a37e-49c0-b613-f85a7f67cb36
group: SEATA_GROUP
data-id: seataServer.properties

注意:请确保client与server的注册中心和配置中心namespace和group一致。

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
package com.javaxing.account.controller;

import com.javaxing.account.service.AccountTblService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

import java.math.BigDecimal;

@RestController
@RequestMapping("/account")
public class accountController {
@Autowired
private AccountTblService accountTblService;
/**
* 扣减账户余额
* @param userId
* @param money
*/
@GetMapping("/deduct")
public void deduct (@RequestParam("userId") String userId,@RequestParam("money") BigDecimal money){
accountTblService.deduct(userId,money);
}
}

entity

1
2
3
4
5
6
7
8
9
10
11
12
13
14
package com.javaxing.account.entity;

import lombok.Data;

import java.io.Serializable;
import java.math.BigDecimal;

@Data
public class AccountTbl implements Serializable {
private Integer id;
private String userId;
private BigDecimal money;
private static final long serialVersionUID = 1L;
}

service

AccountTblService:

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
package com.javaxing.account.service;

import com.javaxing.account.entity.AccountTbl;
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;

import java.math.BigDecimal;

@LocalTCC
public interface AccountTblService{
/**
* 扣减账户余额
* @param userId
* @param money
*/
@TwoPhaseBusinessAction(name = "deduct", commitMethod = "commit", rollbackMethod = "rollback", useTCCFence = true)
public void deduct(@BusinessActionContextParameter(paramName = "userId") String userId,
@BusinessActionContextParameter(paramName = "money")BigDecimal money);

/**
*
* Confirm: 提交
* 二阶段确认方法可以另命名,但要保证与commitMethod一致
* context可以传递try方法的参数
*
* @param actionContext
* @return
*/
boolean commit(BusinessActionContext actionContext);

/**
* Cancel: 扣减余额
* 二阶段取消方法可以另命名,但要保证与rollbackMethod一致
*
* @param actionContext
* @return
*/
boolean rollback(BusinessActionContext actionContext);
}

serviceImpl

AccountTblServiceImpl:

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
package com.javaxing.account.service.impl;

import io.seata.core.context.RootContext;
import io.seata.rm.tcc.api.BusinessActionContext;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import javax.annotation.Resource;
import com.javaxing.account.entity.AccountTbl;
import com.javaxing.account.mapper.AccountTblMapper;
import com.javaxing.account.service.AccountTblService;
import org.springframework.transaction.annotation.Transactional;

import java.math.BigDecimal;

@Service
@Slf4j
public class AccountTblServiceImpl implements AccountTblService{

@Resource
private AccountTblMapper accountTblMapper;

@Override
@Transactional
public void deduct(String userId, BigDecimal money) {
log.info("当前 XID: {}", RootContext.getXID());

// 判断余额是否足够扣减
AccountTbl accountTbl = accountTblMapper.findFirstByUserId(userId);
if (money.compareTo(accountTbl.getMoney()) == 1) {
// 超出余额,抛出异常
throw new RuntimeException("余额不足");
}

log.info("余额扣减成功,扣减余额:{},用户ID:{}", money, accountTbl.getUserId());
// 开始扣减余额
accountTblMapper.deductBalance(userId, money);
log.info("余额扣减成功,剩余余额:{},用户ID:{}", accountTbl.getMoney().subtract(money), accountTbl.getUserId());
}

@Override
public boolean commit(BusinessActionContext actionContext) {
log.info("commit成功");
return true;
}

@Override
public boolean rollback(BusinessActionContext actionContext) {
// 回滚余额
String userId = actionContext.getActionContext("userId").toString();
BigDecimal money = new BigDecimal(actionContext.getActionContext("money").toString());

accountTblMapper.addBalance(userId, money);
log.info("余额回滚成功");
return true;
}
}

值得注意的是,切勿在impl实现类里面调用本类的service接口来实现TCC,否则不会生效,如:在orderImpl实现类里面注入orderService调用TCC的try和commit、rollback。

mapper

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
package com.javaxing.account.mapper;
import org.apache.ibatis.annotations.Param;

import com.javaxing.account.entity.AccountTbl;
import org.apache.ibatis.annotations.Mapper;

import java.math.BigDecimal;

@Mapper
public interface AccountTblMapper {
AccountTbl findFirstByUserId(@Param("userId")String userId);

int deductBalance(@Param("userId") String userId, @Param("money") BigDecimal money);

int addBalance(@Param("userId") String userId, @Param("money") BigDecimal money);
}

mapper.xml

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
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.javaxing.account.mapper.AccountTblMapper">
<resultMap id="BaseResultMap" type="com.javaxing.account.entity.AccountTbl">
<!--@mbg.generated-->
<!--@Table account_tbl-->
<id column="id" jdbcType="INTEGER" property="id" />
<result column="user_id" jdbcType="VARCHAR" property="userId" />
<result column="money" jdbcType="DECIMAL" property="money" />
</resultMap>
<sql id="Base_Column_List">
<!--@mbg.generated-->
id, user_id, money
</sql>

<!--creation by Eason on 2023-06-20-->
<select id="findFirstByUserId" resultMap="BaseResultMap">
select
<include refid="Base_Column_List"/>
from account_tbl
where user_id=#{userId,jdbcType=VARCHAR} limit 1
</select>


<update id="deductBalance">
update account_tbl
set money = money - #{money,jdbcType=DECIMAL}
where user_id = #{userId,jdbcType=VARCHAR}
</update>

<update id="addBalance">
update account_tbl
set money = money + #{money,jdbcType=DECIMAL}
where user_id = #{userId,jdbcType=VARCHAR}
</update>
</mapper>

4)库存微服务

image-20230621160831701

stock-pom.xml

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
<?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>
<parent>
<groupId>com.javaxing</groupId>
<artifactId>spring-cloud-seata-demo</artifactId>
<version>1.0-SNAPSHOT</version>
</parent>

<artifactId>spring-cloud-seata-stock</artifactId>
<packaging>pom</packaging>
<modules>
<module>spring-cloud-seata-stock-api</module>
<module>spring-cloud-seata-stock-biz</module>
</modules>

<properties>
<maven.compiler.source>8</maven.compiler.source>
<maven.compiler.target>8</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>

</project>

mysql数据库

库存表

1
2
3
4
5
6
7
8
9
CREATE TABLE `stock_tbl` (
`id` int NOT NULL AUTO_INCREMENT,
`commodity_code` varchar(255) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci DEFAULT NULL,
`count` int DEFAULT '0',
PRIMARY KEY (`id`) USING BTREE,
UNIQUE KEY `commodity_code` (`commodity_code`) USING BTREE
) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8mb3;

INSERT INTO `tcc_stock`.`stock_tbl` (`id`, `commodity_code`, `count`) VALUES (1, '20230101', 100);

TccFenceLog表

TCC 模式中存在的三大问题是幂等、悬挂和空回滚。在 Seata1.5.1 版本中,增加了一张事务控制表,表名是 tcc_fence_log 来解决这个问题。在@TwoPhaseBusinessAction 注解中提到的属性 useTCCFence 就是来指定是否开启这个机制,这个属性值默认是 false。

1
2
3
4
5
6
7
8
9
10
11
CREATE TABLE `tcc_fence_log` (
`xid` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT 'global id',
`branch_id` bigint NOT NULL COMMENT 'branch id',
`action_name` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT 'action name',
`status` tinyint NOT NULL COMMENT 'status(tried:1;committed:2;rollbacked:3;suspended:4)',
`gmt_create` datetime(3) NOT NULL COMMENT 'create time',
`gmt_modified` datetime(3) NOT NULL COMMENT 'update time',
PRIMARY KEY (`xid`,`branch_id`) USING BTREE,
KEY `idx_gmt_modified` (`gmt_modified`) USING BTREE,
KEY `idx_status` (`status`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;

注意:未避免幂等、悬挂、空回滚,在每个微服务中,都要有一张这样的表,因为每个微服务都会涉及到try,commit,rollback。

4.1 stock-api

pom.xml

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<?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>
<parent>
<groupId>com.javaxing</groupId>
<artifactId>spring-cloud-seata-stock</artifactId>
<version>1.0-SNAPSHOT</version>
</parent>

<artifactId>spring-cloud-seata-stock-api</artifactId>

<properties>
<maven.compiler.source>8</maven.compiler.source>
<maven.compiler.target>8</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>

</project>

4.2 stock-biz

pom.xml

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
<?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 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>com.javaxing</groupId>
<artifactId>spring-cloud-seata-stock</artifactId>
<version>1.0-SNAPSHOT</version>
</parent>

<groupId>com.javaxing</groupId>
<artifactId>spring-cloud-seata-stock-biz</artifactId>
<version>0.0.1-SNAPSHOT</version>
<properties>
<java.version>1.8</java.version>
</properties>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>

微服务bootstrap.yml中添加seata配置

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
server:
port: 8003
spring:
application:
name: stock
cloud:
nacos:
discovery:
server-addr: 10.211.55.12:8848
# ????IP
config:
server-addr: 10.211.55.12:8848
# ???yaml???????????nacos-config.yaml
file-extension: yaml
datasource:
type: com.alibaba.druid.pool.DruidDataSource
druid:
driver-class-name: com.mysql.cj.jdbc.Driver
username: root
password: 123123
url: jdbc:mysql://10.211.55.12:3306/tcc_stock?characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=false&useJDBCCompliantTimezoneShift=true&useLegacyDatetimeCode=false&serverTimezone=America/Los_Angeles&allowMultiQueries=true&allowPublicKeyRetrieval=true

mybatis-plus:
mapper-locations: classpath:/mapperxml/*Mapper.xml

seata:
application-id: ${spring.application.name}
# seata 服务分组,要与服务端配置service.vgroup_mapping的后缀对应
tx-service-group: default_tx_group
registry:
# 指定nacos作为注册中心
type: nacos
nacos:
application: seata-server
server-addr: 10.211.55.12:8848
namespace:
group: SEATA_GROUP

config:
# 指定nacos作为配置中心
type: nacos
nacos:
server-addr: 10.211.55.12:8848
namespace: e56de5d4-a37e-49c0-b613-f85a7f67cb36
group: SEATA_GROUP
data-id: seataServer.properties

注意:请确保client与server的注册中心和配置中心namespace和group一致。

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
package com.javaxing.stock.controller;

import com.javaxing.stock.service.StockTblService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/stock")
public class StockController {

@Autowired
private StockTblService stockTblService;


/**
* 扣减库存
* @param commodityCode
* @param count
*/
@GetMapping("/deduct")
public void deduct (@RequestParam("commodityCode") String commodityCode,@RequestParam("count") Integer count){
stockTblService.deduct(commodityCode,count);
}
}

entity

1
2
3
4
5
6
7
8
9
10
11
12
13
package com.javaxing.stock.entity;

import lombok.Data;

import java.io.Serializable;

@Data
public class StockTbl implements Serializable {
private Integer id;
private String commodityCode;
private Integer count;
private static final long serialVersionUID = 1L;
}

service

StockTblService:

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
package com.javaxing.stock.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;

@LocalTCC
public interface StockTblService{
/**
* Try: 库存-扣减数量
*
* 定义两阶段提交,在try阶段通过@TwoPhaseBusinessAction注解定义了分支事务的 resourceId,commit和 cancel 方法
* name = 该tcc的bean名称,全局唯一
* commitMethod = commit 为二阶段确认方法
* rollbackMethod = rollback 为二阶段取消方法
* BusinessActionContextParameter注解 传递参数到二阶段中
*
* @param commodityCode 商品编号
* @param count 扣减数量
* @return
*/
@TwoPhaseBusinessAction(name = "deduct", commitMethod = "commit", rollbackMethod = "rollback", useTCCFence = true)
void deduct(@BusinessActionContextParameter(paramName = "commodityCode") String commodityCode,
@BusinessActionContextParameter(paramName = "count") int count);

/**
*
* Confirm: 返回成功
* 二阶段确认方法可以另命名,但要保证与commitMethod一致
* context可以传递try方法的参数
*
* @param actionContext
* @return
*/
boolean commit(BusinessActionContext actionContext);

/**
* Cancel: 库存+扣减数量
* 二阶段取消方法可以另命名,但要保证与rollbackMethod一致
*
* @param actionContext
* @return
*/
boolean rollback(BusinessActionContext actionContext);
}

serviceImpl

StockTblServiceImpl:

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
package com.javaxing.stock.service.impl;

import io.seata.core.context.RootContext;
import io.seata.rm.tcc.api.BusinessActionContext;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import javax.annotation.Resource;
import com.javaxing.stock.entity.StockTbl;
import com.javaxing.stock.mapper.StockTblMapper;
import com.javaxing.stock.service.StockTblService;
import org.springframework.transaction.annotation.Transactional;

@Service
@Slf4j
public class StockTblServiceImpl implements StockTblService{
@Resource
private StockTblMapper stockTblMapper;
/**
* Try: 库存-扣减数量
*
* 定义两阶段提交,在try阶段通过@TwoPhaseBusinessAction注解定义了分支事务的 resourceId,commit和 cancel 方法
* name = 该tcc的bean名称,全局唯一
* commitMethod = commit 为二阶段确认方法
* rollbackMethod = rollback 为二阶段取消方法
* BusinessActionContextParameter注解 传递参数到二阶段中
*
* @param commodityCode 商品编号
* @param count 扣减数量
* @return
*/

@Transactional
@Override
public void deduct(String commodityCode, int count) {
log.info("当前 XID: {}", RootContext.getXID());

// 检查库存是否足够扣减,如果不够的话就抛出异常
StockTbl stockTbl = stockTblMapper.findFirstByCommodityCode(commodityCode);
if(stockTbl.getCount() < count){
throw new RuntimeException("库存不足");
}

// 开始扣减库存
stockTblMapper.deductCount(commodityCode,count);
log.info("库存扣减成功,产品编号:{},扣减数量:{}",commodityCode,count);
}

@Override
public boolean commit(BusinessActionContext actionContext) {
log.info("库存commit成功");
return true;
}

@Override
public boolean rollback(BusinessActionContext actionContext) {
String commodityCode = actionContext.getActionContext("commodityCode").toString();
int count = (int) actionContext.getActionContext("count");

// 回滚库存数量
stockTblMapper.addCount(commodityCode,count);
log.info("库存回滚成功");
return true;
}
}

值得注意的是,切勿在impl实现类里面调用本类的service接口来实现TCC,否则不会生效,如:在orderImpl实现类里面注入orderService调用TCC的try和commit、rollback。

mapper

1
2
3
4
5
6
7
8
9
10
11
12
13
14
package com.javaxing.stock.mapper;
import org.apache.ibatis.annotations.Param;

import com.javaxing.stock.entity.StockTbl;
import org.apache.ibatis.annotations.Mapper;

@Mapper
public interface StockTblMapper {
StockTbl findFirstByCommodityCode(@Param("commodityCode")String commodityCode);

int deductCount(@Param("commodityCode") String commodityCode, @Param("count") Integer count);

int addCount(@Param("commodityCode") String commodityCode, @Param("count") Integer count);
}

mapper.xml

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
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.javaxing.stock.mapper.StockTblMapper">
<resultMap id="BaseResultMap" type="com.javaxing.stock.entity.StockTbl">
<!--@mbg.generated-->
<!--@Table stock_tbl-->
<id column="id" jdbcType="INTEGER" property="id" />
<result column="commodity_code" jdbcType="VARCHAR" property="commodityCode" />
<result column="count" jdbcType="INTEGER" property="count" />
</resultMap>
<sql id="Base_Column_List">
<!--@mbg.generated-->
id, commodity_code, `count`
</sql>

<!--creation by Eason on 2023-06-20-->
<select id="findFirstByCommodityCode" resultMap="BaseResultMap">
select
<include refid="Base_Column_List"/>
from stock_tbl
where commodity_code=#{commodityCode,jdbcType=VARCHAR} limit 1
</select>


<update id="deductCount">
update stock_tbl
set `count` = count - #{count,jdbcType=INTEGER}
where commodity_code = #{commodityCode,jdbcType=VARCHAR}
</update>


<update id="addCount">
update stock_tbl
set `count` = count + #{count,jdbcType=INTEGER}
where commodity_code = #{commodityCode,jdbcType=VARCHAR}
</update>
</mapper>

5)模拟成功实验

TCC模式

TCC执行流程

  1. 订单微服务(TM&RM)向TC注册全局事务,TC返回XID给TM,而账户、库存微服务(RM)向TC注册本地事务,TC返回BID给RM。
  2. 一阶段
    1. 订单微服务 在一阶段try 创建订单,并通过feign 调用库存、账户服务 ,在一阶段try 扣减库存和账户余额。
  3. 二阶段
    1. TM如果检测到RM 在一阶段抛出异常的话,TM就会告知TC,由TC驱动所有RM对本地事务进行rollback
    2. TM如果检测到RM 在一阶段都try成功,TM就会告知TC,由TC驱动所有RM对本地事务进行commit

实验开始之前,我们先来看一下数据库表数据:

image-20230621165154277image-20230621165208225image-20230621165234335

账户表余额:100,库存表:100,订单表:无。

5.1 访问addOrder方法

image-20230621165423051

通过接口下单时,扣除库存1,金额10,库存和金额都校验成功,扣减成功。

5.2 微服务日志

订单微服务

image-20230621165748985

账户微服务

image-20230621165650976

库存微服务

image-20230621165711232

5.3 查看执行后的数据库数据

image-20230621165913559image-20230621165922600image-20230621165933032

6)模拟失败实验

实验思路

我们此时余额只有90,但是我们一阶段try却强行扣除1000余额,账户微服务自然会抛出异常-余额不足,此时我们订单服务已经创建订单,status = 0 ,库存服务已经扣去了相应的库存。而TM在二阶段时检测到一阶段有RM执行异常了,就会通知TC 告知所有RM对本地事务进行回滚。

执行方法

image-20230623114554275

订单服务

try成功了,库存服务也try成功,但 账户服务try失败了,所以TM发现有RM一阶段异常了,TM告知TC,由TC驱动所有RM,订单服务和库存服务进行rollback,而账户服务try失败了,直接本地事务回滚了,不需要再走rollback。

image-20230623114700176

库存服务

image-20230623114816229

账户服务

image-20230623115124574

数据库结果

image-20230623115209430image-20230623115155647image-20230623115247210

账户余额依旧是90,库存也是99,而订单我们在rollback环节,并没有删除订单,只是把status改成了-1 ,综上所述,实验圆满结束。