TCC + Saga

📖 前置阅读:本文假设读者已理解 Seata AT 模式的原理和局限。如果还不熟悉,建议先阅读 Seata AT 模式——undo_log 与二阶段原理

一、⚡ AT 能回滚库存——但能回滚一条"已发出的短信"吗?

AT 模式的局限——上一篇说了:

AT 的自动回滚依赖 undo_log——生成反向 SQL
  INSERT → DELETE(undo_log 记录自增 ID——反向就是 DELETE)
  UPDATE → UPDATE(undo_log 记录前置镜像——反向就是把值改回去)

但以下操作——数据库回滚不了:
  ① 发了优惠券——HTTP POST 到营销系统的 API——数据库回滚不了 HTTP 调用
  ② 发了短信——调了阿里云短信 API——阿里云不会因为你的"反向 SQL"就收回短信
  ③ 调了第三方支付——Payment API 已经扣了钱——不能"生成反向 HTTP"退钱
  ④ 给 Redis 写了一个计数器——Redis 没有 undo_log——AT 管不了

TCC 和 Saga 就是为这而生的——手动补偿——操作本身和撤回操作都由你写代码实现。

二、🔄 TCC——Try / Confirm / Cancel——你自己管理回滚

2.1 TCC 的本质——每个操作配一个"撤销操作"

TCC 把每个业务操作拆成三个方法:

  Try(尝试)    —— 预留资源——但不真正执行
  Confirm(确认) —— 真正执行——Try 预留的资源生效
  Cancel(取消)  —— 释放 Try 预留的资源——回滚

和 AT 的区别:
  AT:你写一套代码——Seata 自动生成"撤销操作"(反向 SQL)
  TCC:你写三套代码——Try(正向)、Confirm(确认)、Cancel(撤销)
       → 写了三套代码——能处理任何类型的操作——不再局限于数据库

2.2 示例——“创建订单 + 发优惠券 + 扣积分”——用 TCC

// ===== 场景:下单时——创建订单 + 发优惠券 + 扣积分 =====
// 订单是 DB 操作——但发优惠券是 HTTP API——扣积分也是 HTTP API
// AT 回滚不了 HTTP API——用 TCC

// ===== 订单服务——TCC 接口 =====
public interface OrderTccAction {

    /**
     * Try:预创建订单——状态为 PENDING——库存还没扣——订单还不能支付
     * @param businessContext 在 TM 端传入的参数——和 @BusinessActionContextParameter 对应
     */
    @TwoPhaseBusinessAction(
        name = "order-create",                     // TCC 资源名
        commitMethod = "confirmCreateOrder",       // Confirm 方法
        rollbackMethod = "cancelCreateOrder"       // Cancel 方法
    )
    boolean tryCreateOrder(
        @BusinessActionContextParameter(paramName = "userId") Long userId,
        @BusinessActionContextParameter(paramName = "items") List<OrderItemDto> items,
        @BusinessActionContextParameter(paramName = "totalAmount") BigDecimal totalAmount
    );

    /**
     * Confirm:把订单从 PENDING 变为 CREATED——正式生效
     */
    boolean confirmCreateOrder(BusinessActionContext context);

    /**
     * Cancel:把 PENDING 的订单变为 CANCELLED——释放预占
     */
    boolean cancelCreateOrder(BusinessActionContext context);
}
// ===== 订单服务——TCC 实现 =====
@Service
public class OrderTccActionImpl implements OrderTccAction {

    @Autowired
    private OrderMapper orderMapper;

    @Override
    @Transactional
    public boolean tryCreateOrder(Long userId, List<OrderItemDto> items,
                                   BigDecimal totalAmount) {
        // ① 预创建订单——状态为 PENDING——不是正式订单
        Order order = new Order();
        order.setOrderNo(generateOrderNo());
        order.setUserId(userId);
        order.setTotalAmount(totalAmount);
        order.setStatus(OrderStatus.PENDING);  // ← PENDING——不是正式订单——不可支付
        order.setCreatedAt(LocalDateTime.now());
        orderMapper.insert(order);

        // ② 把 orderId 存入 BusinessActionContext——Confirm/Cancel 会用到
        // Seata 自动把方法返回值之外的参数存入 Context
        // 这里通过 RootContext 手动放
        RootContext.bind("orderId_" + RootContext.getXID(), order.getId());

        return true;  // Try 成功——等待 TC 通知 Confirm 或 Cancel
    }

