Seata 分布式事务原理与实战

Seata是什么

Seata(Simple Extensible Autonomous Transaction Architecture)是阿里开源的一站式分布式事务解决方案,支持 AT / XA / TCC / Saga 四种事务模式。它的核心能力是让跨服务、跨数据库的事务像单库事务一样简单——业务代码几乎不需要改动。

一句话定位: Nacos 管服务发现,Sentinel 管流量防护,Seata 管数据一致性。

为什么需要

微服务架构下,一个业务操作经常跨越多个服务,每个服务有自己的数据库:

graph LR subgraph 下单业务 O[订单服务<br>order_db] -->|扣库存| S[库存服务<br>stock_db] O -->|扣余额| A[账户服务<br>account_db] end subgraph 问题 P1["订单已创建 ❌ 库存没扣"]:::err P2["余额已扣 ❌ 库存没减"]:::err end classDef err fill:#ffcdd2

没有分布式事务的后果:订单创建成功但库存扣减失败、余额已扣但订单未生成——数据不一致。数据库本地事务无法跨服务,必须有一个协调者来保证"全成功或全回滚"。

核心架构

Seata 定义了三个角色(比 2PC 的协调者/参与者更细致):

graph LR subgraph TM 事务管理者 TM[Business Service<br>@GlobalTransactional] end subgraph TC 事务协调者 TC[Seata Server<br>集群] end subgraph RM 资源管理者 RM1[Order Service] --> DB1[(order_db)] RM2[Stock Service] --> DB2[(stock_db)] RM3[Account Service] --> DB3[(account_db)] end TM -->|① 开启全局事务| TC RM1 -->|② 注册分支事务| TC RM2 -->|② 注册分支事务| TC RM3 -->|② 注册分支事务| TC TC -->|③ 通知提交/回滚| RM1 TC -->|③ 通知提交/回滚| RM2 TC -->|③ 通知提交/回滚| RM3 style TM fill:#e1f5fe style TC fill:#c8e6c9

各角色职责:

角色 全称 做什么
TC Transaction Coordinator Seata 服务端,管理全局事务状态,协调分支提交/回滚
TM Transaction Manager 业务入口,通过 @GlobalTransactional 开启全局事务,告诉 TC 是 commit 还是 rollback
RM Resource Manager 参与事务的业务服务,向 TC 注册分支事务,执行本地 SQL 并报告执行结果

四种事务模式详解

AT 模式(默认,重点)

AT(Auto Transaction)是 Seata 的默认和最常用模式。它的核心思路:一阶段直接提交本地事务 + 保存 undo log 快照用于回滚。对业务代码零侵入。

sequenceDiagram participant TM as TM (@GlobalTransactional) participant RM1 as RM (Order) participant RM2 as RM (Stock) participant TC as TC (Seata Server) TM->>TC: 开启全局事务,获取 xid TM->>RM1: 调用业务(携带 xid) RM1->>RM1: 执行本地 SQL RM1->>RM1: 生成 before-image / after-image RM1->>TC: 注册分支事务 RM1->>RM1: 提交本地事务 ✅ Note over RM1: 【关键】AT 一阶段就提交 TM->>RM2: 调用业务(携带 xid) RM2->>RM2: 执行本地 SQL RM2->>RM2: 生成 before-image / after-image RM2->>TC: 注册分支事务 RM2->>RM2: 提交本地事务 ✅ alt 全部成功 TM->>TC: 全局提交 TC->>RM1: 删除 undo log TC->>RM2: 删除 undo log else 任意失败 TM->>TC: 全局回滚 TC->>RM1: 根据 before-image 生成反向 SQL RM1->>RM1: 执行回滚 TC->>RM2: 根据 before-image 生成反向 SQL RM2->>RM2: 执行回滚 end

AT 的五个关键设计:

设计 作用
一阶段提交 每个分支事务执行完 SQL 直接 commit,不长时间锁数据库资源(跟 XA 的长期锁定形成关键对比)
undo log 快照 before-image(修改前快照)和 after-image(修改后快照)保存在 undo_log 表中,用于回滚时生成反向 SQL
全局锁 TC 维护,防止 AT 事务之间的脏写。分支事务提交前先申请全局锁,拿不到则重试,超时则回滚
数据快照校验 回滚时对比当前数据与 after-image,一致则用 before-image 恢复;不一致说明被非 Seata 事务修改过→人工介入
读未提交隔离 默认全局隔离级别是读未提交,性能至上。需要读已提交时用 SELECT FOR UPDATE(会申请全局锁)

