Java 面试笔记VII · 03 / 05

Lesson 68 · 分布式系统设计

分布式事务:2PC、TCC、Saga 与最终一致性

深度·🔥 极高·#分布式·#事务

第 1 站

面试官:分布式事务有哪些解决方案?

"你在项目里用过分布式事务吗?2PC 有什么缺陷?TCC 的补偿逻辑怎么写?Saga 和 TCC 有什么区别?" —— 面试官想考察的是你对分布式事务各种模式的深入理解,而不只是知道名词。

在单体应用中,数据库事务(ACID)就够了。但在微服务架构中,一个业务操作可能跨越多个服务和多个数据库:

典型场景 · 电商下单的分布式事务
// 下单流程涉及三个微服务,三个数据库

订单服务(订单库):创建订单记录
库存服务(库存库):扣减商品库存
账户服务(账户库):扣减用户余额

// 问题:如果订单创建成功,但库存扣减失败?
// 问题:如果库存扣减成功,但余额扣减失败?
// 需要保证三个操作要么全成功,要么全回滚

// 传统 @Transactional 只能管一个数据库
// 跨数据库、跨服务的事务 → 需要分布式事务方案

分布式事务的核心挑战:

  • 跨网络:多个服务之间通过网络通信,网络可能失败
  • 跨数据库:每个服务有自己的数据库,无法共享事务
  • 跨进程:每个服务是独立进程,无法共享 JVM 级别的事务上下文

主流的分布式事务方案从强到弱排列:2PC → 3PC → TCC → Saga → 消息最终一致性

第 2 站

2PC:两阶段提交

2PC(Two-Phase Commit)是最经典的分布式事务协议,由 Jim Gray 在 1970 年代提出。核心思想是引入一个协调者(Coordinator)来统一管理所有参与者(Participant)。

2PC 两阶段提交流程 协调者 Coordinator 参与者 A(订单) 参与者 B(库存) 参与者 C(账户) 阶段一:Prepare(准备 / 投票) Can commit? Can commit? Can commit? 执行本地事务 写 redo/undo 日志 返回 YES 执行本地事务 写 redo/undo 日志 返回 YES 执行本地事务 写 redo/undo 日志 返回 YES/NO 阶段二:Commit / Rollback(提交或回滚) 全部 YES → 发送 COMMIT 指令;任一 NO → 发送 ROLLBACK 指令 参与者收到指令后执行提交或回滚,并回复 ACK 协调者收到所有 ACK 后,事务结束
图 12PC 两阶段提交:准备阶段投票 + 提交阶段执行
2PC 的致命问题 · 同步阻塞
// 2PC 的四个致命问题

1. 同步阻塞:
   准备阶段所有参与者持有本地锁(如行锁),等待协调者的提交/回滚指令
   → 如果协调者响应慢,所有参与者的资源都被锁住
   → 高并发下性能极差

2. 单点故障:
   协调者挂了 → 所有参与者无限期阻塞
   参与者挂了 → 协调者等待超时后回滚

3. 数据不一致:
   阶段二发送 COMMIT 时,如果部分参与者收到、部分没收到
   → 有的提交了,有的没提交 → 数据不一致

4. 性能差:
   两次网络往返(prepare + commit),且全程同步阻塞
   → 吞吐量极低,不适合互联网高并发场景
核心结论
2PC 是强一致性的分布式事务方案,但同步阻塞、单点故障、性能差使其几乎不适用于互联网高并发场景。它主要用在传统企业的 XA 事务中(如银行核心系统)。

XA 标准接口:

XA 接口规范 · 数据库层面的 2PC
// XA 是 X/Open 组织定义的分布式事务标准接口
// 大多数关系型数据库都支持 XA:MySQL、Oracle、PostgreSQL

XA 事务的四个阶段1. XA_START  — 开启分布式事务分支
2. XA_END    — 结束事务分支(但不提交)
3. XA_PREPARE — 准备阶段(参与者确认能否提交)
4. XA_COMMIT / XA_ROLLBACK — 提交或回滚

// MySQL 中的 XA 使用
XA START 'tx1';
UPDATE account SET balance = balance - 100 WHERE user_id = 'A';
XA END 'tx1';
XA PREPARE 'tx1';  -- 此时事务已持久化,但未提交
-- 协调者确认所有参与者都 PREPARE 成功后:
XA COMMIT 'tx1';

