本文用经典的 下单扣库存 场景演示 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-servicecd ~/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 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 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 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-servicecd ~/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 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 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 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) { jdbcTemplate.update( "insert into orders (user_id, product_id, count) values (?, ?, ?)" , userId, productId, count); String stockResult = stockClient.deduct(productId, count); 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 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_id、xid、context、rollback_info、log_status、log_created、log_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 :一条分支事务的根对象,包含 xid、branchId 和 sqlUndoLogs 列表。一次分支事务里可能有多条 SQL,这里只有一条。
SQLUndoLog :单条 SQL 的回滚日志。sqlType=UPDATE、tableName=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 模式反向补偿的底层数据。