文章

Seata

Seata 分布式事务

基于 Seata 2.x,结合本项目(catfun:社区 / 地图 / 用户 / 积分)设计


一、Seata 是什么 / 解决什么问题

Seata(Simple Extensible Autonomous Transaction Architecture)是阿里开源的分布式事务框架,解决微服务调用链中的数据一致性问题

微服务化后,一次业务横跨多个服务、多个数据库,本地事务管不到别人家的库,就需要分布式事务框架。

核心卖点:对业务侵入极小——通常一个注解 + 少量配置就能接入,业务 SQL 不用大改。


二、整体架构:TC / TM / RM(必考)

角色 全称 位置 职责
TC Transaction Coordinator Seata Server(独立进程) 全局事务注册、生成 XID、二阶段调度、状态管理、超时重试下发
TM Transaction Manager 发起方(业务服务内) 通过 @GlobalTransactional 开启/提交/回滚全局事务
RM Resource Manager 各参与方(业务服务内) 执行分支事务(AT 的 SQL 代理 / TCC 的三方法),上报分支状态
1
2
3
4
5
① TM 开启全局事务 ──► TC 注册,生成 XID
② XID 透传到所有下游服务(Feign Header / Dubbo attachment)
③ 各 RM 执行分支事务并上报 TC
④ TM 收到全部成功 → 通知 TC 提交
⑤ TC 向各 RM 下发二阶段指令(Confirm/Commit 或 Cancel/Rollback,可重试)

XID 透传:XID 是全局事务的唯一标识,必须随 RPC 一路传递。Feign 用 RequestInterceptor 塞 header,Dubbo 用 RpcContext attachment,Seata 已内置拦截器,但要确认没被网关/过滤器吞掉。


三、四大模式总览

模式 本质 一致性 侵入 性能 一句话
AT 2PC 改良(自动补偿) 最终一致 低(注解) 全局锁 自动生成 undo_log,回滚靠日志反向补偿
TCC 业务三方法(手动补偿) 最终一致 高(三方法) 无(冻结) 业务自己写 Try/Confirm/Cancel
XA 原生 2PC(数据库级) 强一致 长锁 数据库 XA 协议,一阶段 prepare
Saga 长事务(正反业务) 最终一致 正向业务 + 反向补偿,适合长流程

四、AT 模式(最常考,必背原理)

4.1 执行流程

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
一阶段:
① 拦截业务 SQL(DataSourceProxy 代理数据源)
② 执行前查数据 → 生成 before image(前镜像快照)
③ 执行业务 SQL(正常提交本地事务)
④ 执行后查数据 → 生成 after image(后镜像快照)
⑤ 写 undo_log(before + after 序列化存 blob)→ 与业务 SQL 同事务提交
⑥ 向 TC 注册分支事务(申请全局锁)

二阶段提交(全局成功):
TC 通知 → RM 异步删除 undo_log(业务数据已提交,日志没用了)

二阶段回滚(任一分支失败):
TC 通知 → RM 读 undo_log
① 校验 after image 与当前数据是否一致(防止期间被其他事务改过 → 脏写)
② 一致 → 用 before image 生成反向 SQL(DELETE/UPDATE/INSERT),恢复现场
③ 不一致 → 回滚失败,抛异常告警,需要人工介入

4.2 undo_log 是什么

undo_log 是 AT 模式的回滚日志表(每个参与库都要建):

1
2
3
4
5
6
7
8
9
10
CREATE TABLE `undo_log` (
  `id`            BIGINT AUTO_INCREMENT PRIMARY KEY,
  `branch_id`     BIGINT       NOT NULL,        -- 分支事务 ID
  `xid`           VARCHAR(100) NOT NULL,        -- 全局事务 ID
  `rollback_info` LONGBLOB     NOT NULL,        -- before/after 镜像序列化数据
  `log_status`    INT          NOT NULL,        -- 0=正常 1=已清理
  `log_created`   DATETIME,
  `log_modified`  DATETIME,
  UNIQUE KEY `ux_undo_log` (`xid`, `branch_id`)
) ENGINE=InnoDB;