    @Override
    @Transactional
    public boolean confirmCreateOrder(BusinessActionContext context) {
        // ① 从 Context 中取出 orderId
        Long orderId = (Long) context.getActionContext()
                .get("orderId_" + context.getXid());

        // ② 把订单状态从 PENDING → CREATED——正式生效
        Order order = orderMapper.selectById(orderId);
        if (order == null || order.getStatus() != OrderStatus.PENDING) {
            // 幂等——如果已经 Confirm 过了——不再处理
            return true;
        }
        order.setStatus(OrderStatus.CREATED);
        orderMapper.updateById(order);
        return true;
    }

    @Override
    @Transactional
    public boolean cancelCreateOrder(BusinessActionContext context) {
        Long orderId = (Long) context.getActionContext()
                .get("orderId_" + context.getXid());

        Order order = orderMapper.selectById(orderId);
        if (order == null) {
            // 空回滚——Try 还没执行——Cancel 先到了——不做处理
            return true;
        }
        if (order.getStatus() == OrderStatus.CANCELLED) {
            // 幂等——已经取消过了
            return true;
        }
        order.setStatus(OrderStatus.CANCELLED);
        orderMapper.updateById(order);
        return true;
    }
}
// ===== 优惠券服务——TCC 接口(HTTP API——AT 回滚不了)=====
public interface CouponTccAction {

    @TwoPhaseBusinessAction(
        name = "coupon-grant",
        commitMethod = "confirmGrantCoupon",
        rollbackMethod = "cancelGrantCoupon"
    )
    boolean tryGrantCoupon(
        @BusinessActionContextParameter(paramName = "userId") Long userId,
        @BusinessActionContextParameter(paramName = "couponType") String couponType
    );

    boolean confirmGrantCoupon(BusinessActionContext context);

    boolean cancelGrantCoupon(BusinessActionContext context);
}

@Service
public class CouponTccActionImpl implements CouponTccAction {

    @Autowired
    private CouponService couponService;  // 这个 Service 调外部营销 API

    @Override
    public boolean tryGrantCoupon(Long userId, String couponType) {
        // Try:预占优惠券——调营销 API——标记为用户——但未激活
        Coupon coupon = couponService.reserveCoupon(userId, couponType);
        // 外部 API 返回了 couponId
        RootContext.bind("couponId_" + RootContext.getXID(), coupon.getId());
        return true;
    }

    @Override
    public boolean confirmGrantCoupon(BusinessActionContext context) {
        // Confirm:激活优惠券——用户可用
        Long couponId = (Long) context.getActionContext()
                .get("couponId_" + context.getXid());
        couponService.activateCoupon(couponId);  // HTTP PUT /coupons/{id}/activate
        return true;
    }

    @Override
    public boolean cancelGrantCoupon(BusinessActionContext context) {
        // Cancel:回收优惠券——把预留的优惠券放回库存
        Long couponId = (Long) context.getActionContext()
                .get("couponId_" + context.getXid());
        if (couponId == null) {
            return true;  // 空回滚——Try 还没执行完
        }
        couponService.recycleCoupon(couponId);  // HTTP DELETE /coupons/{id}
        return true;
    }
}
// ===== TM——全局事务发起方——调各个 TCC 接口 =====
@Service
public class OrderApplicationService {

    @Autowired
    private OrderTccAction orderTccAction;
    @Autowired
    private CouponTccAction couponTccAction;
    @Autowired
    private PointTccAction pointTccAction;

