文章目录
- [Spring Cloud 学习与实践(13):使用 Seata 解决分布式事务问题](#Spring Cloud 学习与实践(13):使用 Seata 解决分布式事务问题)
-
- [1、前言:为什么第 12 章之后要继续学习 Seata](#1、前言:为什么第 12 章之后要继续学习 Seata)
- 2、本章环境与涉及模块
- [3、先理解分布式事务与 Seata](#3、先理解分布式事务与 Seata)
-
- [3.1 本地事务为什么管不了远程服务](#3.1 本地事务为什么管不了远程服务)
- [3.2 Seata 中的 TC、TM、RM](#3.2 Seata 中的 TC、TM、RM)
- [3.3 Seata 的四种事务模式](#3.3 Seata 的四种事务模式)
- [3.4 AT 模式的两阶段思路](#3.4 AT 模式的两阶段思路)
- 4、阶段一:先复现库存扣了但订单没有保存
-
- [4.1 本阶段目标](#4.1 本阶段目标)
- [4.2 在正确的位置加入 quantity=77 临时异常](#4.2 在正确的位置加入 quantity=77 临时异常)
- [4.3 准备数据](#4.3 准备数据)
- [4.4 发送故障请求](#4.4 发送故障请求)
- [4.5 验证数据不一致](#4.5 验证数据不一致)
- [4.6 本阶段结论](#4.6 本阶段结论)
- [5、阶段二:安装并启动 Seata Server](#5、阶段二:安装并启动 Seata Server)
-
- [5.1 本阶段目标](#5.1 本阶段目标)
- [5.2 为什么使用 Seata Server 1.6.1](#5.2 为什么使用 Seata Server 1.6.1)
- [5.3 解压目录](#5.3 解压目录)
- [5.4 先检查端口](#5.4 先检查端口)
- [5.5 启动 Seata Server](#5.5 启动 Seata Server)
- [5.6 登录控制台](#5.6 登录控制台)
- [6、阶段三:让 cloud-order 和 cloud-product 接入 Seata](#6、阶段三:让 cloud-order 和 cloud-product 接入 Seata)
-
- [6.1 本阶段目标](#6.1 本阶段目标)
- [6.2 给 cloud-order 增加依赖](#6.2 给 cloud-order 增加依赖)
- [6.3 给 cloud-product 增加依赖](#6.3 给 cloud-product 增加依赖)
- [6.4 cloud-product-dev.yaml](#6.4 cloud-product-dev.yaml)
- [6.5 cloud-order-dev.yaml](#6.5 cloud-order-dev.yaml)
- [6.6 重启两个服务](#6.6 重启两个服务)
- [6.7 观察 RM 日志](#6.7 观察 RM 日志)
- [6.8 回归测试](#6.8 回归测试)
- [7、阶段四:创建 undo_log 并开启全局事务](#7、阶段四:创建 undo_log 并开启全局事务)
-
- [7.1 本阶段目标](#7.1 本阶段目标)
- [7.2 为什么需要 undo_log](#7.2 为什么需要 undo_log)
- [7.3 创建 undo_log](#7.3 创建 undo_log)
- [7.4 给订单入口添加 @GlobalTransactional](#7.4 给订单入口添加 @GlobalTransactional)
- [7.5 本次只重启 cloud-order](#7.5 本次只重启 cloud-order)
- [7.6 验证 quantity=77 全局回滚](#7.6 验证 quantity=77 全局回滚)
- [7.7 验证正常提交](#7.7 验证正常提交)
- [7.8 为什么事务结束后 undo_log 通常为空](#7.8 为什么事务结束后 undo_log 通常为空)
- [8、阶段五:观察 XID 跨服务传播与异常边界](#8、阶段五:观察 XID 跨服务传播与异常边界)
-
- [8.1 本阶段目标](#8.1 本阶段目标)
- [8.2 在 OrderServiceImpl 打印 XID](#8.2 在 OrderServiceImpl 打印 XID)
- [8.3 在 ProductServiceImpl 打印 XID](#8.3 在 ProductServiceImpl 打印 XID)
- [8.4 重启范围](#8.4 重启范围)
- [8.5 直接改库存后要删除 Redis 缓存](#8.5 直接改库存后要删除 Redis 缓存)
- [8.6 场景一:正常提交](#8.6 场景一:正常提交)
- [8.7 场景二:订单服务后置异常](#8.7 场景二:订单服务后置异常)
- [8.8 场景三:库存不足](#8.8 场景三:库存不足)
- [8.9 本阶段结论](#8.9 本阶段结论)
- [9、阶段六:演练 Seata AT 全局锁冲突](#9、阶段六:演练 Seata AT 全局锁冲突)
-
- [9.1 本阶段问题](#9.1 本阶段问题)
- [9.2 MySQL 本地锁与 Seata 全局锁](#9.2 MySQL 本地锁与 Seata 全局锁)
- [9.3 增加 quantity=88 临时长事务](#9.3 增加 quantity=88 临时长事务)
- [9.4 准备两个并发请求](#9.4 准备两个并发请求)
- [9.5 实际日志链路](#9.5 实际日志链路)
- [9.6 为什么 Updates: 1 后仍然失败](#9.6 为什么 Updates: 1 后仍然失败)
- [9.7 请求 A 为什么回滚](#9.7 请求 A 为什么回滚)
- [9.8 完整时间线](#9.8 完整时间线)
- [9.9 全局锁释放后再次验证](#9.9 全局锁释放后再次验证)
- [9.10 Protostuff 警告](#9.10 Protostuff 警告)
- [9.11 清理临时代码](#9.11 清理临时代码)
- 10、阶段七:异常被吞掉为什么会误提交
-
- [10.1 本阶段问题](#10.1 本阶段问题)
- [10.2 第一轮:故意吞掉 quantity=99 异常](#10.2 第一轮:故意吞掉 quantity=99 异常)
- [10.3 第一轮验证](#10.3 第一轮验证)
- [10.4 第二轮:捕获后重新抛出](#10.4 第二轮:捕获后重新抛出)
- [10.5 正确的异常边界](#10.5 正确的异常边界)
- [10.6 清理临时代码](#10.6 清理临时代码)
- [11、阶段八:Seata 全局提交后再发送 RabbitMQ](#11、阶段八:Seata 全局提交后再发送 RabbitMQ)
-
- [11.1 原来的 Spring afterCommit 有什么隐患](#11.1 原来的 Spring afterCommit 有什么隐患)
- [11.2 新增订单消息全局事务钩子](#11.2 新增订单消息全局事务钩子)
- [11.3 替换原来的 Spring 本地事务回调](#11.3 替换原来的 Spring 本地事务回调)
- [11.4 临时移动 quantity=77 的位置](#11.4 临时移动 quantity=77 的位置)
- [11.5 正常提交验证](#11.5 正常提交验证)
- [11.6 回滚验证](#11.6 回滚验证)
- [11.7 全局提交后 MQ 发送失败怎么办](#11.7 全局提交后 MQ 发送失败怎么办)
- [11.8 清理 quantity=77](#11.8 清理 quantity=77)
- 12、阶段九:最终代码与正式事务链路
-
- [12.1 最终 OrderServiceImpl](#12.1 最终 OrderServiceImpl)
- [12.2 最终 ProductServiceImpl](#12.2 最终 ProductServiceImpl)
- [12.3 最终事务链路](#12.3 最终事务链路)
- [12.4 最终正常冒烟测试](#12.4 最终正常冒烟测试)
- [13、Seata AT 原理再梳理一次](#13、Seata AT 原理再梳理一次)
-
- [13.1 第一阶段](#13.1 第一阶段)
- [13.2 第二阶段提交](#13.2 第二阶段提交)
- [13.3 第二阶段回滚](#13.3 第二阶段回滚)
- [13.4 全局锁主要防什么](#13.4 全局锁主要防什么)
- [14、Seata 自身四种模式怎么选](#14、Seata 自身四种模式怎么选)
-
- [14.1 为什么本章不选 TCC](#14.1 为什么本章不选 TCC)
- [14.2 为什么本章不选 Saga](#14.2 为什么本章不选 Saga)
- [14.3 为什么本章不选 XA](#14.3 为什么本章不选 XA)
- [15、Seata 与同类分布式事务方案对比](#15、Seata 与同类分布式事务方案对比)
-
- [15.1 Seata、DTM、Hmily、ByteTCC、TX-LCN](#15.1 Seata、DTM、Hmily、ByteTCC、TX-LCN)
- [15.2 不要只按"功能多"选择](#15.2 不要只按“功能多”选择)
- [15.3 当前项目为什么选择 Seata AT](#15.3 当前项目为什么选择 Seata AT)
- [16、本地事务、Seata 与 RabbitMQ 的职责边界](#16、本地事务、Seata 与 RabbitMQ 的职责边界)
-
- [16.1 Seata 与 RabbitMQ 不是互相替代](#16.1 Seata 与 RabbitMQ 不是互相替代)
- [16.2 三层最终链路](#16.2 三层最终链路)
- 17、本章真实踩坑与容易误解的现象
- 18、学习方案与生产环境的差距
-
- [18.1 全局事务要尽量短](#18.1 全局事务要尽量短)
- [18.2 缓存一致性的额外边界](#18.2 缓存一致性的额外边界)
- 19、本章总结
- 20、下一章预告
Spring Cloud 学习与实践(13):使用 Seata 解决分布式事务问题
本章严格按照实际演练顺序整理:先复现问题,再启动 Seata Server,再接入客户端、创建
undo_log、开启全局事务,最后演练 XID、全局锁、异常传播以及 Seata 与 RabbitMQ 的事务时机。
1、前言:为什么第 12 章之后要继续学习 Seata
第 12 章我们完成了 RabbitMQ 可靠消息链路:
text
订单事务内保存本地消息记录
↓
事务提交后发送 RabbitMQ
↓
Confirm / Return 更新发送状态
↓
发送失败由定时任务补偿
↓
消费者手动 ACK、有限重试、幂等和 DLQ
RabbitMQ 解决的是:
text
订单已经成功创建之后,
后续事件如何可靠地异步发送和消费。
但是当前下单流程里还有一个更靠前的问题:
text
cloud-order 创建订单
↓
Feign 调用 cloud-product 扣库存
↓
cloud-order 保存订单
如果商品服务已经扣减库存,而订单服务随后保存失败,会发生什么?
当前两个服务各自只有本地事务:
| 服务 | 数据库操作 | 原有事务范围 |
|---|---|---|
cloud-product |
扣减 t_product.stock |
商品服务自己的本地事务 |
cloud-order |
保存 t_order、保存 t_mq_message_log |
订单服务自己的本地事务 |
| Feign 调用 | 远程 HTTP 调用 | 不会自动把两个本地事务合成一个事务 |
所以原始结果可能是:
text
库存扣减成功
↓
订单保存失败
↓
库存不会跟着订单回滚
↓
库存减少了,但订单不存在
第 13 章就是围绕这个问题,引入 Seata AT 模式,让订单和库存能够作为一个全局事务整体提交或整体回滚。
本章主线是:
text
复现跨服务数据不一致
↓
启动 Seata Server
↓
订单服务和商品服务接入 Seata
↓
创建 undo_log
↓
添加 @GlobalTransactional
↓
观察 XID 跨服务传播
↓
演练全局锁冲突
↓
演练异常被吞掉导致误提交
↓
改为 Seata 全局提交后发送 RabbitMQ
↓
清理临时代码并确认最终架构
2、本章环境与涉及模块
本章继续基于前面章节的 cloud-demo 项目。
| 项目 | 当前值 |
|---|---|
| JDK | 17 |
| Spring Boot | 2.7.18 |
| Spring Cloud | 2021.0.8 |
| Spring Cloud Alibaba | 2021.0.5.0 |
| Nacos | 2.2.0 |
| Sentinel Dashboard | 1.8.6 |
| MySQL | 8 |
| Redis | 本地 Redis |
| RabbitMQ | 3.8.18 |
| Seata Server | 1.6.1 |
| Seata 控制台 | http://127.0.0.1:7091 |
| Seata TC 通信端口 | 8091 |
本章主要修改两个服务:
| 模块 | 本章职责 |
|---|---|
cloud-order |
全局事务入口,保存订单和本地 MQ 消息记录 |
cloud-product |
全局事务分支,扣减商品库存 |
本章不需要让所有服务都接入 Seata:
| 模块 | 是否接入 | 原因 |
|---|---|---|
cloud-order |
是 | 开启全局事务,同时写订单数据库 |
cloud-product |
是 | 扣库存,需要参与全局回滚 |
cloud-user |
否 | 当前下单流程只查询用户,不修改用户数据 |
cloud-auth |
否 | 只负责登录和身份认证 |
cloud-gateway |
否 | 只做路由、鉴权和请求转发 |
3、先理解分布式事务与 Seata
3.1 本地事务为什么管不了远程服务
Spring 的:
java
@Transactional(rollbackFor = Exception.class)
只能管理当前应用中被事务管理器控制的数据源操作。
例如在 cloud-order 中:
text
保存 t_order
保存 t_mq_message_log
它们使用同一个订单服务数据源,可以放在一个本地事务里。
但是:
java
productClient.deductStock(...)
是一次远程 Feign 调用。商品服务内部会开启自己的本地事务,商品服务一旦提交,订单服务后面的异常无法直接让商品服务数据库回滚。
因此:
text
@Transactional
只能保证单个服务内部的数据一致性,
不能天然保证多个服务、多个数据源一起提交或回滚。
3.2 Seata 中的 TC、TM、RM
Seata 把分布式事务中的角色分为三类:
| 角色 | 全称 | 在当前项目中的对应关系 |
|---|---|---|
| TC | Transaction Coordinator | Seata Server,维护全局事务和分支事务状态 |
| TM | Transaction Manager | cloud-order#createOrder(),开启、提交或回滚全局事务 |
| RM | Resource Manager | cloud-order、cloud-product 中被 Seata 代理的数据源 |
本章中的链路可以理解为:
text
cloud-order#createOrder
↓ 作为 TM 开启全局事务
Seata Server 生成并管理 XID
↓
cloud-product 扣库存
↓ 作为 RM 注册商品分支事务
cloud-order 保存订单和本地消息
↓ 作为 RM 注册订单分支事务
TC 根据整体结果决定提交或回滚
3.3 Seata 的四种事务模式
Seata 1.6.x 官方提供 AT、TCC、Saga、XA 四种事务模式。
| 模式 | 主要做法 | 业务侵入 | 更适合的场景 |
|---|---|---|---|
| AT | 数据源代理、前后镜像、undo_log、全局锁 |
低 | 关系型数据库、普通 CRUD、希望少改业务代码 |
| TCC | 业务自己实现 Try、Confirm、Cancel | 高 | 核心交易、性能和资源控制要求高 |
| Saga | 每个步骤先提交本地事务,失败时执行补偿服务 | 中到高 | 长流程、跨外部系统、不能长时间持锁 |
| XA | 使用数据库 XA 两阶段提交能力 | 较低 | 数据库支持 XA,能够接受更长资源占用和性能成本 |
本章选择 AT,原因是:
text
当前订单和库存都是 MySQL 表;
业务操作主要是普通 INSERT / UPDATE;
希望先用较小的业务改造解决跨服务回滚;
当前目标是理解 Seata 的基本全局事务链路。
3.4 AT 模式的两阶段思路
AT 模式不是让数据库本地事务一直挂到全局事务结束。
它的核心过程是:
text
第一阶段:
执行业务 SQL
记录 before image / after image
写入 undo_log
业务数据与 undo_log 在同一个本地事务中提交
释放 MySQL 本地锁和连接
第二阶段提交:
异步删除 undo_log,快速完成
第二阶段回滚:
根据 undo_log 校验数据并执行补偿恢复
这意味着:
text
第一阶段本地事务提交
≠
全局事务已经最终提交
后面的全局锁演练会真实看到这个区别。
4、阶段一:先复现库存扣了但订单没有保存
4.1 本阶段目标
在接入 Seata 之前,先建立对照组:
text
商品服务成功扣库存
↓
订单服务在保存订单前主动抛出异常
↓
观察库存是否会回滚
本阶段只修改:
text
cloud-order
└── OrderServiceImpl.java
此时方法仍然只有 Spring 本地事务:
java
@Override
@Transactional(rollbackFor = Exception.class)
public Order createOrder(CreateOrderRequest request) {
4.2 在正确的位置加入 quantity=77 临时异常
原始流程中,商品扣库存代码是:
java
ensureSuccess(
productClient.deductStock(
request.getProductId(),
request.getQuantity()
),
"库存扣减失败"
);
临时异常必须放在:
text
远程扣库存成功之后
订单保存之前
加入:
java
/*
* 第 13 章阶段一临时故障演练代码。
*
* 当购买数量为 77 时,在 cloud-product 扣库存成功后、
* cloud-order 保存订单前故意抛出异常。
*
* 用来复现:
* 商品库存已经扣减,
* 但订单没有保存成功,
* 从而产生跨服务数据不一致问题。
*/
if (Integer.valueOf(77).equals(request.getQuantity())) {
log.error(
"模拟订单保存前异常,用于复现分布式事务问题,"
+ "productId={},quantity={}",
request.getProductId(),
request.getQuantity()
);
throw new BizException(
ErrorCode.BIZ_ERROR,
"模拟订单保存失败,用于演练分布式事务问题"
);
}
为什么不能放在 OrderCreatedMessageListener?
| 放置位置 | 演练的问题 |
|---|---|
OrderCreatedMessageListener |
消费者处理 MQ 消息失败 |
OrderServiceImpl#createOrder |
商品扣库存成功,但订单服务事务失败 |
第 12 章的 quantity=66 放在 Listener,是为了演练 RabbitMQ 消费失败、重试和 DLQ。
本章的 quantity=77 必须放在 OrderServiceImpl,因为我们要验证的是订单与库存的跨服务事务。
4.3 准备数据
先把商品库存重置为明显的值:
sql
UPDATE t_product
SET stock = 1000
WHERE id = 1;
查询确认:
sql
SELECT id, name, stock
FROM t_product
WHERE id = 1;
记录当前订单数量:
sql
SELECT COUNT(*) AS order_count
FROM t_order;

4.4 发送故障请求
http
POST http://localhost:9000/api/order/orders
Authorization: Bearer {{token}}
Content-Type: application/json
{
"productId": 1,
"quantity": 77
}
预期接口返回失败,并看到:
text
模拟订单保存前异常,用于复现分布式事务问题

4.5 验证数据不一致
查询库存:
sql
SELECT id, stock
FROM t_product
WHERE id = 1;
预期:
text
1000 → 923
查询订单:
sql
SELECT id, user_id, product_id, quantity, amount
FROM t_order
ORDER BY id DESC
LIMIT 5;
预期没有新增 quantity=77 的订单。
查询本地 MQ 消息表:
sql
SELECT message_id, message_type, send_status, created_at
FROM t_mq_message_log
ORDER BY id DESC
LIMIT 5;
预期也没有新增本次消息,因为异常发生在订单和本地消息记录保存之前。

4.6 本阶段结论
| 数据 | 结果 |
|---|---|
| 接口 | 失败 |
t_product.stock |
1000 → 923 |
t_order |
不新增 |
t_mq_message_log |
不新增 |
这证明:
text
cloud-product 的本地事务已经提交;
cloud-order 的本地事务没有提交;
两个服务之间没有统一事务边界。
quantity=77 暂时保留,后面接入 Seata 后继续用来做对照。
5、阶段二:安装并启动 Seata Server
5.1 本阶段目标
本阶段只把 TC 跑起来:
text
下载 Seata Server 1.6.1
↓
以 file 模式启动
↓
访问 7091 控制台
本阶段不修改业务代码,也不测试库存回滚。
5.2 为什么使用 Seata Server 1.6.1
当前项目版本是:
| 组件 | 版本 |
|---|---|
| Spring Boot | 2.7.18 |
| Spring Cloud | 2021.0.8 |
| Spring Cloud Alibaba | 2021.0.5.0 |
| JDK | 17 |
本章使用 Seata Server 1.6.1,是为了与当前 Spring Cloud Alibaba 版本保持相对稳定的兼容关系。
Seata 官方网站已经把 1.6 文档标记为历史版本,因此本文所有配置和 API 都以当前学习项目的 1.6.1 为准,不直接照搬 2.x 的配置方式。
5.3 解压目录
Windows 下可以解压到:
text
"你的目录"\seata\seata-server-1.6.1
主要目录:
text
seata-server-1.6.1
├── bin
│ ├── seata-server.bat
│ └── seata-server.sh
├── conf
├── lib
└── logs
5.4 先检查端口
bash
netstat -ano | findstr 8091
netstat -ano | findstr 7091
| 端口 | 用途 |
|---|---|
8091 |
Seata 客户端连接 TC 的通信端口 |
7091 |
Seata 管理控制台端口 |
5.5 启动 Seata Server
进入 bin:
bash
cd /d "你的目录"\seata\seata-server-1.6.1\bin
启动:
bash
seata-server.bat -p 8091 -h 127.0.0.1 -m file

参数说明:
| 参数 | 含义 |
|---|---|
-p 8091 |
TC 通信端口 |
-h 127.0.0.1 |
本地学习环境对外地址 |
-m file |
Server 事务状态暂时使用 file 存储 |
本章先使用最简单的 file 模式:
| 配置 | 本章选择 | 原因 |
|---|---|---|
| 注册中心 | file | 先减少接入变量 |
| 配置中心 | file | 不先引入额外 Nacos 配置 |
| Server 存储 | file | 学习环境快速启动 |
5.6 登录控制台
浏览器访问:
text
http://127.0.0.1:7091
本地默认账号:
text
用户名:seata
密码:seata
此时看不到业务全局事务记录是正常的,因为业务服务尚未接入,也没有执行 @GlobalTransactional。
6、阶段三:让 cloud-order 和 cloud-product 接入 Seata
6.1 本阶段目标
本阶段完成:
text
两个服务引入 Seata Starter
↓
两个服务配置相同的事务组
↓
连接 127.0.0.1:8091
↓
观察 RM 识别数据源
6.2 给 cloud-order 增加依赖
修改:
text
cloud-order/pom.xml
增加:
xml
<!-- Seata:用于订单服务参与分布式事务 -->
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-seata</artifactId>
</dependency>
不单独写版本号,继续由父工程 Spring Cloud Alibaba BOM 管理。
6.3 给 cloud-product 增加依赖
修改:
text
cloud-product/pom.xml
增加:
xml
<!-- Seata:用于商品服务参与分布式事务 -->
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-seata</artifactId>
</dependency>
6.4 cloud-product-dev.yaml
在 Nacos 的 cloud-product-dev.yaml 中合并 Seata 配置:
yaml
biz:
version: product-dev-v1
spring:
cloud:
sentinel:
transport:
dashboard: 127.0.0.1:8858
port: 8719
alibaba:
seata:
tx-service-group: cloud-demo-tx-group
redis:
host: 127.0.0.1
port: 6379
database: 0
timeout: 3000ms
seata:
enabled: true
application-id: cloud-product
tx-service-group: cloud-demo-tx-group
registry:
type: file
config:
type: file
service:
vgroup-mapping:
cloud-demo-tx-group: default
grouplist:
default: 127.0.0.1:8091

6.5 cloud-order-dev.yaml
在 Nacos 的 cloud-order-dev.yaml 中保留原来的 Feign、Sentinel、RabbitMQ 配置,并合并 Seata:
yaml
biz:
version: order-dev-v1
feign:
circuitbreaker:
# 启用 OpenFeign CircuitBreaker 包装。
# 开启后,远程调用异常时才会进入 fallbackFactory。
enabled: true
client:
config:
default:
# 建立连接最长等待 1 秒。
connectTimeout: 1000
# 连接建立后,远程调用最多等待响应 1 秒。
readTimeout: 1000
spring:
cloud:
sentinel:
transport:
dashboard: 127.0.0.1:8858
port: 8720
alibaba:
seata:
tx-service-group: cloud-demo-tx-group
rabbitmq:
host: 127.0.0.1
port: 5672
username: guest
password: guest
virtual-host: /
# 开启带 CorrelationData 的发布确认
publisher-confirm-type: correlated
# 开启消息不可路由时的 return 回调
publisher-returns: true
# 消息无法路由到队列时,不直接丢弃,而是触发 return
template:
mandatory: true
listener:
simple:
# 手动 ACK
acknowledge-mode: manual
default-requeue-rejected: false
seata:
enabled: true
application-id: cloud-order
tx-service-group: cloud-demo-tx-group
registry:
type: file
config:
type: file
service:
vgroup-mapping:
cloud-demo-tx-group: default
grouplist:
default: 127.0.0.1:8091

注意:
text
spring.cloud.alibaba.seata.tx-service-group
和
seata.tx-service-group
都使用:
text
cloud-demo-tx-group
事务组映射关系是:
text
cloud-demo-tx-group
↓ vgroup-mapping
默认集群 default
↓ grouplist
127.0.0.1:8091
6.6 重启两个服务
本阶段修改了两个模块的依赖和 Nacos 配置,因此重启:
text
1. cloud-product
2. cloud-order
6.7 观察 RM 日志
两个服务启动后都出现:
text
RM will register :jdbc:mysql://localhost:3306/cloud_demo
这条日志表示:
text
Seata RM 已识别到当前 JDBC 数据源,
准备把它作为可参与全局事务的资源注册。


但它并不代表:
text
已经开启了一笔全局事务。
6.8 回归测试
先调用:
bash
POST http://localhost:9000/api/order/orders
Authorization: Bearer {{token}}
Content-Type: application/json
{
"productId": 1,
"quantity": 1
}
普通 quantity=1 下单仍然成功,说明引入 Seata 客户端没有破坏原有链路。

再次把库存重置为 1000,执行 quantity=77:
| 验证项 | 结果 |
|---|---|
| 接口 | 失败 |
| 商品库存 | 1000 → 923 |
| 订单 | 不新增 |
| 本地消息 | 不新增 |

说明:
text
两个服务虽然已经接入 Seata 客户端,
但订单入口还没有开启全局事务。
7、阶段四:创建 undo_log 并开启全局事务
7.1 本阶段目标
阶段四不能只停留在建表,而是完成 AT 模式最小闭环:
text
创建 undo_log
↓
给 createOrder 添加 @GlobalTransactional
↓
验证正常提交
↓
验证异常全局回滚
7.2 为什么需要 undo_log
AT 模式回滚时需要知道:
text
SQL 执行前数据是什么;
SQL 执行后数据是什么;
回滚时应该恢复成什么。
undo_log 用于保存回滚信息。
| 表 | 职责 |
|---|---|
t_product |
商品业务数据 |
t_order |
订单业务数据 |
t_mq_message_log |
MQ 生产者发送状态 |
undo_log |
Seata AT 回滚日志 |
当前 cloud-order 和 cloud-product 都连接 cloud_demo,所以本章只需在这个库里创建一次。
如果生产环境每个服务使用独立数据库,则每个参与 AT 模式的业务库都需要自己的 undo_log。
7.3 创建 undo_log
在 cloud_demo 执行:
sql
CREATE TABLE IF NOT EXISTS `undo_log`
(
`branch_id` BIGINT NOT NULL COMMENT 'branch transaction id',
`xid` VARCHAR(128) NOT NULL COMMENT 'global transaction id',
`context` VARCHAR(128) NOT NULL COMMENT 'undo_log context,such as serialization',
`rollback_info` LONGBLOB NOT NULL COMMENT 'rollback info',
`log_status` INT NOT NULL COMMENT '0:normal status,1:defense status',
`log_created` DATETIME(6) NOT NULL COMMENT 'create datetime',
`log_modified` DATETIME(6) NOT NULL COMMENT 'modify datetime',
UNIQUE KEY `ux_undo_log` (`xid`, `branch_id`)
) ENGINE = InnoDB
DEFAULT CHARSET = utf8mb4
COMMENT = 'AT transaction mode undo table';
ALTER TABLE `undo_log`
ADD INDEX `ix_log_created` (`log_created`);

验证:
sql
SHOW TABLES LIKE 'undo_log';
DESC undo_log;
SHOW INDEX FROM undo_log;
SELECT * FROM undo_log;
初始查询为空是正常的。
这里只新增数据库表结构,没有修改 Java 代码或 Nacos 配置,因此不需要为了建表机械重启服务。
7.4 给订单入口添加 @GlobalTransactional
修改:
text
cloud-order
└── OrderServiceImpl.java
新增 import:
java
import io.seata.spring.annotation.GlobalTransactional;
由:
java
@Override
@Transactional(rollbackFor = Exception.class)
public Order createOrder(CreateOrderRequest request) {
改为:
java
@Override
@GlobalTransactional(
name = "create-order-global-transaction",
rollbackFor = Exception.class
)
@Transactional(rollbackFor = Exception.class)
public Order createOrder(CreateOrderRequest request) {
两个注解都保留:
| 注解 | 管理范围 |
|---|---|
@GlobalTransactional |
cloud-order 与 cloud-product 的全局事务 |
@Transactional |
cloud-order 内部 t_order 与 t_mq_message_log 的本地事务 |
不需要在 cloud-product#deductStock() 上再加另一个 @GlobalTransactional。
全局事务只需要从业务入口开启:
text
cloud-order 开启全局事务并生成 XID
↓
Feign 调用传播 XID
↓
cloud-product 作为 RM 加入现有全局事务
7.5 本次只重启 cloud-order
本次只修改了订单服务 Java 代码,因此只需重启 cloud-order。
7.6 验证 quantity=77 全局回滚
恢复库存:
sql
UPDATE t_product
SET stock = 1000
WHERE id = 1;
发送:
http
POST http://localhost:9000/api/order/orders
Authorization: Bearer {{token}}
Content-Type: application/json
{
"productId": 1,
"quantity": 77
}

接入 Seata 前后结果对比:
| 验证项 | 接入前 | 添加 @GlobalTransactional 后 |
|---|---|---|
| 接口 | 失败 | 失败 |
| 库存 | 1000 → 923 |
最终恢复为 1000 |
| 订单 | 不新增 | 不新增 |
| 本地消息 | 不新增 | 不新增 |
| 最终一致性 | 被破坏 | 保持一致 |
核心链路:
text
商品服务先扣减库存
↓
商品分支第一阶段完成
↓
订单服务抛出 quantity=77 模拟异常
↓
TM 请求 TC 回滚全局事务
↓
商品服务根据 undo_log 恢复库存
7.7 验证正常提交
正常场景不是多余测试,因为全局提交和全局回滚的日志状态不同。
恢复库存后为1000后发送:
json
{
"productId": 1,
"quantity": 1
}

轻量确认:
| 验证项 | 预期 |
|---|---|
| 接口 | 成功 |
| 库存 | 1000 → 999 |
| 订单 | 新增 |
| Seata | 全局事务提交 |
7.8 为什么事务结束后 undo_log 通常为空
执行:
sql
SELECT * FROM undo_log;
最终通常为空:
| 全局结果 | undo_log 后续处理 |
|---|---|
| 全局提交 | 第二阶段异步清理 |
| 全局回滚 | 使用回滚日志恢复后删除 |
| 事务执行过程中 | 可能短暂存在 |
因此判断 Seata 是否生效,主要看业务数据是否正确回滚,而不是要求事务结束后 undo_log 必须保留记录。
8、阶段五:观察 XID 跨服务传播与异常边界
8.1 本阶段目标
上一阶段通过库存结果证明了 Seata 能回滚。
这一阶段要看清楚:
text
cloud-order 和 cloud-product
为什么属于同一笔全局事务?
答案是 XID。
8.2 在 OrderServiceImpl 打印 XID
新增:
java
import io.seata.core.context.RootContext;
在参数校验后打印:
java
log.info(
"创建订单进入全局事务,xid={},productId={},quantity={}",
RootContext.getXID(),
request.getProductId(),
request.getQuantity()
);
因为 @GlobalTransactional 代理会在进入方法体前开启全局事务,所以此处正常应取得非空 XID。
8.3 在 ProductServiceImpl 打印 XID
在 deductStock() 参数校验后增加:
java
log.info(
"商品扣库存进入分支事务,xid={},productId={},quantity={}",
RootContext.getXID(),
productId,
quantity
);
8.4 重启范围
本次修改了两个服务:
text
1. cloud-product
2. cloud-order
8.5 直接改库存后要删除 Redis 缓存
后面为了快速准备测试数据,会直接执行:
sql
UPDATE t_product SET stock = ... WHERE id = 1;
直接改数据库绕过了商品服务的缓存删除逻辑,因此每次还要执行:
bash
redis-cli del cloud:product:detail:1
否则订单服务查询商品时可能读到旧库存缓存。
8.6 场景一:正常提交
库存设为 1000,删除缓存,发送 quantity=1。
bash
POST http://localhost:9000/api/order/orders
Authorization: Bearer {{token}}
Content-Type: application/json
{
"productId": 1,
"quantity": 1
}
订单服务日志:
text
创建订单进入全局事务,xid=...
商品服务日志:
text
商品扣库存进入分支事务,xid=...
要求:
text
两个服务 XID 非空且完全相同。


结果:
| 验证项 | 预期 |
|---|---|
| 请求 | 成功 |
| 库存 | 1000 → 999 |
| XID | Order 与 Product 相同 |
| Seata | 全局提交 |
8.7 场景二:订单服务后置异常
库存设为 1000,删除缓存,发送 quantity=77。
链路:
text
cloud-order 开启 XID-A
↓
cloud-product 绑定 XID-A
↓
库存扣减成功
↓
订单服务抛出模拟异常
↓
全局回滚
↓
库存恢复为 1000


8.8 场景三:库存不足
库存设为 10,删除缓存,发送:
json
{
"productId": 1,
"quantity": 20
}


商品原子 UPDATE 条件中:
sql
stock >= quantity
不成立,因此影响行数为 0。
对比两种失败:
| 失败场景 | 商品数据是否先修改 | 回滚特点 |
|---|---|---|
quantity=77 |
是 | Seata 根据 undo_log 补偿恢复 |
| 库存 10、购买 20 | 否 | 没有商品数据需要补偿 |
两种场景都会让订单方法异常结束,因此订单和本地消息都不应保存。
8.9 本阶段结论
text
@GlobalTransactional 开启全局事务
↓
订单服务获得 XID
↓
Feign 调用传播 XID
↓
商品服务绑定相同 XID
↓
参与同一个全局事务
如果商品服务打印 xid=null,说明它没有加入全局事务,不能继续假装测试通过。
XID 日志在学习项目中继续保留,便于后续观察。
9、阶段六:演练 Seata AT 全局锁冲突
9.1 本阶段问题
AT 第一阶段会提交商品服务的本地事务并释放 MySQL 本地锁。
那么:
text
请求 A 已经扣减商品 1,
但整个全局事务还没有结束。
请求 B 再修改商品 1,
会发生什么?
Seata 通过全局锁避免两个受 Seata 管理的全局事务同时成功提交对同一行的写入。
9.2 MySQL 本地锁与 Seata 全局锁
| 锁 | 管理者 | 持有范围 |
|---|---|---|
| MySQL 本地行锁 | MySQL | 当前本地事务提交或回滚前 |
| Seata 全局锁 | TC | 整个全局事务最终提交或回滚前 |
请求 A 的商品分支过程:
text
获取 MySQL 本地行锁
↓
执行库存 UPDATE
↓
记录前后镜像和 undo_log
↓
注册分支并申请全局锁
↓
提交本地事务
↓
释放 MySQL 本地锁
↓
继续持有 Seata 全局锁
9.3 增加 quantity=88 临时长事务
在 OrderServiceImpl 的远程扣库存成功后、订单保存前增加:
java
/*
* 第 13 章阶段六临时并发演练代码。
*
* quantity=88 时:
* 1. 商品服务先成功扣减库存;
* 2. 当前全局事务继续保持 15 秒;
* 3. 这段时间内,用另一个请求修改相同商品;
* 4. 等待结束后主动抛出异常,让请求 A 自己回滚。
*/
if (Integer.valueOf(88).equals(request.getQuantity())) {
log.warn(
"全局锁演练开始,当前事务将暂停 15 秒,"
+ "xid={},productId={},quantity={}",
RootContext.getXID(),
request.getProductId(),
request.getQuantity()
);
try {
TimeUnit.SECONDS.sleep(15);
} catch (InterruptedException exception) {
Thread.currentThread().interrupt();
throw new BizException(
ErrorCode.BIZ_ERROR,
"全局锁演练线程被中断"
);
}
log.warn(
"全局锁演练等待结束,准备抛出异常触发全局回滚,xid={}",
RootContext.getXID()
);
throw new BizException(
ErrorCode.BIZ_ERROR,
"模拟长事务失败,用于演练 Seata 全局锁"
);
}
为什么暂停放在订单服务而不是商品服务?
text
商品服务方法已经返回
↓
商品分支本地事务已经提交
↓
MySQL 本地锁已经释放
↓
订单全局事务还未结束
↓
Seata 全局锁仍然存在
这样更适合观察全局锁,而不是 MySQL 本地行锁等待。
9.4 准备两个并发请求
恢复库存为 1000并删除缓存。

请求 A:
json
{
"productId": 1,
"quantity": 88
}

先发 A,看到暂停日志后,在 15 秒内立即发 B。
请求 B:
json
{
"productId": 1,
"quantity": 1
}

应满足:
text
请求 A:Order XID = Product XID = xid-A
请求 B:Order XID = Product XID = xid-B
xid-A != xid-B

9.5 实际日志链路
初始库存:
text
1000
请求 A 扣减 88:
text
XID-A = 192.168.1.4:8091:3918930312329973777
UPDATE t_product ... quantity=88
Updates: 1
库存第一阶段本地提交后为:
text
912
请求 B 查询商品详情时读到:
text
stock = 912
这是 AT 模式的一个重要特点:
text
请求 A 商品分支的本地事务已经提交,
普通 SELECT 可以读到 912;
但请求 A 的全局事务还没有最终结束。
请求 B 执行扣减 1:
text
XID-B = 192.168.1.4:8091:3918930312329973779
UPDATE ... quantity=1
Updates: 1
业务方法里 SQL 确实影响了一行,但提交时出现:
text
org.springframework.dao.QueryTimeoutException:
JDBC commit; Global lock wait timeout
Caused by:
io.seata.rm.datasource.exec.LockWaitTimeoutException:
Global lock wait timeout
Caused by:
io.seata.rm.datasource.exec.LockConflictException:
get global lock fail
lockKeys:t_product:1
异常层次:
text
QueryTimeoutException
↓
LockWaitTimeoutException
↓
LockConflictException
↓
请求 B 无法取得 t_product:1 全局锁
这里的 QueryTimeoutException 不是普通查询超时,而是 Seata 全局锁等待超时被 Spring JDBC 包装后的异常。
9.6 为什么 Updates: 1 后仍然失败
顺序是:
text
请求 B 的 UPDATE 在本地事务中执行成功
↓
打印 SQL 执行成功日志
↓
业务方法返回
↓
Spring 开始提交本地事务
↓
Seata ConnectionProxy 申请全局锁
↓
发现 t_product:1 已被请求 A 持有
↓
申请失败并重试
↓
超时后请求 B 本地事务回滚
所以:
text
SQL 影响一行
≠
事务已经最终提交
请求 B 的临时数据变化可以理解为:
text
数据库已提交值 912
↓
B 本地事务中暂时为 911
↓
提交时申请全局锁失败
↓
B 本地事务回滚
↓
数据库仍为 912
9.7 请求 A 为什么回滚
15 秒后,请求 A 自己的 quantity=88 临时代码主动抛出异常。
A 的回滚不是 B 失败触发的。
正确因果关系:
text
请求 A:
quantity=88
↓
等待 15 秒
↓
自身主动抛异常
↓
A 自己触发全局回滚
请求 B:
只是因为抢不到 A 持有的全局锁而失败
请求 A 的商品分支日志:
text
rm handle branch rollback process
Branch Rollbacking
undo_log deleted with GlobalFinished
Branch Rollbacked result: PhaseTwo_Rollbacked
Seata 根据 A 的 undo_log 将库存:
text
912 → 1000
9.8 完整时间线
| 时间点 | 事件 | 数据库库存 |
|---|---|---|
| 开始 | 初始库存 | 1000 |
| A 第一阶段 | A 扣减 88并提交商品本地事务 | 912 |
| B 查询 | 普通 SELECT 读取到 A 第一阶段数据 | 912 |
| B UPDATE | 本地事务内暂时扣减 1 | 临时 911 |
| B 提交 | 全局锁超时,B 本地事务回滚 | 912 |
| A 15 秒结束 | A 自己抛异常,全局回滚 | 1000 |
9.9 全局锁释放后再次验证
A 回滚完成后再次发送 quantity=1:


| 验证项 | 预期 |
|---|---|
| 请求 | 成功 |
| 库存 | 1000 → 999 |
| 订单 | 新增 |
这一步验证的是全局锁已释放,不是机械重复正常测试。
9.10 Protostuff 警告
演练中出现:
text
Load [io.seata.rm.datasource.undo.parser.ProtostuffUndoLogParser] class fail.
io/protostuff/runtime/IdStrategy
但实际结果是:
text
undo_log 正常写入;
A 的库存正常补偿恢复;
undo_log 正常删除;
PhaseTwo_Rollbacked 成功。
因此该警告没有阻断本章实际使用的序列化和回滚链路,不需要为了消除一条非致命警告盲目增加依赖。
9.11 清理临时代码
演练完成后删除:
text
quantity=88 临时代码
后续正式版本不保留这个长事务开关。
10、阶段七:异常被吞掉为什么会误提交
10.1 本阶段问题
有些人可能会认为:
text
业务代码中发生过异常,事务就一定回滚。
实际上事务代理只能根据方法最终的退出状态做判断。
如果异常被方法内部 catch 住,不再继续向外抛出,代理看到的是:
text
方法正常返回
于是本地事务和全局事务都可能提交。
rollbackFor = Exception.class 只能决定"哪些已经抛出方法的异常触发回滚",无法处理已经被吞掉的异常。
10.2 第一轮:故意吞掉 quantity=99 异常
在扣库存后、保存订单前加入错误示例:
java
/*
* 第 13 章阶段七临时错误代码。
*
* quantity=99 时:
* 商品已经扣库存,订单服务发生异常,
* 但故意 catch 后正常 return。
*/
if (Integer.valueOf(99).equals(request.getQuantity())) {
try {
throw new BizException(
ErrorCode.BIZ_ERROR,
"模拟订单业务异常"
);
} catch (BizException exception) {
log.error(
"故意吞掉订单业务异常,方法将正常返回,"
+ "xid={},message={}",
RootContext.getXID(),
exception.getMessage()
);
return new Order();
}
}
return new Order() 不是业务设计,只是为了制造:
text
商品库存已经扣减
↓
订单没有保存
↓
方法却正常返回
10.3 第一轮验证
库存设为 1000,删除缓存,发送:
json
{
"productId": 1,
"quantity": 99
}



预期:
| 验证项 | 结果 |
|---|---|
| 接口 | 表面成功 |
| 返回订单 | 没有真实订单 ID |
| 库存 | 1000 → 901 |
t_order |
不新增 |
t_mq_message_log |
不新增 |
| Seata | 全局提交 |
完整链路:
text
商品分支扣库存 99
↓
订单服务模拟抛异常
↓
catch 吞掉异常
↓
createOrder 正常 return
↓
事务代理认为业务成功
↓
Seata 全局提交
↓
库存变为 901,但订单不存在
10.4 第二轮:捕获后重新抛出
将临时代码修改为:
java
if (Integer.valueOf(99).equals(request.getQuantity())) {
try {
throw new BizException(
ErrorCode.BIZ_ERROR,
"模拟订单业务异常"
);
} catch (BizException exception) {
log.error(
"记录订单业务异常后重新抛出,"
+ "xid={},message={}",
RootContext.getXID(),
exception.getMessage()
);
throw exception;
}
}

恢复库存为1000后再次发送相同请求:

| 验证项 | 结果 |
|---|---|
| 接口 | 返回失败 |
| 商品库存 | 最终恢复为 1000 |
t_order |
不新增 |
t_mq_message_log |
不新增 |
| Seata | 全局回滚 |
10.5 正确的异常边界
| 写法 | 事务结果 |
|---|---|
| 完全不捕获,直接向外抛 | 回滚 |
catch 后 throw exception |
回滚 |
catch 后正常 return |
可能提交 |
catch 后继续往下执行 |
可能提交 |
当前项目最自然的链路是:
text
Service 抛出 BizException
↓
Spring / Seata 事务代理感知失败并回滚
↓
GlobalExceptionHandler 统一转换为 Result 失败响应
不要为了在 Service 层返回"友好结果",把必须触发回滚的异常吞掉。
10.6 清理临时代码
两轮演练后删除所有 quantity=99 逻辑。
11、阶段八:Seata 全局提交后再发送 RabbitMQ
11.1 原来的 Spring afterCommit 有什么隐患
第 12 章中,订单服务使用:
java
TransactionSynchronizationManager.registerSynchronization(
new TransactionSynchronization() {
@Override
public void afterCommit() {
orderMessageProducer.sendOrderCreatedMessage(message);
}
}
);
当时只有订单服务本地事务,它能够保证:
text
订单本地事务提交后再发 MQ
接入 Seata 后,订单本地事务只是全局事务中的一个分支。
执行时机可能是:
text
商品分支本地提交
↓
订单分支本地提交
↓
Spring 本地 afterCommit 发送 RabbitMQ
↓
Seata 才继续完成全局事务提交
极端情况下可能出现:
text
订单本地事务已经提交
↓
RabbitMQ 消息已经发送
↓
Seata 全局事务随后失败并回滚
↓
订单和库存被补偿恢复
↓
但 MQ 消息无法收回
所以发送时机需要从:
text
Spring 本地事务提交后
改成:
text
Seata 全局事务确认提交后
11.2 新增订单消息全局事务钩子
新增:
text
cloud-order
└── src/main/java
└── com.example.cloud.order.mq
└── OrderCreatedMessageTransactionHook.java
java
package com.example.cloud.order.mq;
import com.example.cloud.api.message.OrderCreatedMessage;
import io.seata.tm.api.transaction.TransactionHook;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
/**
* 订单创建消息的 Seata 全局事务钩子。
*
* 只有 Seata 全局事务真正提交完成后,
* 才发送订单创建消息。
*/
@Slf4j
@RequiredArgsConstructor
public class OrderCreatedMessageTransactionHook
implements TransactionHook {
private final OrderMessageProducer orderMessageProducer;
private final OrderCreatedMessage message;
@Override
public void beforeBegin() {
// 本章不需要处理。
}
@Override
public void afterBegin() {
// 本章不需要处理。
}
@Override
public void beforeCommit() {
// 此时全局事务还没有确认提交,不能发送消息。
}
/**
* Seata 全局事务确认提交后调用。
*/
@Override
public void afterCommit() {
log.info(
"Seata 全局事务提交完成,开始发送订单创建消息,"
+ "messageId={},orderId={}",
message.getMessageId(),
message.getOrderId()
);
orderMessageProducer.sendOrderCreatedMessage(message);
}
@Override
public void beforeRollback() {
// 本章不需要处理。
}
/**
* Seata 全局事务回滚完成后调用。
*/
@Override
public void afterRollback() {
log.warn(
"Seata 全局事务已回滚,不发送订单创建消息,"
+ "messageId={},orderId={}",
message.getMessageId(),
message.getOrderId()
);
}
@Override
public void afterCompletion() {
// 全局事务提交或回滚后的统一结束回调。
}
}
这个类不加 @Component。
原因是每笔订单都有自己的 message,需要在订单方法中动态创建一个独立 Hook。
11.3 替换原来的 Spring 本地事务回调
OrderServiceImpl 新增:
java
import com.example.cloud.order.mq.OrderCreatedMessageTransactionHook;
import io.seata.tm.api.transaction.TransactionHookManager;
删除原来的:
java
TransactionSynchronizationManager.registerSynchronization(...)
替换成:
java
TransactionHookManager.registerHook(
new OrderCreatedMessageTransactionHook(
orderMessageProducer,
message
)
);
订单方法后半段演进为:
java
boolean success = save(order);
if (!success) {
throw new BizException(
ErrorCode.BIZ_ERROR,
"创建订单失败"
);
}
/*
* 构建订单创建消息。
*
* 这里仍然不能直接发送 RabbitMQ 消息。
* 因为当前 Seata 全局事务还没有完成最终提交。
*/
OrderCreatedMessage message = buildOrderCreatedMessage(order);
/*
* 订单和本地消息记录仍处于同一个订单服务本地事务。
*/
mqMessageLogService.saveOrderCreatedMessage(
message,
OrderMqConstant.ORDER_EXCHANGE,
OrderMqConstant.ORDER_CREATED_ROUTING_KEY
);
/*
* 只有 Seata 全局事务真正提交后才发送 MQ。
*/
TransactionHookManager.registerHook(
new OrderCreatedMessageTransactionHook(
orderMessageProducer,
message
)
);
return order;
11.4 临时移动 quantity=77 的位置
前面 quantity=77 位于:
text
扣库存后、保存订单前
为了验证:
text
订单 INSERT 已经执行;
本地消息 INSERT 已经执行;
Hook 已经注册;
全局事务随后回滚;
MQ 仍然不能发送。
本阶段暂时将 quantity=77 移到 Hook 注册后、return order 前:
java
TransactionHookManager.registerHook(
new OrderCreatedMessageTransactionHook(
orderMessageProducer,
message
)
);
/*
* 阶段八临时故障演练。
*/
if (Integer.valueOf(77).equals(request.getQuantity())) {
log.error(
"订单及本地消息已经执行保存,"
+ "现在模拟异常触发全局回滚,"
+ "xid={},orderId={},messageId={}",
RootContext.getXID(),
order.getId(),
message.getMessageId()
);
throw new BizException(
ErrorCode.BIZ_ERROR,
"模拟全局事务提交前异常"
);
}
return order;
11.5 正常提交验证
重启后发送 quantity=1。


重点日志顺序:
text
Seata 全局事务提交成功
↓
OrderCreatedMessageTransactionHook.afterCommit
↓
Seata 全局事务提交完成,开始发送订单创建消息
↓
发送订单创建消息完成
RabbitMQ Confirm、生产者日志和消费者日志来自不同线程,后面的日志可能交错,但必须确认 Hook 是在全局提交后才开始发送。
正常结果:
| 数据 | 预期 |
|---|---|
| 库存 | 1000 → 999 |
t_order |
新增 |
t_mq_message_log |
新增并最终发送成功 |
t_order_event_log |
新增消费记录 |
| Hook | 执行 afterCommit() |
11.6 回滚验证
库存恢复为1000后发送移动后的 quantity=77。
请求发送前↓

请求发送后:


预期:
text
订单 INSERT 已执行
本地消息 INSERT 已执行
Hook 已注册
↓
订单服务抛异常
↓
Seata 全局回滚
↓
Hook.afterRollback
↓
不发送 RabbitMQ 消息
数据库结果:
| 数据 | 预期 |
|---|---|
| 商品库存 | 恢复为 1000 |
t_order |
本次记录回滚,不存在 |
t_mq_message_log |
本次记录回滚,不存在 |
| RabbitMQ | 未发送本次消息 |
t_order_event_log |
无本次消费记录 |
afterRollback() 不需要手工删除本地消息记录,因为 t_order 和 t_mq_message_log 本来就在订单服务本地事务中,会一起回滚。
11.7 全局提交后 MQ 发送失败怎么办
改成 Seata afterCommit() 后,发送时机正确了,但仍可能发生:
text
全局事务已经提交
↓
afterCommit 开始发送 MQ
↓
RabbitMQ 宕机或网络失败
此时不能再回滚订单和库存,因为全局事务已经结束。
第 12 章的本地消息表继续发挥作用:
text
全局事务内已保存 t_mq_message_log
↓
全局提交后立即发送
↓
发送成功:Confirm 更新状态
↓
发送失败:保留待发送或失败状态
↓
MqMessageRetryTask 定时补偿
所以引入 Seata 后不能删除本地消息表。
11.8 清理 quantity=77
本阶段验证完成后,彻底删除 quantity=77 临时代码。
至此所有特殊数量代码都应删除:
| 特殊数量 | 用途 | 最终状态 |
|---|---|---|
66 |
RabbitMQ 消费异常 | 删除 |
77 |
Seata 异常回滚 | 删除 |
88 |
全局锁冲突 | 删除 |
99 |
吞异常误提交 | 删除 |
12、阶段九:最终代码与正式事务链路
12.1 最终 OrderServiceImpl
。
java
package com.example.cloud.order.service.impl;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.example.cloud.api.message.OrderCreatedMessage;
import com.example.cloud.api.product.dto.ProductDTO;
import com.example.cloud.api.user.dto.UserDTO;
import com.example.cloud.common.context.UserContext;
import com.example.cloud.common.exception.BizException;
import com.example.cloud.common.result.ErrorCode;
import com.example.cloud.common.result.Result;
import com.example.cloud.order.client.ProductClient;
import com.example.cloud.order.client.UserClient;
import com.example.cloud.order.dto.CreateOrderRequest;
import com.example.cloud.order.entity.Order;
import com.example.cloud.order.mapper.OrderMapper;
import com.example.cloud.order.mq.OrderCreatedMessageTransactionHook;
import com.example.cloud.order.mq.OrderMessageProducer;
import com.example.cloud.order.mq.OrderMqConstant;
import com.example.cloud.order.service.MqMessageLogService;
import com.example.cloud.order.service.OrderService;
import io.seata.core.context.RootContext;
import io.seata.spring.annotation.GlobalTransactional;
import io.seata.tm.api.transaction.TransactionHookManager;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.support.TransactionSynchronization;
import org.springframework.transaction.support.TransactionSynchronizationManager;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
/**
* 订单业务实现类。
*/
@Service
@RequiredArgsConstructor
@Slf4j
public class OrderServiceImpl
extends ServiceImpl<OrderMapper, Order>
implements OrderService {
private final UserClient userClient;
private final ProductClient productClient;
private final OrderMessageProducer orderMessageProducer;
private final MqMessageLogService mqMessageLogService;
/**
* 创建订单。
*
* 当前用户 ID 不再由客户端请求体传入。
* 只从 UserContext 获取。
*/
@Override
@GlobalTransactional(
name = "create-order-global-transaction",
rollbackFor = Exception.class
)
@Transactional(rollbackFor = Exception.class)
public Order createOrder(CreateOrderRequest request) {
if (request == null) {
throw new BizException(
ErrorCode.PARAM_ERROR,
"订单参数不能为空"
);
}
if (request.getProductId() == null) {
throw new BizException(
ErrorCode.PARAM_ERROR,
"商品 ID 不能为空"
);
}
if (request.getQuantity() == null
|| request.getQuantity() <= 0) {
throw new BizException(
ErrorCode.PARAM_ERROR,
"购买数量必须大于 0"
);
}
log.info(
"创建订单进入全局事务,xid={},productId={},quantity={}",
RootContext.getXID(),
request.getProductId(),
request.getQuantity()
);
/*
* 1. 从当前请求上下文获取可信用户 ID。
*
* 不再信任客户端请求体中的 userId。
*/
Long userId = UserContext.requireUserId();
/*
* 2. 远程校验用户。
*/
UserDTO user = requireData(
userClient.getById(userId),
"用户不存在或用户服务调用失败"
);
if (user.getStatus() == null
|| user.getStatus() != 1) {
throw new BizException(
ErrorCode.BIZ_ERROR,
"用户已禁用"
);
}
/*
* 3. 远程查询商品。
*/
ProductDTO product = requireData(
productClient.getById(
request.getProductId()
),
"商品不存在或商品服务调用失败"
);
if (product.getStatus() == null
|| product.getStatus() != 1) {
throw new BizException(
ErrorCode.BIZ_ERROR,
"商品未上架"
);
}
/*
* 4. 远程扣减库存。
*/
ensureSuccess(
productClient.deductStock(
request.getProductId(),
request.getQuantity()
),
"库存扣减失败"
);
/*
* 5. 使用可信用户身份和真实商品信息落库。
*/
Order order = new Order();
order.setUserId(userId);
order.setProductId(request.getProductId());
order.setProductName(product.getName());
order.setQuantity(request.getQuantity());
BigDecimal amount = product.getPrice()
.multiply(
BigDecimal.valueOf(
request.getQuantity()
)
);
order.setAmount(amount);
order.setStatus(0);
boolean success = save(order);
if (!success) {
throw new BizException(
ErrorCode.BIZ_ERROR,
"创建订单失败"
);
}
/*
* 订单保存成功后,不要立刻发送 MQ 消息。
*
* 当前方法有 @Transactional。
* 如果事务还没提交就发送消息,
* 可能出现"消息发出去了,但订单事务回滚了"的不一致。
*
* 所以这里注册 afterCommit 回调:
* 只有订单事务真正提交成功后,才发送订单创建消息。
*/
OrderCreatedMessage message = buildOrderCreatedMessage(order);
/*
* 先在本地消息表中保存一条待发送记录。
*
* 注意:
* 这一步仍然处于创建订单的事务中。
* 如果订单事务回滚,这条 MQ 消息记录也会一起回滚。
*
* 如果订单事务提交成功,但后续 MQ 发送失败,
* 至少 t_mq_message_log 中还有一条待发送或失败记录,
* 后面可以靠定时任务补偿。
*/
mqMessageLogService.saveOrderCreatedMessage(
message,
OrderMqConstant.ORDER_EXCHANGE,
OrderMqConstant.ORDER_CREATED_ROUTING_KEY
);
/*
* 注册 Seata 全局事务钩子。
*
* 不能继续使用 Spring 本地事务的 afterCommit,
* 因为本地事务提交时,Seata 全局事务可能还没有完成最终提交。
*
* 只有 Seata 全局事务真正提交成功后,
* 才发送订单创建消息。
*/
TransactionHookManager.registerHook(
new OrderCreatedMessageTransactionHook(
orderMessageProducer,
message
)
);
return order;
}
/**
* 构建订单创建消息。
*
* 注意:
* 这个消息表示"订单已经创建"这个业务事件。
* 它不是数据库实体,也不是前端请求对象。
*/
private OrderCreatedMessage buildOrderCreatedMessage(Order order) {
return OrderCreatedMessage
.builder()
.messageId(UUID.randomUUID().toString())
.orderId(order.getId())
.userId(order.getUserId())
.productId(order.getProductId())
.quantity(order.getQuantity())
.amount(order.getAmount())
.createdAt(LocalDateTime.now().format(
DateTimeFormatter.ISO_LOCAL_DATE_TIME
))
.build();
}
private <T> T requireData(
Result<T> result,
String defaultMessage
) {
if (result == null
|| result.getCode() != 0
|| result.getData() == null) {
String message = result == null
? defaultMessage
: result.getMessage();
throw new BizException(
ErrorCode.BIZ_ERROR,
message
);
}
return result.getData();
}
private void ensureSuccess(
Result<?> result,
String defaultMessage
) {
if (result == null
|| result.getCode() != 0) {
String message = result == null
? defaultMessage
: result.getMessage();
throw new BizException(
ErrorCode.BIZ_ERROR,
message
);
}
}
}
注意:
text
TransactionHookManager.registerHook(...)
只能注册一次。
如果同一个消息 Hook 被注册两次,全局提交后会主动发送两次相同 messageId。消费者幂等虽然可能挡住第二次业务写入,但生产者主动重复发送仍然是错误的。
12.2 最终 ProductServiceImpl
java
package com.example.cloud.product.service.impl;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.example.cloud.common.context.UserContext;
import com.example.cloud.common.exception.BizException;
import com.example.cloud.common.result.ErrorCode;
import com.example.cloud.product.constant.ProductCacheKey;
import com.example.cloud.product.entity.Product;
import com.example.cloud.product.mapper.ProductMapper;
import com.example.cloud.product.service.ProductService;
import io.seata.core.context.RootContext;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
/**
* 商品业务实现类。
*/
@Service
@Slf4j
@RequiredArgsConstructor
public class ProductServiceImpl
extends ServiceImpl<ProductMapper, Product>
implements ProductService {
private final StringRedisTemplate stringRedisTemplate;
/**
* 数据库原子条件更新版本。
*
* 和朴素版的区别:
*
* 朴素版:
* SELECT 查询库存
* ↓
* Java 判断库存
* ↓
* UPDATE 修改库存
*
* 当前版本:
* 一条 UPDATE SQL 同时完成库存判断和扣减。
*
* 正确性由数据库保证,不再依赖 Java 内存中的旧数据。
*/
@Override
@Transactional(rollbackFor = Exception.class)
public void deductStock(Long productId, Integer quantity) {
log.info(
"扣减库存,当前用户 ID:{}",
UserContext.getUserId()
);
// 1. 参数校验仍然在 Java 层完成。
if (quantity == null || quantity <= 0) {
throw new BizException(
ErrorCode.PARAM_ERROR,
"扣减数量必须大于 0"
);
}
log.info(
"商品扣库存进入分支事务,xid={},productId={},quantity={}",
RootContext.getXID(),
productId,
quantity
);
/*
* 2. 执行数据库原子扣减。
*
* SQL 中已经包含:
* id 匹配
* status = 1
* stock >= quantity
*
* 因此,不需要先查询库存再更新。
*/
int affectedRows = baseMapper.deductStock(productId, quantity);
// 影响行数为 1,表示成功扣减库存。
if (affectedRows == 1) {
String redisKey = ProductCacheKey.productDetailKey(productId);
stringRedisTemplate.delete(redisKey);
log.info(
"库存扣减 SQL 执行成功,删除商品详情缓存,"
+ "等待事务最终提交,id={},redisKey={}",
productId,
redisKey
);
return;
}
/*
* 3. 如果影响行数为 0,说明某个条件没有满足。
*
* 此处再次查询商品,不是为了决定是否可以扣减,
* 而是为了给调用方返回更准确的错误信息。
*
* 真正决定库存能否扣减的,是上面的原子 UPDATE SQL。
*/
Product product = getById(productId);
if (product == null) {
throw new BizException(
ErrorCode.NOT_FOUND,
"商品不存在"
);
}
if (product.getStatus() == null || product.getStatus() != 1) {
throw new BizException(
ErrorCode.BIZ_ERROR,
"商品未上架,不能扣减库存"
);
}
throw new BizException(
ErrorCode.BIZ_ERROR,
"商品库存不足"
);
}
}
商品成功日志改为:
text
库存扣减 SQL 执行成功,等待事务最终提交
是因为阶段六已经证明:
text
UPDATE 影响 1 行
仍可能在提交阶段因为全局锁冲突而回滚。
12.3 最终事务链路
text
参数校验
↓
@GlobalTransactional 开启全局事务
↓
订单服务打印 XID
↓
Feign 查询用户和商品
↓
Feign 调商品服务扣库存
↓
商品服务使用相同 XID
↓
保存 t_order
↓
保存 t_mq_message_log
↓
注册 Seata TransactionHook
↓
方法正常返回
↓
Seata 全局事务提交
↓
Hook.afterCommit()
↓
发送 RabbitMQ
↓
Confirm / Return 更新消息状态
↓
消费者处理并写 t_order_event_log
12.4 最终正常冒烟测试
清理全部临时代码后:
sql
UPDATE t_product
SET stock = 1000
WHERE id = 1;
bash
redis-cli del cloud:product:detail:1
发送:
json
{
"productId": 1,
"quantity": 1
}
最终确认:
| 验证项 | 结果 |
|---|---|
| 请求 | 成功 |
| Order 与 Product XID | 非空且相同 |
| 库存 | 1000 → 999 |
t_order |
新增 |
t_mq_message_log |
新增并确认成功 |
t_order_event_log |
新增 |
| TransactionHook | 只执行一次发送 |
undo_log |
事务结束后为空 |
13、Seata AT 原理再梳理一次
13.1 第一阶段
商品分支执行库存 UPDATE:
text
解析 SQL
↓
查询修改前数据,形成 before image
↓
执行 UPDATE
↓
查询修改后数据,形成 after image
↓
生成 rollback_info
↓
申请全局锁
↓
业务数据与 undo_log 一起本地提交
订单分支保存 t_order 和 t_mq_message_log 也会形成自己的 AT 分支。
13.2 第二阶段提交
全局事务成功时:
text
TC 通知各分支提交
↓
第一阶段本地事务已经提交
↓
第二阶段主要清理 undo_log
↓
通常异步快速完成
13.3 第二阶段回滚
全局事务失败时:
text
TC 通知 RM 回滚
↓
读取 undo_log
↓
使用 after image 校验当前数据是否被其他写操作改变
↓
根据 before image 恢复业务数据
↓
删除 undo_log
13.4 全局锁主要防什么
全局锁重点防止受 Seata 管理的路径发生脏写:
text
事务 A 第一阶段已经修改商品 1
↓
事务 A 全局事务尚未结束
↓
事务 B 也尝试提交对商品 1 的修改
↓
B 获取 t_product:1 全局锁失败
↓
B 不能成功提交该修改
但普通 SELECT 不会自动阻塞到全局事务结束,因此阶段六的请求 B 能读取到 912。
所以不要简单写:
text
Seata AT 等同于数据库串行化隔离。
更准确的表达是:
text
Seata AT 通过 undo_log 和全局锁,
让参与全局事务的写操作能够整体提交或补偿回滚,
并防止受管理路径的并发脏写。
14、Seata 自身四种模式怎么选
| 对比项 | AT | TCC | Saga | XA |
|---|---|---|---|---|
| 第一阶段 | 执行业务 SQL并记录 undo | Try 预留资源 | 每步直接提交本地事务 | XA prepare |
| 第二阶段提交 | 异步清理 undo | Confirm | 流程正常结束 | XA commit |
| 第二阶段失败 | 根据 undo 补偿 | Cancel | 调用反向补偿服务 | XA rollback |
| 业务侵入 | 低 | 高 | 中到高 | 低到中 |
| 是否依赖关系型数据库 | 是 | 否,可管理业务资源 | 否 | 依赖 XA 资源 |
| 是否需要业务补偿代码 | 通常不需要 | 必须 | 必须 | 通常不需要 |
| 锁与隔离 | 全局锁,适合短事务 | 业务自行预留资源 | 通常不持全局锁,隔离较弱 | 数据库资源持有时间较长 |
| 适合场景 | 常规订单、库存、账户 CRUD | 核心交易、高性能、精细资源控制 | 长业务流程、外部系统 | 数据库 XA 能力成熟且吞吐要求可接受 |
14.1 为什么本章不选 TCC
如果用 TCC 扣库存,需要设计:
text
Try:冻结或预留库存
Confirm:正式扣除冻结库存
Cancel:释放冻结库存
同时要处理:
- 幂等
- 空回滚
- 悬挂
- Confirm / Cancel 重试
它的控制力更强,但业务改造明显更大。
当前学习项目只是普通 MySQL 库存 UPDATE,因此先使用 AT 更合适。
14.2 为什么本章不选 Saga
Saga 更适合:
text
一个流程持续很久;
包含许多步骤;
参与者可能是外部系统;
不适合一直持有事务锁;
可以接受补偿后的最终一致性。
当前订单与库存链路短、数据库可控,不需要先引入状态机和业务补偿服务。
14.3 为什么本章不选 XA
XA 更接近传统数据库两阶段提交,依赖资源管理器的 XA 能力。
它可以减少业务补偿代码,但通常会更长时间占用数据库连接和资源,吞吐量与故障恢复复杂度需要认真评估。
本章的目标是 Spring Cloud Alibaba 项目中低侵入地解决普通 MySQL 写一致性,所以采用 AT。
15、Seata 与同类分布式事务方案对比
15.1 Seata、DTM、Hmily、ByteTCC、TX-LCN
| 方案 | 主要模式与特点 | 生态侧重点 | 更适合的考虑方向 |
|---|---|---|---|
| Seata | AT、TCC、Saga、XA;AT 低侵入、与 Spring Cloud Alibaba 集成自然 | Java / Spring Cloud / Dubbo,国内微服务生态常见 | 已使用 Spring Cloud Alibaba,希望 AT 少改业务代码 |
| DTM | Saga、TCC、XA、二阶段消息、Outbox、Workflow;支持多语言 SDK 和多种数据存储 | 多语言、跨技术栈 | Go、Java、PHP、C#、Python、Node.js 混合服务,或偏工作流 / Outbox |
| Hmily | 重点提供 TCC、TAC,也有 XA 相关模块;支持 Spring Cloud、Dubbo 等 RPC | Java 微服务 | 更偏业务 TCC 或自动补偿 SQL 路线 |
| ByteTCC | 以 Try-Confirm-Cancel 为核心,兼容 JTA,支持 Spring Cloud、Dubbo | Java、TCC/JTA | 已有成熟 TCC 资源模型,愿意实现 Confirm / Cancel |
| TX-LCN | 面向 Spring Cloud、Dubbo、Motan,使用事务控制端与客户端协调 | Java 微服务、关系型数据库 | 研究 LCN/TCC/TXC 等历史方案或维护已有项目 |
15.2 不要只按"功能多"选择
选择分布式事务框架时,应先问:
| 维度 | 需要回答的问题 |
|---|---|
| 事务模型 | 需要 AT、TCC、Saga、XA,还是消息最终一致性? |
| 业务侵入 | 能否接受开发 Try / Confirm / Cancel / 补偿接口? |
| 数据源 | 是否都是关系型数据库?是否包含 Redis、MongoDB、外部服务? |
| 技术栈 | 全部 Java,还是多语言微服务? |
| 事务时长 | 是毫秒级短事务,还是分钟级长流程? |
| 一致性要求 | 必须同步决定成败,还是允许最终一致? |
| 性能成本 | 能否接受全局锁、XA 资源占用或补偿重试? |
| 运维能力 | 是否能运维事务协调器集群、事务日志、告警和人工补偿? |
15.3 当前项目为什么选择 Seata AT
cloud-demo 当前特点:
text
Spring Cloud Alibaba 技术栈
MySQL 关系型数据库
订单和库存是短链路
业务 SQL 以普通 INSERT / UPDATE 为主
希望尽量少改造业务代码
所以本章选择:
text
Seata AT
不是因为它在所有场景中都最好,而是因为它最贴合当前项目条件。
16、本地事务、Seata 与 RabbitMQ 的职责边界
当前项目最终不是"只用 Seata",而是三层组合。
| 层次 | 技术 | 管理范围 | 解决的问题 |
|---|---|---|---|
| 单服务本地一致性 | Spring @Transactional |
t_order、t_mq_message_log |
订单与本地消息记录一起提交或回滚 |
| 跨服务写一致性 | Seata AT | 商品库存、订单、本地消息记录 | 关键数据库写整体提交或补偿回滚 |
| 异步事件最终一致性 | RabbitMQ、本地消息表、Confirm、补偿任务 | 已提交订单与后续消费者 | 全局提交后可靠发送和消费事件 |
16.1 Seata 与 RabbitMQ 不是互相替代
| 对比项 | Seata AT | RabbitMQ 可靠消息 |
|---|---|---|
| 核心目标 | 多个关键数据库写一起成功或失败 | 异步通知、削峰、解耦、最终一致性 |
| 调用方式 | 同步参与主业务事务 | 主业务提交后异步执行 |
| 失败处理 | undo_log 补偿回滚 |
重试、DLQ、本地消息补偿 |
| 对主链路延迟 | 增加全局事务协调成本 | 消费者不阻塞主流程 |
| 当前项目用途 | 订单与库存 | 订单创建后的事件通知 |
合理分工:
text
创建订单 + 扣库存
→ Seata
订单创建后的日志、通知、积分、统计
→ RabbitMQ
不要把所有后续动作都塞进 Seata 全局事务,否则事务范围会越来越长、全局锁持有时间越来越久。
也不要把必须同步决定成败的订单与库存问题,简单推给 MQ 后完全不考虑补偿和业务状态设计。
16.2 三层最终链路
text
第 1 层:订单服务本地事务
────────────────────────
保存 t_order
保存 t_mq_message_log
一起提交或回滚
第 2 层:Seata 全局事务
────────────────────────
cloud-product 扣库存
cloud-order 保存订单和本地消息
整体提交或整体回滚
第 3 层:RabbitMQ 可靠消息
────────────────────────
全局事务提交后发送
发送失败由本地消息表补偿
消费者通过幂等、ACK、重试、DLQ 保证处理可靠性
17、本章真实踩坑与容易误解的现象
| 现象 | 容易产生的误解 | 正确解释 |
|---|---|---|
RM will register :jdbc:mysql://... |
已经开启全局事务 | 只是 RM 识别并注册数据源资源 |
创建 undo_log 后没有重启服务 |
会不会不生效 | 只新增表结构,没有代码和配置变化,无需机械重启 |
undo_log 最终为空 |
Seata 没工作 | 提交或回滚后会清理,最终为空正常 |
| Order 与 Product XID 相同 | 只是日志巧合 | 说明 Feign 调用成功传播同一全局事务上下文 |
| 请求 B 查询到库存 912 | Seata 回滚失败 | A 第一阶段已本地提交,普通 SELECT 可以看到中间状态 |
UPDATE 显示 Updates: 1 后仍失败 |
SQL 日志矛盾 | SQL 执行成功不等于事务提交成功,提交时可能抢全局锁失败 |
QueryTimeoutException |
MySQL 查询太慢 | 实际根因是 Global lock wait timeout |
| 请求 A 最终回滚 | 被请求 B 失败连带回滚 | A 是自身 quantity=88 异常触发回滚,B 只是锁冲突失败 |
| 异常被 catch 后提交 | rollbackFor 没生效 |
异常被吞掉,事务代理只看到正常返回 |
Spring afterCommit |
等同于 Seata 全局提交 | 它只代表订单服务本地事务提交 |
| Hook 重复注册 | 消费者幂等能兜底所以没关系 | 生产者会主动重复发送,必须只注册一次 |
| Protostuff Parser 警告 | AT 一定无法回滚 | 本次实际回滚成功,属于未阻断链路的可选解析器警告 |
18、学习方案与生产环境的差距
本章完成了 Seata AT 的核心学习链路,但不是生产最终方案。
| 当前做法 | 学习阶段 | 生产环境还要考虑 |
|---|---|---|
| Seata Server 单机 | 足够 | TC 集群、高可用、故障转移 |
| Server 使用 file 存储 | 足够 | 使用数据库持久化事务状态 |
客户端直连 127.0.0.1:8091 |
足够 | 通过注册中心发现 Seata Server |
两个服务共用 cloud_demo |
便于学习 | 通常按服务拆分独立数据库 |
共用一张 undo_log |
当前可用 | 每个参与 AT 的数据库分别建表 |
| XID 使用 INFO 日志 | 方便观察 | 生产可降为 DEBUG,配合链路追踪 |
| 全局事务范围较长 | 可观察完整流程 | 尽量减少远程查询和耗时操作,缩短事务 |
| 全局锁冲突直接失败 | 便于演练 | 设计友好错误、有限重试和业务降级 |
| Seata Hook 发送 MQ | 当前可用 | 关注版本兼容、Hook 异常监控和补偿任务 |
| MQ 补偿任务单实例扫描 | 已在第 12 章完成 | 多实例抢占、乐观锁、任务分片、告警 |
| Redis 直接删除缓存 | 学习方案 | 事务回滚后的缓存一致性需要更完整设计 |
18.1 全局事务要尽量短
事务越长:
- 全局锁持有越久;
- 并发冲突越多;
- TC 和 RM 压力越大;
- 回滚成本越高;
- 用户请求延迟越高。
因此生产代码中,应尽量:
text
事务外完成不影响一致性的准备工作;
事务内只保留真正需要整体提交或回滚的关键写操作;
不要在全局事务中 sleep、调用慢接口或执行无关任务。
18.2 缓存一致性的额外边界
本章商品服务在库存 UPDATE 成功后立即删除 Redis 缓存,但此时只是 SQL 执行成功,Seata 全局事务尚未最终提交。
如果全局事务随后回滚:
text
缓存已删除
数据库库存恢复
结果通常只是下一次读取重新查询数据库并缓存正确库存,不会留下错误缓存,因此当前学习方案可以工作。
但更复杂的生产场景还要考虑:
- 回滚期间并发读重建缓存;
- 多级缓存;
- 缓存删除失败;
- 延迟双删或消息驱动缓存更新;
- 数据库与缓存的最终一致性监控。
19、本章总结
这一章不是从 Seata 配置开始,而是先用真实订单链路复现:
text
库存扣减成功,订单保存失败,库存无法回滚。
随后启动 Seata Server,让 cloud-order 和 cloud-product 作为 Seata 客户端接入,并通过 undo_log 和 @GlobalTransactional 建立全局事务。
我们通过 XID 日志确认订单服务和商品服务属于同一笔全局事务,并分别验证了:
- 正常下单时全局提交;
- 订单服务后置异常时库存补偿回滚;
- 库存不足时商品 SQL 未修改数据;
- 两笔事务修改同一商品时发生全局锁竞争;
- SQL 影响一行不代表事务已经提交;
- 异常被吞掉会让事务代理误判成功;
- 捕获后重新抛出才能正常回滚。
最后,我们发现第 12 章的 Spring 本地事务 afterCommit 已经不足以表达"Seata 全局事务成功",因此新增 TransactionHook:
text
全局事务提交
↓
afterCommit 发送 RabbitMQ
全局事务回滚
↓
afterRollback 不发送消息
当前项目形成三层一致性保障:
text
@Transactional
保证订单和本地消息记录的单服务本地一致性;
@GlobalTransactional
保证订单和商品库存的跨服务写一致性;
RabbitMQ + 本地消息表
保证全局提交后的异步事件最终一致性。
本章最值得记住的几句话:
text
引入 Seata 依赖,不等于开启了全局事务。
SQL 执行成功,不等于事务已经提交成功。
发生过异常,不等于事务一定会回滚;
异常必须让事务代理感知到。
Spring 本地 afterCommit,不等于 Seata 全局事务已经提交。
Seata 负责关键数据库写的一致性,
RabbitMQ 负责全局提交后的异步事件可靠性。
20、下一章预告
第 13 章解决了订单服务与商品服务之间的跨服务事务问题,并进一步理清了本地事务、Seata 全局事务和 RabbitMQ 可靠消息各自负责的范围。
第 14 章将为当前微服务项目接入 Knife4j 接口文档。
下一章会依次完成:
text
先让单个业务服务生成 OpenAPI 文档
↓
为 Controller、请求参数和返回结果补充接口说明
↓
在文档页面中携带 JWT 调试受保护接口
↓
通过 Gateway 聚合用户、商品、订单和认证服务的文档
↓
演练文档资源被网关鉴权拦截、路径错误和分组混乱
↓
说明生产环境如何关闭接口文档