Spring Cloud 学习与实践(13):使用 Seata 解决分布式事务问题

文章目录

  • [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-ordercloud-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-ordercloud-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-ordercloud-product 的全局事务
@Transactional cloud-order 内部 t_ordert_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 正确的异常边界

写法 事务结果
完全不捕获,直接向外抛 回滚
catchthrow 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_ordert_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_ordert_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_ordert_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-ordercloud-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 聚合用户、商品、订单和认证服务的文档
    ↓
演练文档资源被网关鉴权拦截、路径错误和分组混乱
    ↓
说明生产环境如何关闭接口文档
相关推荐
渣渣灰飞1 小时前
MySQL 系统学习 第四阶段:MySQL 高级 第一节:索引(Index)
数据库·学习·mysql
Devin~Y2 小时前
电商场景下的Java面试实战:从Spring Boot微服务到Kafka、Redis与AI RAG
java·spring boot·redis·elasticsearch·spring cloud·微服务·kafka
星子yu2 小时前
【学习】怎么学好数据结构
数据结构·学习
~kiss~2 小时前
MoE的痛点:离散路由
学习
Amazing_Cacao2 小时前
CFCA精品可可工艺师初级校准:暴力破解机器黑盒,实现物理参数与最终风味的精准对齐
笔记·学习
SizeTheMoment3 小时前
Spring Cloud 微服务认证体系深度解析:从架构设计到安全实践
安全·spring cloud·微服务
风若飞3 小时前
Tomcat 9.0.118 分布式 Redis Session 配置指南
redis·分布式·tomcat
浮江雾3 小时前
Flutter第三节----Dart中的数据类型
android·开发语言·学习·flutter·入门·函数
风和先行4 小时前
Android 数据库相关学习总结
android·数据库·学习