本文用经典的 下单扣库存 场景演示 Seata AT 模式的完整用法:order-service(8083,TM 事务发起方)插入订单后通过 Feign 调用 stock-service(8084,RM 分支事务)扣减库存,两个服务的数据库分别维护一份业务表和一张 undo_log。Seata Server(8091,TC)负责协调全局事务:成功路径两库一起提交,失败路径两库同时回滚。

Step 1 · Seata Server 启动

请参考文章 Seata 2.6.0 安装教程(MySQL 8.4.0 + Nacos 3.0.3)

Step 2 · 建业务库和 undo_log

保存为 init-seata.sql 并执行(两个业务库各有一张业务表 + 一张 undo_log,undo_log 是 AT 模式回滚的命根子):

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
CREATE DATABASE IF NOT EXISTS seata_order DEFAULT CHARACTER SET utf8mb4;
USE seata_order;

CREATE TABLE IF NOT EXISTS orders (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
user_id BIGINT NOT NULL,
product_id BIGINT NOT NULL,
count INT NOT NULL,
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4;

CREATE TABLE IF NOT EXISTS undo_log (
branch_id BIGINT NOT NULL,
xid VARCHAR(128) NOT NULL,
context VARCHAR(128) NOT NULL,
rollback_info LONGBLOB NOT NULL,
log_status INT NOT NULL,
log_created DATETIME(6) NOT NULL,
log_modified DATETIME(6) NOT NULL,
UNIQUE KEY ux_undo_log (xid, branch_id)
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4;

CREATE DATABASE IF NOT EXISTS seata_stock DEFAULT CHARACTER SET utf8mb4;
USE seata_stock;

CREATE TABLE IF NOT EXISTS stock (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
product_id BIGINT UNIQUE NOT NULL,
quantity INT NOT NULL
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4;

CREATE TABLE IF NOT EXISTS undo_log (
branch_id BIGINT NOT NULL,
xid VARCHAR(128) NOT NULL,
context VARCHAR(128) NOT NULL,
rollback_info LONGBLOB NOT NULL,
log_status INT NOT NULL,
log_created DATETIME(6) NOT NULL,
log_modified DATETIME(6) NOT NULL,
UNIQUE KEY ux_undo_log (xid, branch_id)
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4;

INSERT INTO stock (product_id, quantity) VALUES (1, 100);

执行建表脚本:

1
mysql -u root < init-seata.sql

Step 3 · 创建 stock-service(8084)

1
2
mkdir -p ~/codexwork/sca-practice/stock-service
cd ~/codexwork/sca-practice/stock-service

pom.xml(双 BOM + web/jdbc/mysql/nacos/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
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.5.16</version>
<relativePath/>
</parent>
<groupId>com.example</groupId>
<artifactId>stock-service</artifactId>
<version>0.0.1-SNAPSHOT</version>
<properties>
<java.version>21</java.version>
</properties>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-dependencies</artifactId>
<version>2025.0.0</version>
<type>pom</type>
<scope>import</scope>
</dependency>
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-alibaba-dependencies</artifactId>
<version>2025.0.0.0</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId>
</dependency>
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-seata</artifactId>
</dependency>
<dependency>
<groupId>org.apache.seata</groupId>
<artifactId>seata-spring-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:

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
server:
port: 8084

spring:
application:
name: stock-service
cloud:
nacos:
discovery:
server-addr: 127.0.0.1:8848
datasource:
url: jdbc:mysql://127.0.0.1:3306/seata_stock?useSSL=false&characterEncoding=utf8&serverTimezone=Asia/Shanghai
username: root
password: ""
driver-class-name: com.mysql.cj.jdbc.Driver

seata:
tx-service-group: seata_tx_group # SCA 2025.x 起改用 Seata 原生键,spring.cloud.alibaba.seata.tx-service-group 已废弃
registry:
type: nacos
nacos:
application: seata-server
server-addr: 127.0.0.1:8848
group: SEATA_GROUP
cluster: default
username: nacos
password: nacos
service:
vgroup-mapping:
seata_tx_group: default

主类 + 控制器:

1
2
3
4
5
6
7
8
9
10
11
12
// StockApplication.java
package com.example.stock;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class StockApplication {
public static void main(String[] args) {
SpringApplication.run(StockApplication.class, args);
}
}
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
// StockController.java
package com.example.stock;

import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

import java.util.Map;

@RestController
public class StockController {

private final JdbcTemplate jdbcTemplate;

public StockController(JdbcTemplate jdbcTemplate) {
this.jdbcTemplate = jdbcTemplate;
}

@GetMapping("/stock/deduct")
public String deduct(@RequestParam Long productId,
@RequestParam Integer count) {
int updated = jdbcTemplate.update(
"update stock set quantity = quantity - ? where product_id = ? and quantity >= ?",
count, productId, count);
if (updated == 0) {
throw new RuntimeException("库存不足");
}
return "扣减成功";
}

@GetMapping("/stock/query")
public Map<String, Object> query(@RequestParam Long productId) {
return jdbcTemplate.queryForMap(
"select product_id, quantity from stock where product_id = ?", productId);
}
}

Step 4 · 创建 order-service(8083)

1
2
mkdir -p ~/codexwork/sca-practice/order-service
cd ~/codexwork/sca-practice/order-service

pom.xml 与 stock-service 相同,另加 OpenFeign 与 LoadBalancer(版本由 BOM 管理,不写版本号):

1
2
3
4
5
6
7
8
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-openfeign</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-loadbalancer</artifactId>
</dependency>

application.yml 与 stock-service 几乎一样,只改:端口 8083、应用名 order-service、数据源指向 seata_order。tx-service-group 必须与 stock-service 一致(都是 seata_tx_group)。

主类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// OrderApplication.java
package com.example.order;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cloud.openfeign.EnableFeignClients;

@EnableFeignClients
@SpringBootApplication
public class OrderApplication {
public static void main(String[] args) {
SpringApplication.run(OrderApplication.class, args);
}
}

Feign 客户端(调 stock-service):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// StockClient.java
package com.example.order;

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

@FeignClient(name = "stock-service")
public interface StockClient {

@GetMapping("/stock/deduct")
String deduct(@RequestParam("productId") Long productId,
@RequestParam("count") Integer count);
}

核心:带 @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
// OrderService.java
package com.example.order;

import org.apache.seata.spring.annotation.GlobalTransactional;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;

@Service
public class OrderService {

private final JdbcTemplate jdbcTemplate;
private final StockClient stockClient;

public OrderService(JdbcTemplate jdbcTemplate, StockClient stockClient) {
this.jdbcTemplate = jdbcTemplate;
this.stockClient = stockClient;
}

@GlobalTransactional(rollbackFor = Exception.class)
public String createOrder(Long userId, Long productId, Integer count, boolean fail) {
// 1) 本库插订单
jdbcTemplate.update(
"insert into orders (user_id, product_id, count) values (?, ?, ?)",
userId, productId, count);

// 2) 跨服务扣库存(XID 由 Seata 自动通过 Feign 传递)
String stockResult = stockClient.deduct(productId, count);

// 3) 模拟业务失败:订单和库存都要回滚
if (fail) {
throw new RuntimeException("模拟下单失败,触发全局回滚");
}
return "下单成功:" + stockResult;
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// OrderController.java
package com.example.order;

import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class OrderController {

private final OrderService orderService;

public OrderController(OrderService orderService) {
this.orderService = orderService;
}

@GetMapping("/order/create")
public String create(@RequestParam Long userId,
@RequestParam Long productId,
@RequestParam Integer count,
@RequestParam(defaultValue = "false") boolean fail) {
return orderService.createOrder(userId, productId, count, fail);
}
}

启动顺序:Seata Server → stock-service → order-service(三个都起来后,Nacos 服务列表能看到 order-service、stock-service,SEATA_GROUP 分组下能看到 seata-server)。

Step 5 · 成功路径验证

1
2
curl "http://localhost:8083/order/create?userId=1&productId=1&count=1"
mysql -u root -e "SELECT * FROM seata_order.orders; SELECT product_id, quantity FROM seata_stock.stock;"

预期:返回 下单成功:扣减成功;orders 表有 userId=1 的一行,stock 表 quantity 变成 99。两个库各写各的,但一起成功了。

Step 6 · 失败回滚验证(重点)

1
2
curl "http://localhost:8083/order/create?userId=2&productId=1&count=1&fail=true"
mysql -u root -e "SELECT * FROM seata_order.orders; SELECT product_id, quantity FROM seata_stock.stock;"

预期:接口报 500(模拟下单失败);但 orders 表没有 userId=2 的行,stock 还是 99——订单插入被回滚,库存的扣减也被补偿回去了。两个分支要么全成,要么全回。

想看细节,在失败瞬间查 undo_log(回滚完成后会被清空,要抢在回滚前看,或把日志级别调成 debug):

1
mysql -u root -e "SELECT xid, branch_id, log_status FROM seata_order.undo_log; SELECT xid, branch_id, log_status FROM seata_stock.undo_log;"

rollback_info 里就是改前镜像 + 改后镜像,Seata 靠它把数据改回去。

Step 7 · 对比实验:去掉 @GlobalTransactional

OrderService.createOrder 上的 @GlobalTransactional 注释掉,重启 order-service,再跑一次失败的请求:

1
2
curl "http://localhost:8083/order/create?userId=3&productId=1&count=1&fail=true"
mysql -u root -e "SELECT * FROM seata_order.orders; SELECT product_id, quantity FROM seata_stock.stock;"

预期:接口同样报错,但 orders 表多了 userId=3 的行、stock 变成 97——接口告诉用户失败了,数据却悄悄写进去了。这就是没有分布式事务时的部分成功,正是本课要消灭的东西。实验完把注解加回来。

补充:undo_log 表内容分析

下面这条记录来自 seata_stock.undo_log,是扣减库存时 Seata 写入的一行真实数据。

原文

1
6440958251277541397	192.168.0.107:8091:6440958251277541393	serializer=jackson&compressorType=NONE&map=67108864	{"@class":"org.apache.seata.rm.datasource.undo.BranchUndoLog","xid":"192.168.0.107:8091:6440958251277541393","branchId":6440958251277541397,"sqlUndoLogs":["java.util.ArrayList",[{"@class":"org.apache.seata.rm.datasource.undo.SQLUndoLog","sqlType":"UPDATE","tableName":"stock","beforeImage":{"@class":"org.apache.seata.rm.datasource.sql.struct.TableRecords","tableName":"stock","rows":["java.util.ArrayList",[{"@class":"org.apache.seata.rm.datasource.sql.struct.Row","fields":["java.util.ArrayList",[{"@class":"org.apache.seata.rm.datasource.sql.struct.Field","name":"id","keyType":"PRIMARY_KEY","type":-5,"value":["java.lang.Long",1]},{"@class":"org.apache.seata.rm.datasource.sql.struct.Field","name":"quantity","keyType":"NULL","type":4,"value":98}]]}]]},"afterImage":{"@class":"org.apache.seata.rm.datasource.sql.struct.TableRecords","tableName":"stock","rows":["java.util.ArrayList",[{"@class":"org.apache.seata.rm.datasource.sql.struct.Row","fields":["java.util.ArrayList",[{"@class":"org.apache.seata.rm.datasource.sql.struct.Field","name":"id","keyType":"PRIMARY_KEY","type":-5,"value":["java.lang.Long",1]},{"@class":"org.apache.seata.rm.datasource.sql.struct.Field","name":"quantity","keyType":"NULL","type":4,"value":97}]]}]]}}]]}	0	2026-08-11 02:02:20.598169	2026-08-11 02:02:20.598169

这是 undo_log 表的一整行(Tab 分隔),各列依次为:branch_idxidcontextrollback_infolog_statuslog_createdlog_modified。核心在 rollback_info,它是 Jackson 序列化后的 JSON。

格式化内容

rollback_info 列格式化后如下:

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
{
"@class": "org.apache.seata.rm.datasource.undo.BranchUndoLog",
"xid": "192.168.0.107:8091:6440958251277541393",
"branchId": 6440958251277541397,
"sqlUndoLogs": [
"java.util.ArrayList",
[
{
"@class": "org.apache.seata.rm.datasource.undo.SQLUndoLog",
"sqlType": "UPDATE",
"tableName": "stock",
"beforeImage": {
"@class": "org.apache.seata.rm.datasource.sql.struct.TableRecords",
"tableName": "stock",
"rows": [
"java.util.ArrayList",
[
{
"@class": "org.apache.seata.rm.datasource.sql.struct.Row",
"fields": [
"java.util.ArrayList",
[
{
"@class": "org.apache.seata.rm.datasource.sql.struct.Field",
"name": "id",
"keyType": "PRIMARY_KEY",
"type": -5,
"value": [
"java.lang.Long",
1
]
},
{
"@class": "org.apache.seata.rm.datasource.sql.struct.Field",
"name": "quantity",
"keyType": "NULL",
"type": 4,
"value": 98
}
]
]
}
]
]
},
"afterImage": {
"@class": "org.apache.seata.rm.datasource.sql.struct.TableRecords",
"tableName": "stock",
"rows": [
"java.util.ArrayList",
[
{
"@class": "org.apache.seata.rm.datasource.sql.struct.Row",
"fields": [
"java.util.ArrayList",
[
{
"@class": "org.apache.seata.rm.datasource.sql.struct.Field",
"name": "id",
"keyType": "PRIMARY_KEY",
"type": -5,
"value": [
"java.lang.Long",
1
]
},
{
"@class": "org.apache.seata.rm.datasource.sql.struct.Field",
"name": "quantity",
"keyType": "NULL",
"type": 4,
"value": 97
}
]
]
}
]
]
}
}
]
]
}

内容解析

先看表结构各列:

内容 说明
branch_id 6440958251277541397 分支事务 ID,与 xid 组成唯一键(ux_undo_log
xid 192.168.0.107:8091:6440958251277541393 全局事务 ID,格式为 {IP}:{端口}:{transactionId}
context serializer=jackson&compressorType=NONE&map=67108864 序列化器为 Jackson、未压缩、附带 Map 标志位
rollback_info JSON(见上) 改前镜像 + 改后镜像,回滚的依据
log_status 0 0=正常(等待二阶段结果);1=已回滚完成
log_created / log_modified 2026-08-11 02:02:20.598169 记录创建/修改时间

再拆解 rollback_info 的 JSON 结构:

  • BranchUndoLog:一条分支事务的根对象,包含 xidbranchIdsqlUndoLogs 列表。一次分支事务里可能有多条 SQL,这里只有一条。
  • SQLUndoLog:单条 SQL 的回滚日志。sqlType=UPDATEtableName=stock 表示这是一条对 stock 表的更新。
  • beforeImage / afterImage:改前镜像和改后镜像,结构都是 TableRecords → rows → Row → fields。字段里 keyType=PRIMARY_KEY 标记主键(Seata 用它拼回滚 SQL 的 WHERE 条件),type 是 JDBC 类型码:-5 为 BIGINT,4 为 INTEGER。
  • 对照数据:id=1 的行,quantity 从改前的 98 变为改后的 97,正好对应一次 update stock set quantity = quantity - 1

回滚时 Seata 的流程是:先拿当前行与 afterImage 比对(防止数据被别的业务改过),一致就把 quantity 恢复成 beforeImage 里的 98,然后删除这条 undo_log。这就是 AT 模式反向补偿的底层数据。