// Java 中使用 XA
XADataSource xaDs = new MysqlXADataSource();
xaDs.setUrl("jdbc:mysql://localhost/db1");
XAConnection xaConn = xaDs.getXAConnection();
XAResource xaRes = xaConn.getXAResource();

Xid xid = new XidImpl(1, "global-tx-1".getBytes(), "branch-1".getBytes());
xaRes.start(xid, XAResource.TMNOFLAGS);
// 执行 SQL...
xaRes.end(xid, XAResource.TMSUCCESS);
xaRes.prepare(xid);
xaRes.commit(xid, false);

为什么互联网不用 XA?

  • XA 准备阶段持有数据库行锁,整个 2PC 过程锁不释放 → 并发性能极差
  • 协调者(Transaction Manager)是单点,挂了所有参与者阻塞
  • 跨数据库的 XA 需要所有参与者都支持 XA 接口,异构系统集成困难
  • 互联网场景追求高吞吐、低延迟,XA 的同步阻塞模式无法接受
第 3 站

3PC:三阶段提交——超时处理的改进

3PC(Three-Phase Commit)在 2PC 基础上增加了Pre-Commit阶段,并引入了超时机制

3PC 三个阶段
阶段一:CanCommit(询问)
  协调者问参与者"你能提交吗?"
  参与者只做预检查,不执行事务
  → 返回 YES / NO

阶段二:PreCommit(预提交)
  如果全部 YES → 协调者发送 PreCommit 指令
  参与者执行本地事务(但不提交),写 redo/undo 日志
  → 返回 ACK

阶段三:DoCommit(提交)
  协调者收到所有 ACK → 发送 DoCommit
  参与者提交本地事务

// 3PC 相比 2PC 的改进
// 1. CanCommit 阶段不锁资源,减少阻塞时间
// 2. 引入超时机制:如果阶段三超时,参与者自动提交
//    (假设能进入阶段三说明之前都同意了)

// 但 3PC 仍然无法完全解决数据不一致:
// 网络分区时,部分参与者超时提交,部分等待回滚 → 仍然不一致
工程实践
3PC 在理论上比 2PC 好,但实际工程中几乎不用。原因是:实现复杂、仍然无法保证强一致性、性能依然不好。工程上更多使用 TCC 和 Saga 方案。
第 4 站

TCC:Try-Confirm-Cancel 业务级事务

TCC 是一种业务层面的两阶段提交方案。不同于 2PC 依赖数据库,TCC 把事务逻辑写到业务代码中:

TCC 三步流程:Try → Confirm / Cancel Try(预留) 检查资源并预留 冻结库存/冻结余额 不做实际变更 Confirm(确认) 执行实际变更 扣减冻结的库存 幂等、可重试 Cancel(取消) 释放预留资源 解冻库存/解冻余额 幂等、可重试 全部 Try 成功 任一 Try 失败 Cancel 所有已 Try 的 Confirm 所有参与者 关键:Cancel 要能处理"空回滚"(Try 没执行就收到 Cancel) 关键:Confirm/Cancel 必须幂等(可能重试多次)
图 2TCC 业务级两阶段事务:Try 预留资源,Confirm 执行,Cancel 回滚
InventoryTccService.java · 库存服务 TCC 实现
public class InventoryTccService {

    // Phase 1: Try — 冻结库存(不实际扣减)
    public boolean tryDeduct(String txId, String productId, int qty) {
        // 幂等检查:是否已经 Try 过
        if (tccLogRepository.exists(txId, "TRY")) return true;

        // 检查库存是否充足
        Inventory inv = inventoryRepo.findByProductId(productId);
        if (inv.getAvailable() < qty) return false;

        // 冻结库存:available -= qty, frozen += qty
        inv.setAvailable(inv.getAvailable() - qty);
        inv.setFrozen(inv.getFrozen() + qty);
        inventoryRepo.save(inv);

        // 记录 TCC 事务日志
        tccLogRepository.save(txId, "TRY", productId, qty);
        return true;
    }

    // Phase 2a: Confirm — 实际扣减冻结库存
    public void confirm(String txId, String productId, int qty) {
        // 幂等:已确认过就跳过
        if (tccLogRepository.exists(txId, "CONFIRM")) return;

        Inventory inv = inventoryRepo.findByProductId(productId);
        inv.setFrozen(inv.getFrozen() - qty); // 冻结减少
        inventoryRepo.save(inv);
        tccLogRepository.save(txId, "CONFIRM", productId, qty);
    }