为什么能回滚:因为记录了改动前后的完整镜像,回滚时用 before image 生成反向 SQL,等价于”把数据改回原样”。这也是”自动补偿”的含义。

4.3 全局锁与隔离性(高频)

  • 全局锁:Seata 在 TC 侧维护一张全局锁表(global_table / 内存),不同全局事务对同一行数据的修改要先抢全局锁再提交(同一全局事务内可重入),防止并发写同一行导致 undo_log 回滚时覆盖已提交数据。
  • 本地锁 + 全局锁:本地事务锁 + 全局锁双重保障,写-写冲突被挡住。
  • 读的隔离:AT 默认 读已提交(RC),普通 SELECT 不加全局锁 → 可能脏读(读到别人未提交全局事务的数据)。
  • 防脏读SELECT ... FOR UPDATE(会申请全局锁)或加 @GlobalLock / @GlobalTransactional 注解读。

4.4 脏写问题

场景:全局事务 A 改了行 X(未提交),另一个事务 B 直接改行 X 并提交 → A 回滚时 after image 校验失败(数据已被 B 改过)→ 回滚失败。 解法:B 也纳入 Seata(B 的写会抢全局锁),或对 A 的读加锁。全局锁解决的就是这个

4.5 适用场景

  • 跨服务改数据、需要强一致(同生共死)
  • 希望侵入小、快速接入
  • 低并发、不介意全局锁开销
  • 数据库须支持事务(MySQL InnoDB),MyISAM 不支持

五、TCC 模式

5.1 三方法职责

1
2
3
Try      预留资源(冻结金额/预占库存/冻结积分)——不落正式账
Confirm  真正执行业务(使用预留资源)——只做"使用",不做新校验
Cancel   释放预留资源(解冻)——释放自己 Try 冻结的
  • Try 全成功 → Confirm;任一 Try 失败 → Cancel 补偿
  • 进入 Confirm 后没有回滚,Confirm 失败只能幂等重试(与 2PC 的本质区别)

5.2 fence 表:解决”网络不确定性”的四大问题

TCC 一旦落到工程里,网络会带来两类麻烦:指令可能被重发(TC 超时重试)、指令可能乱序(Cancel 比 Try 先到)。这四大问题全靠一张 fence 表解决。

第一步:一张分支事务状态表

1
2
3
4
5
6
7
tcc_fence_log(fence 表)
┌────────────┬────────────┬────────────┬───────────┬──────────┐
│ xid        │ branch_id  │ action     │ status    │ 时间戳    │
│ 全局事务ID  │ 分支事务ID  │ 方法名      │ 0/1/2     │          │
└────────────┴────────────┴────────────┴───────────┴──────────┘
status: 0=Try 已执行   1=已提交(Confirm)   2=已回滚(Cancel)
主键: (xid, branch_id)   ← 唯一约束 = 幂等的基础

四个问题全围绕这张表展开,本质就是三句话:谁先到、谁后到、重复来了怎么办

问题一:TC 超时重试 Confirm/Cancel

什么意思:TC 给分支下发二阶段指令(Confirm/Cancel)时网络超时,TC 不知道分支到底执行没有,只能重试下发。所以同一个指令可能被送来 2 次、3 次。

为什么必须有:不重试,指令丢了,业务就永远停在”Try 完了但没确认/没回滚”,一直不一致。

怎么做:TC 内置重试机制(Seata Server 侧,无需配置),分支执行完必须上报结果,TC 收不到上报就重发。

问题二:幂等去重(重复的二阶段不执行业务)

什么意思:既然 Confirm/Cancel 会被重发,分支必须保证重复收到时不重复执行业务,否则会重复扣款/重复解冻。

怎么做:先改状态、后执行业务(CAS 更新):

1
2
3
4
Confirm 收到后:
  先 UPDATE tcc_fence_log SET status=1 WHERE xid=? AND branch_id=? AND status=0
  → 影响行数 == 1:第一次来,继续执行业务 ✅
  → 影响行数 == 0:执行过了,直接返回成功,不执行业务 ✅

问题三:空回滚判断(Try 没执行,Cancel 却来了)