AT 对比 XA 最核心的区别: XA 在一阶段只预留资源(不提交),二阶段才真正提交或回滚,资源锁定时间很长。AT 在一阶段直接提交,二阶段要么删 undo log(提交),要么用 undo log 回滚(回滚),资源锁定时间极短。所以 AT 性能远好于 XA。

XA 模式

完全遵循 2PC 协议。一阶段不提交,二阶段才 commit 或 rollback。强一致,高性能差。

优点 缺点
强一致,满足 ACID 长期锁资源,性能最差
无代码入侵 依赖关系型数据库

TCC 模式

Try-Confirm-Cancel 三段式,每个阶段都需要业务代码手动实现。性能最好,侵入性最大。

优点 缺点
一阶段提交,无全局锁,性能极致 每个阶段都要手写业务代码
不依赖数据库事务,支持非关系型存储 需处理空回滚和业务悬挂问题

TCC 的两个关键陷阱:

  • 空回滚: Try 阻塞时全局事务已超时,触发了 Cancel。但 Try 还没执行,Cancel 做了不应该做的回滚。解决:记录事务状态,Cancel 前检查 Try 是否已执行。
  • 业务悬挂: 空回滚后 Try 才到达,但已无法 Confirm 或 Cancel。解决:Try 执行前检查是否已被 Cancel。

Saga 模式

长事务拆分为多个本地短事务依次提交,失败时按逆序执行补偿操作。

优点 缺点
一阶段提交,无锁 无隔离性,可能有脏写
适合长事务、事件驱动 补偿逻辑需要手动实现

四种模式选型速查

模式 入侵性 一致性 性能 适用场景
AT 最终一致 大多数业务场景,默认选这个
XA 强一致 对一致性要求极严,可以接受性能损失
TCC 最终一致 最高 性能是硬要求,且愿意为每个接口写三套逻辑
Saga 最终一致 长事务、事件驱动、不适合锁资源的场景

新人上手指南:AT 模式完整示例

第一步:启动 Seata Server

# 1. 下载
curl -O https://github.com/apache/incubator-seata/releases/download/v2.0.0/seata-server-2.0.0.zip
unzip seata-server-2.0.0.zip && cd seata

# 2. 配置存储(默认 file 模式,测试可用)
# 生产环境请改为 db 模式:修改 conf/application.yml 中 store.mode=db,并连接 MySQL

# 3. 启动
sh bin/seata-server.sh -p 8091 -h 127.0.0.1
# -p 端口(默认 8091),-h 注册 IP

第二步:在每个参与事务的数据库中创建 undo_log 表

-- AT 模式必须!每个业务数据库都需要这张表
CREATE TABLE IF NOT EXISTS `undo_log`
(
    `id`            BIGINT(20)   NOT NULL AUTO_INCREMENT COMMENT '主键',
    `branch_id`     BIGINT(20)   NOT NULL COMMENT '分支事务ID',
    `xid`           VARCHAR(128) NOT NULL COMMENT '全局事务ID',
    `context`       VARCHAR(128) NOT NULL COMMENT '上下文',
    `rollback_info` LONGBLOB     NOT NULL COMMENT '回滚信息(before/after image)',
    `log_status`    INT(11)      NOT NULL COMMENT '状态',
    `log_created`   DATETIME     NOT NULL COMMENT '创建时间',
    `log_modified`  DATETIME     NOT NULL COMMENT '修改时间',
    PRIMARY KEY (`id`),
    UNIQUE KEY `ux_undo_log` (`xid`, `branch_id`)
) ENGINE = InnoDB AUTO_INCREMENT = 1 DEFAULT CHARSET = utf8mb4 COMMENT = 'AT事务undo日志表';

第三步:添加依赖与配置

<!-- 每个参与事务的服务都要加 -->
<dependency>
    <groupId>com.alibaba.cloud</groupId>
    <artifactId>spring-cloud-starter-alibaba-seata</artifactId>