    // Phase 2b: Cancel — 释放冻结库存
    public void cancel(String txId, String productId, int qty) {
        // 幂等:已取消过就跳过
        if (tccLogRepository.exists(txId, "CANCEL")) return;

        // 空回滚处理:Try 没执行就收到 Cancel
        if (!tccLogRepository.exists(txId, "TRY")) {
            tccLogRepository.save(txId, "CANCEL_EMPTY", productId, qty);
            return;
        }

        Inventory inv = inventoryRepo.findByProductId(productId);
        inv.setFrozen(inv.getFrozen() - qty);
        inv.setAvailable(inv.getAvailable() + qty);
        inventoryRepo.save(inv);
        tccLogRepository.save(txId, "CANCEL", productId, qty);
    }
}
TCC 的三大难点
  • 业务侵入性高:每个服务都要写 Try/Confirm/Cancel 三个接口,代码量 ×3
  • 幂等性:Confirm 和 Cancel 必须幂等,网络重试可能调用多次
  • 空回滚和悬挂:Try 超时后收到 Cancel(空回滚);Cancel 执行后 Try 才到达(悬挂)
第 5 站

Saga:长事务编排与补偿

Saga 将一个长事务拆分成一系列本地短事务,每个短事务都有对应的补偿操作。如果某一步失败,就反向执行补偿操作:

Saga:正向执行 + 反向补偿 正向执行:T1 → T2 → T3 → T4 T1 创建订单 T2 扣库存 T3 扣余额 T4 发短信 ✕ T4 失败 → 反向补偿:C3 → C2 → C1 C1 取消订单 C2 回滚库存 C3 退回余额 每个 Ti 都有对应的补偿操作 Ci,失败时反向执行补偿
图 3Saga 模式:正向执行本地事务,失败时反向执行补偿操作

Saga 有两种实现方式:

方式原理优势劣势
编排式
(Orchestration)
中央协调者按顺序调用各服务流程清晰,易追踪协调者是中心点
协同式
(Choreography)
各服务通过事件(MQ)驱动,无中央协调者松耦合,无单点流程分散,难追踪
编排式 Saga 伪代码 · Order Saga Coordinator
public class OrderSagaOrchestrator {

    // Saga 步骤定义
    public Saga<OrderContext> buildSaga() {
        return Saga
            .<OrderContext>create()
            .step("createOrder")
                .action(ctx -> orderService.create(ctx))
                .compensation(ctx -> orderService.cancel(ctx))
            .step("deductInventory")
                .action(ctx -> inventoryService.deduct(ctx))
                .compensation(ctx -> inventoryService.rollback(ctx))
            .step("deductBalance")
                .action(ctx -> accountService.deduct(ctx))
                .compensation(ctx -> accountService.refund(ctx))
            .step("sendNotification")
                .action(ctx -> notificationService.send(ctx))
                .compensation(ctx -> {})  // 通知无需补偿
            .build();
    }

    // 执行 Saga
    public void execute(OrderContext ctx) {
        saga.execute(ctx);
        // 如果某步失败,自动反向执行已完成步骤的 compensation
    }
}
Saga vs TCC
Saga:每一步直接提交本地事务,失败时通过补偿操作回滚。适合长事务、步骤间无强依赖。
TCC:先预留资源(Try),全部成功后再确认(Confirm)。适合对资源控制要求高的场景(如金融)。
关键区别:Saga 中间状态可见(T1 提交后其他服务能看到),TCC 在 Confirm 之前中间状态不可见。
第 6 站

消息最终一致性:事务消息与事务发件箱

对于不需要强一致性的场景,基于消息队列的最终一致性方案是性价比最高的选择。

方案一:事务消息(Transaction Outbox)

事务发件箱模式 · Transactional Outbox Pattern
// 核心问题:本地事务 + 发送 MQ 消息 不是原子的
// 如果本地事务成功但消息发送失败 → 下游收不到通知
// 如果消息发送成功但本地事务回滚 → 下游收到"幽灵消息"

事务发件箱模式// 1. 同一个本地事务中:写业务表 + 写消息表
@Transactional
public void createOrder(Order order) {
    orderRepository.save(order); // 写业务表

    OutboxMessage msg = new OutboxMessage();
    msg.setTopic("order-created");
    msg.setPayload(toJson(order));
    msg.setStatus("PENDING");
    outboxRepository.save(msg); // 写消息表(同一个数据库,同一个事务)
}