什么意思:Try 超时(实际没执行),TC 判全局失败,给所有分支发 Cancel——包括那个”其实 Try 根本没执行”的分支。此时 Cancel 如果直接回滚,会把从未冻结的资源解冻(余额变负数那种事故)。

怎么做:Cancel 先查表:

1
2
3
4
Cancel 收到后:
  查 tcc_fence_log 有无 (xid, branch_id) 记录
  → 没有记录:Try 从未执行 → 不执行业务,只插入一条 status=2 记录(空回滚)
  → 有记录且 status=0:正常执行回滚

问题四:悬挂防护(Cancel 先到了,迟到的 Try 才来)

什么意思:Try 超时 → Cancel 先执行完(空回滚)→ 那个迟到的 Try 又执行了 → 资源被冻结了,但全局事务已结束,永远不会有人来 Confirm/Cancel → 资源永久悬挂(泄漏)。

怎么做:Try 执行前先查表:

1
2
3
4
Try 执行前:
  查 tcc_fence_log 有无 (xid, branch_id) 记录
  → 已有二阶段记录(status=1/2):被 Cancel 过了 → 拒绝执行业务,直接返回成功(防悬挂)
  → 无记录:正常冻结资源,插入 status=0 记录

一张表总结四个问题

问题 谁来了 靠什么防
TC 超时重试 TC 反复下发二阶段 TC 内置重试 + 分支上报结果
幂等去重 Confirm/Cancel 重复执行 (xid, branch_id) 唯一键 + CAS 更新
空回滚 Cancel 先于 Try Cancel 前查表,无记录只登记不执行业务
悬挂防护 Try 晚于 Cancel Try 前查表,已回滚就拒绝执行

一句话记忆:Try 前查表防悬挂,Cancel 前查表防空回滚,Confirm/Cancel 执行时 CAS 更新防重复。

Seata 落地:不用自己写,注解 + fence 表全自动

1
2
3
4
5
6
7
8
9
10
11
12
13
@TwoPhaseBusinessAction(name = "redeem", commitMethod = "confirmRedeem", rollbackMethod = "cancelRedeem")
public boolean tryRedeem(BusinessActionContext ctx,
                         @BusinessActionContextParameter(paramName = "flowId") String flowId) {
    // 冻结积分(Seata 拦截器自动做防悬挂 + 插 fence 记录)
}

public boolean confirmRedeem(BusinessActionContext ctx) {
    // 真正扣积分(Seata 自动做幂等去重)
}

public boolean cancelRedeem(BusinessActionContext ctx) {
    // 解冻积分(Seata 自动做空回滚判断)
}
1
2
3
4
5
seata:
  tcc:
    fence:
      log-table-name: tcc_fence_log   # fence 表名
      clean-period: 1h                # 过期记录清理周期

5.3 常见坑

  • Confirm/Cancel 忘写幂等 → 重复扣款/重复解冻
  • 二阶段拿不到 Try 的入参 → 必须 @BusinessActionContextParameter 标记,参数走 XID 上下文
  • Try 本身防重放 → 事务表唯一约束
  • fence 表忘建 → 二阶段直接报错

六、XA 模式

  • Seata XA = 标准的数据库级 2PC:一阶段 prepare,二阶段 commit/rollback,由数据库 XA 协议保证。
  • 优点:强一致、业务无感知(只要数据库支持 XA)
  • 缺点:Prepare 后资源长锁、协调者单点、性能差
  • 面试点:Seata 的 XA 模式就是 2PC 的”原教旨”实现;互联网高并发场景基本不用。

七、Saga 模式

  • 长事务:每个参与方提供正向业务 + 反向补偿两个方法,Saga 按序执行正向;某步失败,逆序执行已成功步骤的补偿。
  • Seata Saga 支持注解状态机(JSON)两种编排方式。
  • 特点:无锁、无隔离(中间态对外可见)、适合流程长/跨多服务/允许最终一致(如:下单 → 支付 → 履约 → 售后)。
  • 面试点:Saga 没有 Try 的”预留”概念,隔离性最弱,需要业务容忍中间态。

八、选型对比 + 决策树(必考)