</dependency>
# application.yml(每个服务各自配置自己的数据源和 Seata)
spring:
  application:
    name: order-service
  datasource:
    url: jdbc:mysql://localhost:3306/order_db?useSSL=false&serverTimezone=Asia/Shanghai
    username: root
    password: root
    driver-class-name: com.mysql.cj.jdbc.Driver

seata:
  application-id: ${spring.application.name}
  tx-service-group: my_tx_group      # 事务分组名,需与 Seata Server 配置一致
  registry:
    type: nacos                       # Seata 用 Nacos 做注册中心
    nacos:
      server-addr: 127.0.0.1:8848
      namespace: ""
      group: SEATA_GROUP
  config:
    type: nacos                       # 配置也放 Nacos
    nacos:
      server-addr: 127.0.0.1:8848
      namespace: ""
      group: SEATA_GROUP
  service:
    vgroup-mapping:
      my_tx_group: default            # 事务分组→Seata 集群名映射
  data-source-proxy-mode: AT          # 启用 AT 数据源代理(自动生成 undo log)

第四步:在入口方法上加 @GlobalTransactional

业务场景: 下单扣库存扣余额,三个操作分属三个服务,必须同时成功或同时失败。

@FeignClient(name = "order-service")
public interface OrderClient {
    @PostMapping("/order/create")
    Result create(@RequestBody OrderReq req);
}

@FeignClient(name = "stock-service")
public interface StockClient {
    @PostMapping("/stock/deduct")
    Result deduct(@RequestBody StockReq req);
}

@FeignClient(name = "account-service")
public interface AccountClient {
    @PostMapping("/account/debit")
    Result debit(@RequestBody AccountReq req);
}

@Service
public class BusinessService {

    @Autowired
    private OrderClient orderClient;
    @Autowired
    private StockClient stockClient;
    @Autowired
    private AccountClient accountClient;

    /**
     * 下单主方法。三处调用的任何一个失败都会导致全部回滚。
     * 对调用方来说,这个方法就像本地事务一样——要么全成,要么全回滚。
     */
    @GlobalTransactional(name = "create-order", rollbackFor = Exception.class)
    public Result purchase(String userId, String commodityCode, int count) {
        // 1. 扣库存
        stockClient.deduct(new StockReq(commodityCode, count));

        // 2. 扣余额
        accountClient.debit(new AccountReq(userId, count * 100));

        // 3. 创建订单
        orderClient.create(new OrderReq(userId, commodityCode, count));

        return Result.ok("下单成功");
    }
}

关于 @GlobalTransactional 的几个细节:

  • rollbackFor = Exception.class:默认只回滚 RuntimeException,一定要显式设为 Exception 才能捕获所有异常
  • 调用链路中的 Feign 调用会自动透传 xid(全局事务 ID),不需要手动处理
  • 如果某个分支不想要 Seata 管理,在 FeignClient 的 fallback 中自行处理即可

验证

# 正常调用:三个表都更新
curl "http://localhost:8080/business/purchase?userId=U001&commodityCode=C001&count=2"
# → 订单表插入一条、库存减2、账户扣200

# 异常场景:在 StockService 中故意抛异常
curl "http://localhost:8080/business/purchase?userId=U001&commodityCode=C001&count=999999"
# → 库存不足抛异常 → 全部回滚,数据库无变化
# 验证:undo_log 表中能看到回滚记录

常见问题

1. @GlobalTransactional 不生效,数据没回滚

  • 确认 seata.data-source-proxy-mode=AT 已配置(否则 Seata 不会拦截数据源生成 undo log)
  • 确认 undo_log 表已在每个业务数据库中创建
  • 确认 Feign 调用链路上的每个服务都正确引入了配置
  • Seata Server 没启动或连通不了:看日志 Could not found global transaction xid

2. undo_log 表缺失

AT 模式强制要求每个业务数据库都有一张 undo_log 表。缺少时一阶段正常提交,但二阶段回滚因无快照而无法执行,导致数据不一致。

3. 全局锁等待超时

多个全局事务同时操作同一行数据时,后到的事务拿不到全局锁,重试超时(默认 30s)后抛 LockConflictException,事务回滚。

常见原因: 高并发热点数据(如秒杀同一商品),业务 SQL 长时间锁表。
对策: 拆分热点、异步扣减、或调大 seata.client.rm.lock.retryInterval 和重试次数。