// 2. 独立线程/定时任务:扫描消息表,发送到 MQ
@Scheduled(fixedRate = 1000)
public void publishMessages() {
    List<OutboxMessage> pending = outboxRepository
        .findByStatus("PENDING");

    for (OutboxMessage msg : pending) {
        mqProducer.send(msg.getTopic(), msg.getPayload());
        msg.setStatus("SENT");
        outboxRepository.save(msg);
    }
}
事务发件箱模式:本地事务保证业务+消息的原子性 本地事务 写业务表 + 写消息表 同一事务,保证原子性 消息表 PENDING 状态 等待投递 消息队列 RocketMQ / Kafka 下游消费 同事务 定时投递 核心保证 业务表和消息表在同一个数据库中 → 本地事务保证原子写入
图 4事务发件箱模式:本地事务写业务+消息,独立线程投递消息

方案二:RocketMQ 事务消息

RocketMQ 事务消息 · 半消息机制
// RocketMQ 的事务消息流程

1. 发送半消息(Half Message)
   → 消息对消费者不可见(存在半消息队列)

2. 执行本地事务
   → 成功 → 发送 Commit → 消息对消费者可见
   → 失败 → 发送 Rollback → 删除半消息

3. 如果 RocketMQ 没收到 Commit/Rollback
   → 定时回查(TransactionListener.checkLocalTransaction)
   → 检查本地事务状态,决定 Commit 还是 Rollback

// 本质是把"事务发件箱"的逻辑搬到了 MQ Broker 侧
第 7 站

Seata 框架:四种模式对比

Seata(Simple Extensible Autonomous Transaction Architecture)是阿里巴巴开源的分布式事务框架,支持四种模式:

模式原理一致性性能适用场景
AT自动补偿(类 2PC,但无锁)最终一致传统业务,不想改代码
TCCTry-Confirm-Cancel 业务层最终一致核心业务,资源预留
Saga长事务拆分 + 补偿最终一致长流程、跨机构
XA标准 XA 两阶段提交强一致传统企业应用
Seata AT 模式核心原理 · 自动补偿
// AT 模式的核心思想:自动帮你做补偿

// 阶段一:业务 SQL 执行
// Seata 拦截 SQL,自动记录 undo_log(回滚日志)
UPDATE inventory SET stock = stock - 1 WHERE product_id = 'P001';
// Seata 记录:
// before_image: {product_id: P001, stock: 100}
// after_image:  {product_id: P001, stock: 99}

// 阶段二:
// 全部成功 → 异步删除 undo_log(非常轻量)
// 任一失败 → 根据 undo_log 的 before_image 反向执行
// → UPDATE inventory SET stock = 100 WHERE product_id = 'P001'

// AT vs XA 的关键区别:
// XA:Prepare 阶段持有数据库行锁,直到 Commit → 同步阻塞
// AT:Prepare 阶段直接提交本地事务,释放行锁 → 异步补偿
// 所以 AT 性能比 XA 好很多,但只能保证最终一致性

如何选择?

选型决策树 · 分布式事务方案选择
需要强一致性?
├── 是 → 2PC / XA(传统金融系统,能接受低性能)
└── 否(最终一致性可接受)
    ├── 业务能拆分出 Try/Confirm/Cancel?
    │   ├── 是 → TCC(核心业务,如交易、支付)
    │   └── 否 → Saga / AT
    ├── 流程长、步骤多?
    │   ├── 是 → Saga(跨机构、跨系统)
    │   └── 否 → AT(简单业务,不想改代码)
    └── 能接受消息异步方案?
        └── 是 → 消息最终一致性(性价比最高)
本课文脑图回顾
  • 2PC:两阶段提交,强一致但同步阻塞、单点故障、性能差
  • 3PC:增加预提交和超时机制,改进有限,工程中不用
  • TCC:业务层 Try-Confirm-Cancel,性能好但代码量大,要处理幂等/空回滚/悬挂
  • Saga:长事务拆分+补偿,编排式 vs 协同式,中间状态可见
  • 消息最终一致性:事务发件箱模式/RocketMQ 事务消息,性价比最高
  • Seata 四种模式:AT(自动补偿)/ TCC / Saga / XA,按需选择