维度 AT TCC XA Saga
一致性 最终一致 最终一致 强一致 最终一致
业务侵入
性能 中(全局锁+日志) 好(无锁) 差(长锁)
隔离性 全局锁保证写隔离 冻结机制 数据库保证 最弱
长流程 不适合 不适合 不适合 适合
典型场景 短事务、跨库改数据 高并发、钱/库存/积分 银行强一致 订单+物流+售后
1
2
3
4
5
选型决策:
① 流程很长 / 中间态可接受?      ──► Saga
② 强一致(不能容忍任何不一致)?  ──► XA(性能换强一致)
③ 有资源概念 + 高并发 + 无锁?    ──► TCC
④ 其他跨服务短事务?              ──► AT(默认首选)

九、Seata vs 消息队列方案(本地消息表 / 事务消息)

维度 Seata(AT/TCC) MQ 事务消息 / 本地消息表
时机 同步,调用链内完成 异步,消息解耦
一致性 最终一致,窗口短 最终一致,窗口长
侵入 注解/三方法 建表 + 消费者幂等
回滚 框架自动/补偿 无回滚,靠补偿消息
适用 必须同步完成、强一致 允许异步、削峰解耦

本项目积分变更就是 RocketMQ 事务消息PointTransactionProducer):本地写 cf_point_flow + 投递消息原子化,消费侧 flowId 幂等——因为积分变更允许异步,不需要同步强一致。选 Seata 还是 MQ,核心就是”能不能等”


十、部署与配置(项目落地)

10.1 Seata Server(TC)部署

  • 下载 seata-server,独立进程,可集群
  • 配置注册中心:本项目用 Nacosregistry.type=nacos,TC 注册到 Nacos 供客户端发现
  • 配置存储:store.mode=db(生产用 DB 存事务状态,单机可用 file)

10.2 Client(业务服务)配置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
seata:
  enabled: true
  application-id: catfun-community        # 服务名
  tx-service-group: catfun_tx_group       # 事务分组(与 Server 端一致)
  registry:
    type: nacos
    nacos:
      server-addr: ${NACOS_ADDR:127.0.0.1:8848}
  service:
    vgroup-mapping:
      catfun_tx_group: default            # 分组映射到 TC 集群
  client:
    tm:
      commit-retry-count: 5               # TM 提交全局事务重试次数
      rollback-retry-count: 5             # TM 回滚重试次数
    rm:
      report-retry-count: 5               # 分支上报重试次数

依赖:seata-spring-boot-starter + 启动类 @EnableAutoDataSourceProxy(AT 模式代理数据源)。

10.3 常见部署坑

  • tx-service-group 与服务端不一致 → 找不到 TC
  • Nacos 连不上 → 全局事务直接失败
  • 网关层吞掉 XID header → 透传中断
  • 忘记建 undo_log / fence 表 → 二阶段报错

十一、高频面试题 Q&A

Q1:Seata AT 模式执行流程? 一阶段:代理 SQL → before 快照 → 执行 → after 快照 → 写 undo_log → 注册分支;二阶段提交删日志,回滚靠镜像反向补偿。

Q2:undo_log 为什么能回滚? 记录 before/after 完整镜像,回滚用 before 生成反向 SQL 恢复现场,等价于”改回去”。

Q3:AT 怎么保证隔离性?脏读怎么解决? 全局锁防写-写冲突;普通 SELECT 不加锁可能脏读,用 FOR UPDATE / @GlobalLock 解决。

Q4:Seata 四模式怎么选? 见第八节决策树:短事务默认 AT、资源型高并发 TCC、强一致 XA、长流程 Saga。

Q5:XID 怎么传递? TM 生成,随 RPC 透传(Feign header / Dubbo attachment),RM 解析后关联分支。

Q6:AT 模式回滚失败怎么办? after image 校验不一致(被脏写)→ 抛异常 + 告警 + 人工介入,这也是 AT 的固有缺陷。

Q7:Seata 和 2PC 什么关系? XA 模式 = 原生 2PC;AT = 2PC 改良(一阶段直接提交 + 日志补偿,解决阻塞问题)。