4. AT 默认读未提交,读到中间状态

AT 默认全局隔离级别是读未提交。需要读已提交时用 @GlobalLock + SELECT FOR UPDATE 申请全局锁。

@GlobalLock
@Transactional
public List<Order> queryUnpaidOrders() {
    return orderMapper.selectForUpdate();
}

面试题

Q1:Seata AT 模式的一阶段和二阶段分别做了什么?与 XA 的本质区别是什么?

参考答案:

AT 一阶段:TM 向 TC 开启全局事务获取 xid。每个 RM 执行业务 SQL 前,Seata 代理数据源拦截 SQL 执行,自动生成 before-image 和 after-image 写入 undo_log 表。然后 RM 注册分支事务到 TC,直接提交本地事务

AT 二阶段:全部成功则各 RM 异步删除 undo_log。任意失败则 RM 根据 undo_log 中的 before-image 生成反向 SQL 回滚。回滚前校验当前数据是否等于 after-image,不一致则报人工介入。

与 XA 的本质区别:

AT XA
一阶段 直接提交,释放资源 只 prepare,不提交,锁定资源
二阶段提交 删 undo log 真正 commit
二阶段回滚 用 before-image 反向恢复 真正 rollback
资源锁定时间 极短
一致性 最终一致 强一致

根本差异: XA 用锁定资源来保一致性,AT 用数据快照 + 补偿来保一致性。这是 Seata AT 性能远超 XA 的原因。

Q2:AT 模式的全局锁和数据快照校验各自解决了什么问题?

参考答案:

这俩是 AT 安全性的双层保障,解决的是不同维度的脏写问题。

全局锁防止 Seata 事务间的脏写: AT 一阶段提交后数据已经可见,此时如果另一个 AT 事务修改同一行数据后回滚,会把前一个事务已经提交的数据也覆盖掉。全局锁确保只有持有锁的事务能执行 SQL,其他 AT 事务必须等锁释放。这样两个 AT 事务之间不会互相脏写。

数据快照校验防止非 Seata 事务的脏写: 但全局锁管不到直接通过数据库客户端手动修改数据的场景。所以 AT 在回滚时会对比当前数据与 after-image。如果不一致,说明有非 Seata 操作改过这行数据。此时 Seata 不会盲目覆盖,而是记录异常日志交由人工处理。

所以完整的安全链条是:全局锁(防 AT 间脏写)+ 数据快照校验(防外部脏写)。缺一不可。

Q3:TCC 为什么会引入空回滚和业务悬挂?怎么解决?

参考答案:

空回滚和业务悬挂都源于同一个根因:TCC 的三个阶段(Try / Confirm / Cancel)是异步的分布式调用,它们的执行顺序可能因为网络超时、线程阻塞而和预期不一致。

空回滚:Try 请求因网络延迟未到达,全局事务已经超时触发了 Cancel。Cancel 执行时发现没有资源需要释放,但它又必须正常返回(否则 TM 会认为取消失败)。这就产生了"空取消"。

业务悬挂:空回滚之后,Try 请求才姗姗来迟。它预留了资源,但全局事务已经结束了,这些资源永远不会被 Confirm 也不会被 Cancel,永远挂在那里。

解决方案:在每个业务数据库中维护一张 TCC 事务状态表,记录每个事务 ID 的执行阶段(Init / Trying / Cancelled / Confirmed)。三个方法都先查询状态再决定是否执行:

  • Cancel 执行前:状态是 Init → 标记 Cancelled,直接 return(空回滚)
  • Try 执行前:状态是 Cancelled → 跳过执行,避免悬挂
  • Try 执行后:状态为 Trying
  • Confirm 前:状态必须是 Trying

这个模式通常抽象为一个工具类或 AOP 切面,避免每个 TCC 接口都重复写。这也是 TCC 侵入性高的一个体现——AT 把这层的处理全自动了。


延伸阅读: Seata 官方文档 https://seata.apache.org/zh-cn/docs/overview/what-is-seata/ ,AT 模式的源码分析推荐看"Seata AT 模式源码解析"系列,特别是 DataSourceProxy 如何拦截 SQL 生成 undo log 的细节。

posted @ 2026-07-27 21:32  念笙  阅读(24)  评论(0)    收藏  举报