    @GlobalTransactional
    public Order createOrderWithCoupon(CreateOrderRequest request) {
        // ① Try:预创建订单
        boolean orderTry = orderTccAction.tryCreateOrder(
                request.getUserId(), request.getItems(), request.getTotalAmount());
        if (!orderTry) throw new BusinessException("预创建订单失败");

        // ② Try:预发优惠券——不是数据库操作——是 HTTP 调外部 API
        boolean couponTry = couponTccAction.tryGrantCoupon(
                request.getUserId(), "FIRST_ORDER");
        if (!couponTry) throw new BusinessException("预发优惠券失败");

        // ③ Try:预扣积分——也是 HTTP 调外部 API
        boolean pointTry = pointTccAction.tryDeductPoints(
                request.getUserId(), 100);
        if (!pointTry) throw new BusinessException("预扣积分失败");

        // ④ 所有 Try 成功——TM 通知 TC 进 Confirm
        // TC 依次调每个 RM 的 confirmXxx()
        // → orderTccAction.confirmCreateOrder() ——订单 PENDING→CREATED
        // → couponTccAction.confirmGrantCoupon() ——优惠券激活
        // → pointTccAction.confirmDeductPoints()  ——积分确认扣除

        return ...;  // 返回订单信息
    }
    // 如果任何一个 Try 抛异常——TC 依次调每个 RM 的 cancelXxx()
    // → orderTccAction.cancelCreateOrder() ——订单 PENDING→CANCELLED
    // → couponTccAction.cancelGrantCoupon() ——优惠券回收
    // → pointTccAction.cancelDeductPoints()  ——积分退回
}

2.3 TCC 的两个致命陷阱——空回滚与悬挂

陷阱一:空回滚——Try 没执行——Cancel 先到了

  时间线:
  ① TM 调 Order TCC 的 Try——网络超时——TM 不知道 Try 成功了没有
  ② TM 决定回滚——发起 Cancel
  ③ Cancel 到达 order-service——但此时 Try 还没收到(网络延迟)——或者 Try 正在执行
  ④ Cancel 执行时——订单不存在(Try 还没创建)——Cancel 失败
  
  这叫"空回滚"——Cancel 先于 Try 到达

  解决——控制记录表:
    在 Cancel 中——如果查不到订单——不能报错——记录一条"Cancel 已执行"的空记录
    当 Try 终于到达时——先查"Cancel 是否已执行"——如果是——Try 不再执行

陷阱二:悬挂——Try 超时后——Cancel 执行了——Try 又到了

  时间线:
  ① TM 调 Try——Try 执行中——卡住了(GC 停顿——网络延迟)
  ② TM 等 10 秒超时——发起 Cancel
  ③ Cancel 到达——顺利执行——订单状态改为 CANCELLED
  ④ 第 30 秒——Try 终于执行完了——订单 INSERT 进去了——状态是 PENDING
  ⑤ 结果:Cancel 已经执行了——但 Try 把数据又写进去了——这个 Try"悬挂"了
  
  这叫"悬挂"——Try 在 Cancel 之后到达——Cancel 的撤销效果被 Try 覆盖了

  解决——同样用控制记录表:
    Cancel 执行时——记录一条"xid=xxx 已 Cancel"
    Try 执行前——先查"xid=xxx 是否已 Cancel"——如果是——拒绝执行