Q8:Seata Server(TC)挂了会怎样? 全局事务无法提交/回滚(分支悬挂)→ 生产必须 TC 集群 + DB 持久化。

Q9:AT 支持哪些数据库? MySQL(InnoDB)/PostgreSQL/Oracle 等,MyISAM 不支持。

Q10:Seata 的性能损耗来自哪? SQL 解析、前后快照查询、undo_log 写入、全局锁申请(约 10%~30% 开销)。


十二、本项目场景设计

场景一:AT 模式 —— 取消订单释放冻结库存(低并发 + 强一致)

涉及服务catfun-trade(订单,TM 发起)+ catfun-merchant(库存,RM 参与)

背景:下单 TCC 冻结库存后订单待支付(30 分钟),用户取消 / 超时未支付时需同时完成三件事——订单置 CANCELLED、释放冻结库存(unfreeze)、还原优惠券,跨 trade/merchant 两库强一致:不能出现”订单取消了、库存还冻着”(frozen 泄漏、可售库存虚低)。

流程OrderCancelService.cancel@GlobalTransactional + 本地 @Transactional):

1
2
3
4
5
6
7
取消订单(用户主动 / 超时定时任务)
  ├─ ① 本地:Order → CANCELLED(cancelTime / cancelReason 落库)
  ├─ ② Feign 调 /api/merchant/inventory/unfreeze:frozen - N / stock + N(注册 AT 分支,写 undo_log)
  ├─ ③ 本地:还原优惠券(coupon_user → UNUSED)
  └─ 任一步失败 → 全局回滚
       merchant 分支 → undo_log 反向补偿(UPDATE → UPDATE)
       本地事务 → 本地 SQL 回滚

为什么选 AT:取消是低频、短事务(每条明细一次 UPDATE)→ AT 全局锁无压力;释放库存无外部副作用,undo_log 物理回滚足够,无需 TCC 三段式;侵入小,只加注解。与场景二互补——高频写(下单冻结)用 TCC 防超卖,低频强一致(取消释放)用 AT

注意releaseStock 旧实现失败仅 log.warn 吞异常(造成冻结泄漏),现改为失败抛 BusinessException 触发全局回滚;超时任务每单独立一个全局事务,单笔失败不影响其他订单(避免一个脏单拖垮整批)。

场景二:TCC 模式 —— 下单库存冻结(高并发 + 资源模型)

涉及服务catfun-trade(订单,TM 发起)+ catfun-merchant(库存,RM 参与)

背景:下单需预占库存防超卖,库存高频扣减不能长时间持行锁;下单方法全部执行完、全局事务即提交(Confirm)——天然”冻结/解冻”资源模型。

资源模型cf_inventory 表双字段(stock 可售 / frozen 冻结):

阶段 方法(InventoryTccAction SQL 效果
Try freeze stock - N / frozen + NWHERE stock >= N 原子防超卖)
Confirm confirm 下单成功后提交(空操作,冻结保留)
Cancel cancel frozen - N / stock + N(TC 全局回滚时自动下发)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// Try:预占库存——原子 SQL 防超卖(InventoryRepositoryImpl.freeze)
// 影响 0 行(库存不足)→ 抛 BusinessException 触发全局回滚
public boolean freeze(BusinessActionContext ctx,
                      @BusinessActionContextParameter(paramName = "productId") String productId,
                      @BusinessActionContextParameter(paramName = "quantity") Integer quantity) {
    if (!inventoryRepository.freeze(productId, quantity)) {
        throw new BusinessException("库存不足: productId=" + productId + ", quantity=" + quantity);
    }
    return true;
}

// Confirm:空操作——下单成功后(全局事务提交时)由 TC 提交,冻结保留
public boolean confirm(BusinessActionContext ctx) { return true; }

// Cancel:解冻库存(TC 下发;productId/quantity 由注解自动持久化到 ctx)
public boolean cancel(BusinessActionContext ctx) {
    Object productId = ctx.getActionContext("productId");
    Object quantity = ctx.getActionContext("quantity");
    return inventoryRepository.unfreeze(String.valueOf(productId), Integer.parseInt(String.valueOf(quantity)));
}

时序OrderAppService.create@GlobalTransactional + 本地 @Transactional):

1
2
3
4
5
6
7
8
创建订单
  ├─ ① 循环 Feign 调 catfun-merchant /api/merchant/inventory/tcc/freeze(TCC Try,注册分支)
  │     原子 SQL 预占库存;返回 data != true → 抛 BusinessException
  ├─ ② 订单 + 订单明细落库
  ├─ ③ 核销优惠券 + 清理购物车
  └─ 任一步失败 → 全局回滚
       本地事务 → 本地 SQL 回滚(undo_log / 本地回滚)
       TCC 分支 → TC 下发 Cancel → cancel() 解冻(fence 幂等,见 5.2)

为什么选 TCC:库存高频扣减 → AT 的全局锁(SELECT FOR UPDATE)长事务会成瓶颈;”下单冻结、成功提交、失败解冻”天然契合 Try/Confirm/Cancel 三段语义,且下单成功即提交(Confirm 空操作、冻结保留)正是纯业务需求;fence 表兜底幂等/空回滚/防悬挂,Try 失败走 fence 空回滚,业务侧不需要冻结流水表。


十三、面试速记

  1. Seata = 微服务分布式事务框架,注解级低侵入
  2. 三角色:TC(Server 调度)/ TM(发起)/ RM(执行),XID 随 RPC 透传
  3. AT = 2PC 改良:一阶段提交 + undo_log 镜像,二阶段删日志或反向补偿
  4. AT 全局锁防脏写;普通读可能脏读FOR UPDATE / @GlobalLock 解决
  5. TCC = 手动三方法:Try 冻结 / Confirm 执行 / Cancel 释放;二阶段”超时重试、幂等去重、空回滚、悬挂防护”四大问题靠 fence 表解决
  6. XA = 原生 2PC(强一致慢);Saga = 长流程正反补偿(隔离最弱)
  7. 选型:短事务 AT、资源高并发 TCC、强一致 XA、长流程 Saga;能异步就用 MQ
  8. 本项目:取消订单释放库存 → AT;下单库存冻结 → TCC;积分变更 → RocketMQ 事务消息

十四、AT vs TCC:性能 / 安全区别

结合 catfun-trade(库存冻结 + 订单创建)场景,只看 AT 和 TCC 在性能、安全两个维度的差别,方便选型时对比。

14.1 性能

维度 AT TCC
网络交互 每分支注册+上报 ≈ 2N 次 RPC Try 即业务调用,无镜像上报
写放大 每次 DML 多写一条 undo_log(数据量×2)
并发吞吐 全局锁串行化热点行 + 脏写回滚率上升 无全局锁,吞吐高
长流程 强制短事务 预占是逻辑占位,与时长无关

14.2 安全

维度 AT TCC
回滚方式 物理镜像:把字段改回 before 值(精确但机械) 业务语义:调 unfreeze 等方法(灵活,可加审计)
外部副作用 ❌ 只能回滚数据库,回滚不了短信/MQ/第三方 ✅ Cancel 是业务代码,能补偿外部副作用
隔离性 写加全局锁,读不加锁可能脏读(需 @GlobalLock) Try 预占标记即逻辑隔离
幂等 天然(undo_log 唯一键) 强制自己写(否则重复扣款/解冻)
故障恢复 undo_log + TC 会话重建,较自动 幂等重试 + 对账/定时任务兜底
异构写者 脏写检查会误伤(没接 Seata 的服务改同一行 → 莫名回滚) 无感
SQL 边界 UPDATE 带 JOIN/LIMIT 等不支持镜像 无限制

14.3 一句话结论

“涉及钱用 TCC”的真正原因:资金/支付环节有大量数据库之外的外部副作用(短信、第三方回调、MQ),AT 的物理回滚补不了它们;TCC 的 Cancel 是业务代码,可以补偿。

注意:库存冻结这个场景几乎没有外部副作用,所以 AT 足够用(秒级短事务、无外部副作用 → 选 AT)。

本文由作者按照 CC BY 4.0 进行授权