-- ===== TCC 防悬挂 + 空回滚控制表——每个参与 TCC 的服务都建一张 =====
CREATE TABLE tcc_operation_record (
    id           BIGINT AUTO_INCREMENT PRIMARY KEY,
    xid          VARCHAR(128) NOT NULL COMMENT '全局事务 ID',
    branch_id    BIGINT NOT NULL COMMENT '分支事务 ID',
    action_name  VARCHAR(64) NOT NULL COMMENT 'TCC 资源名——order-create/coupon-grant',
    status       TINYINT NOT NULL COMMENT '1-Try 2-Confirm 3-Cancel',
    created_at   DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
    UNIQUE KEY uk_xid_branch_action (xid, branch_id, action_name)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
// ===== 改进后的 TCC 实现——带防悬挂 + 空回滚 =====
@Service
public class OrderTccActionImpl implements OrderTccAction {

    @Autowired
    private TccOperationRecordMapper recordMapper;

    @Override
    @Transactional
    public boolean tryCreateOrder(Long userId, List<OrderItemDto> items,
                                   BigDecimal totalAmount) {
        String xid = RootContext.getXID();
        Long branchId = RootContext.getBranchId();

        // ① 防悬挂——检查 Cancel 是否已执行
        TccOperationRecord cancelRecord = recordMapper.selectOne(
                xid, branchId, "order-create", 3);  // status=3 = Cancel
        if (cancelRecord != null) {
            // Cancel 先到了——Try 不能再执行——这就是"悬挂"——拒绝
            return false;
        }

        // ② 记录 Try
        TccOperationRecord tryRecord = new TccOperationRecord();
        tryRecord.setXid(xid);
        tryRecord.setBranchId(branchId);
        tryRecord.setActionName("order-create");
        tryRecord.setStatus(1);  // Try
        recordMapper.insert(tryRecord);

        // ③ 执行业务逻辑
        Order order = new Order();
        // ... 创建订单——状态 PENDING
        orderMapper.insert(order);
        RootContext.bind("orderId_" + xid, order.getId());
        return true;
    }

    @Override
    @Transactional
    public boolean cancelCreateOrder(BusinessActionContext context) {
        String xid = context.getXid();
        Long branchId = context.getBranchId();

        // ① 幂等——检查 Cancel 是否已执行
        TccOperationRecord existingRecord = recordMapper.selectOne(
                xid, branchId, "order-create", 3);
        if (existingRecord != null) {
            return true;  // Cancel 已经执行过了——幂等——直接返回
        }

        // ② 记录 Cancel——在查订单之前——防止空回滚
        TccOperationRecord cancelRecord = new TccOperationRecord();
        cancelRecord.setXid(xid);
        cancelRecord.setBranchId(branchId);
        cancelRecord.setActionName("order-create");
        cancelRecord.setStatus(3);  // Cancel
        recordMapper.insert(cancelRecord);

        // ③ 空回滚处理——查不到订单——不能报错
        Long orderId = (Long) context.getActionContext().get("orderId_" + xid);
        if (orderId == null) {
            return true;  // Try 没执行——空回滚——正常
        }
        Order order = orderMapper.selectById(orderId);
        if (order == null) {
            return true;  // Try 没执行完——空回滚——正常
        }
        if (order.getStatus() == OrderStatus.CANCELLED) {
            return true;  // 幂等
        }

        // ④ 执行业务撤销
        order.setStatus(OrderStatus.CANCELLED);
        orderMapper.updateById(order);
        return true;
    }
}

⚠️ 新手提示:空回滚和悬挂是 TCC 的两个经典坑——90% 的 TCC 实现都有这两个问题。解决方案就是一张操作记录表——在 Cancel 执行前先记一笔"Cancel 已执行"——在 Try 执行前先查"Cancel 是否已执行"。记录表的唯一键 (xid, branch_id, action_name) 天然防并发——并发的 Try 和 Cancel 只有一个能插入成功。

三、📖 Saga——长事务编排——状态机驱动的补偿链

3.1 Saga 的本质——把每一步和它的补偿串起来

TCC 的问题:每个操作要写 3 套代码——Try/Confirm/Cancel——代码量翻 3 倍
Saga 的思路:只写 2 套——正向操作 + 补偿操作——用一个编排器串起来

Saga = 一系列有序的事务——每个事务有对应的补偿事务
  → T1 → T2 → T3 → ... → Tn
  → 如果 Ti 失败——从 Ti-1 开始逆序执行补偿:
     Ci-1 → Ci-2 → ... → C1

和 TCC 的最大区别:
  TCC:Try 阶段不真正执行——Confirm 才执行——Try 可以 Cancel
  Saga:每一步直接执行——不预留——失败了执行补偿操作"弥补"
  → TCC 是"预留——确认"——Saga 是"执行——反悔"
场景:下单满一年自动续费——这是一个跨天的长流程

  T1: 创建续费订单         C1: 取消续费订单
  T2: 扣款(调支付接口)    C2: 退款(调支付接口)
  T3: 发送续费成功短信       C3: 发送"续费已取消"短信(补偿通知)
  T4: 更新会员过期时间      C4: 恢复原来的会员过期时间
  
  如果用 TCC——Try 阶段就要预留资源——但支付接口预留不了——预留了就要扣钱
  所以 TCC 在这个场景不合适——用 Saga——直接执行——失败了补偿

3.2 Saga 的两种编排方式——协同型 vs 编排型

方式一:协同型(Choreography)——无中心协调器——事件驱动
  每个服务完成自己的事务后——发事件——下一个服务监听到事件——继续执行

  order-service 创建订单 → 发 OrderCreated 事件
  payment-service 监听 → 扣款 → 发 PaymentCompleted 事件
  sms-service 监听 → 发短信 → 发 SmsSent 事件

  优点:松散耦合——不需要中心化协调器
  缺点:流程隐式分布在各个服务——看不到全貌——改流程要改多个服务

方式二:编排型(Orchestration)——有中心协调器——状态机驱动
  一个 Saga Orchestrator 串起所有步骤——每一步调哪个服务——失败了做哪个补偿

  协调器(SagaOrchestrator):
    Step1: 调 order-service → 成功 → Step2
                                → 失败 → end
    Step2: 调 payment-service → 成功 → Step3
                                → 失败 → 补偿 Step1
    Step3: 调 sms-service → 成功 → end
                                → 失败 → 补偿 Step2 → 补偿 Step1

  优点:流程显式——在协调器中一目了然——改流程只改协调器
  缺点:协调器成了新的单点——虽然可以高可用部署

推荐:编排型——流程可见性 > 松散耦合的优势——尤其是复杂流程

3.3 编排型 Saga 的代码实现

// ===== Saga 协调器——状态机驱动的长事务编排 =====
@Service
public class RenewMemberSagaOrchestrator {

    @Autowired
    private OrderService orderService;
    @Autowired
    private PaymentService paymentService;
    @Autowired
    private SmsService smsService;
    @Autowired
    private MemberService memberService;

    /**
     * 续费会员的完整流程——6 步——每步有补偿
     * Saga 状态机:
     *   START → CREATE_ORDER → DEDUCT_PAYMENT → SEND_SMS → UPDATE_MEMBER → END
     *     ↑          ↓               ↓              ↓            ↓
     *     └── cancel_order ←── refund ←── (skip) ←── restore_expiry
     */
    public void execute(RenewMemberRequest request) {
        SagaState state = SagaState.START;
        String orderId = null;
        String paymentId = null;

        try {
            // Step 1:创建续费订单
            orderId = orderService.createRenewOrder(request.getUserId(), request.getAmount());
            state = SagaState.ORDER_CREATED;

            // Step 2:扣款——调支付接口
            paymentId = paymentService.deduct(request.getUserId(), request.getAmount());
            state = SagaState.PAYMENT_DEDUCTED;

            // Step 3:发短信通知
            smsService.sendRenewSuccess(request.getUserId(), request.getAmount());
            state = SagaState.SMS_SENT;

            // Step 4:更新会员过期时间
            memberService.extendExpiry(request.getUserId(), 365);
            state = SagaState.COMPLETED;

        } catch (Exception e) {
            log.error("续费流程失败——当前状态: {}——开始补偿", state, e);
            compensate(state, orderId, paymentId, request);
        }
    }

    private void compensate(SagaState failedState, String orderId,
                             String paymentId, RenewMemberRequest request) {
        switch (failedState) {
            case PAYMENT_DEDUCTED:
            case SMS_SENT:
                // 已经扣了钱——要退款
                try {
                    paymentService.refund(paymentId, request.getAmount());
                    log.info("补偿:退款成功——paymentId={}", paymentId);
                } catch (Exception e) {
                    log.error("补偿失败:退款失败——需要人工介入——paymentId={}", paymentId, e);
                    // ← 补偿也失败了——记录到异常表——人工处理
                }
                // fall through——继续取消订单

            case ORDER_CREATED:
                // 取消订单
                try {
                    orderService.cancelOrder(orderId);
                    log.info("补偿:订单取消成功——orderId={}", orderId);
                } catch (Exception e) {
                    log.error("补偿失败:取消失败——需要人工介入——orderId={}", orderId, e);
                }
                break;

            case START:
            case COMPLETED:
                // 不需要补偿
                break;
        }
    }
}

enum SagaState {
    START,
    ORDER_CREATED,
    PAYMENT_DEDUCTED,
    SMS_SENT,
    COMPLETED
}

3.4 Seata Saga 模式——用状态机 DSL 替代手写编排器

// Seata Saga 提供了声明式的状态机 DSL——不需要手写上面的 Java 代码
// 把流程定义成一个 JSON 文件——Seata 自动执行和补偿

{
  "Name": "renew-member-saga",
  "Comment": "会员续费 Saga",
  "StartState": "CreateOrder",
  "Version": "1.0",
  "States": {
    "CreateOrder": {
      "Type": "ServiceTask",
      "ServiceName": "orderService.createRenewOrder",
      "Next": "DeductPayment",
      "CompensateState": "CancelOrder"
    },
    "DeductPayment": {
      "Type": "ServiceTask",
      "ServiceName": "paymentService.deduct",
      "Next": "SendSms",
      "CompensateState": "RefundPayment"
    },
    "SendSms": {
      "Type": "ServiceTask",
      "ServiceName": "smsService.sendRenewSuccess",
      "Next": "UpdateMember",
      "CompensateState": "SendCancelSms"
    },
    "UpdateMember": {
      "Type": "ServiceTask",
      "ServiceName": "memberService.extendExpiry",
      "Next": "Succeed"
    },

    "CancelOrder": {
      "Type": "ServiceTask",
      "ServiceName": "orderService.cancelOrder",
      "Next": "Fail"
    },
    "RefundPayment": {
      "Type": "ServiceTask",
      "ServiceName": "paymentService.refund",
      "Next": "CancelOrder"
    },
    "SendCancelSms": {
      "Type": "ServiceTask",
      "ServiceName": "smsService.sendCancelSms",
      "Next": "RefundPayment"
    },

    "Succeed": { "Type": "Succeed" },
    "Fail": { "Type": "Fail" }
  }
}

四、⚖️ AT vs TCC vs Saga——选型决策

维度ATTCCSaga
回滚方式框架自动反向 SQL你写 Cancel你写补偿操作——逆序执行
代码量1x(只写正向)3x(Try + Confirm + Cancel)2x(正向 + 补偿)
支持非 DB 操作❌——只支持关系型 DB✅——任何操作✅——任何操作
预留资源不需要——一阶段提交需要——Try 预留不需要——直接执行
数据一致性强——全局锁保证强——预留保证弱——补偿可能失败
适用场景纯 DB 操作——下单扣库存有资源预留需求——秒杀/选座长流程——续费/退款/审批

选型口诀

  • 所有操作都是 DB 的 INSERT/UPDATE/DELETE → 用 AT——最简单
  • 涉及到调外部 API——第三方接口——Redis——且需要资源预留 → 用 TCC
  • 涉及到长流程——步骤多——可能跨天——且每一步都可以接受"先做——错了再改" → 用 Saga

🎯 总结

  1. TCC = Try/Confirm/Cancel——每步三套代码——换来的是"什么操作都能补偿":AT 只能回滚 DB 操作——TCC 可以补偿 HTTP API 调用、Redis 操作、第三方支付。代价是代码量翻 3 倍——以及空回滚和悬挂两个经典陷阱。解决方案:一张操作记录表——在 Cancel 执行前先记一笔——在 Try 执行前先查 Cancel 是否已执行。

  2. Saga = 直接执行 + 逆序补偿——长事务编排模式:不需要预留资源——每一步直接执行——失败了从当前步开始逆序补偿。推荐编排型——用一个协调器串起所有步骤——流程一目了然。Seata Saga 提供声明式状态机 DSL——JSON 定义流程——避免手写补偿链。

  3. 补偿可能失败——需要人工干预机制:退款接口超时、取消订单失败——补偿也有可能失败。需要异常记录表 + 定时任务重试 + 人工介入界面。> 95% 的情况下补偿成功——剩下的 5% 需要人工处理——这是 BASE 的代价。

  4. AT → TCC → Saga——复杂度递增——控制力递增:AT 最简单——但只支持 DB。TCC 最灵活——但代码量最大。Saga 适合长流程——但一致性最弱。90% 的场景用 AT + 事务消息就够了——只有 AT 处理不了的才上 TCC 或 Saga。

📖 下一步阅读:TCC 和 Saga 都是同步型的——如果场景允许异步——事务消息(RocketMQ 事务消息 + 本地消息表)是最简单的分布式事务方案——代码侵入最小——性能最好。继续阅读 事务消息 + 本地消息表 + 生产踩坑