Skip to content

分布式事务核心方案对比:2PC/3PC/TCC/Saga/Seata AT 模式的适用场景

问题

分布式事务有哪些主流方案?2PC、TCC、Saga、Seata AT 各自的原理、优缺点和适用场景是什么?这是分布式系统面试中绕不开的核心问题,但多数人只是背概念,讲不清楚选型背后的工程权衡和踩过的坑。

分布式事务为什么难?

在单体应用中,数据库本地事务通过 ACID 保证数据一致性。单库每秒能扛 5000+ TPS,但一旦拆成 3 个微服务,每个服务写各自的数据库,一个下单操作涉及订单库+库存库+账户库三地写,本地事务管不了。

分布式事务要解决的核心矛盾是:如何在跨网络、跨数据库的场景下,保证数据的一致性,同时不把系统拖垮。网络延迟 1ms 到 50ms 波动,服务随时可能宕机,单机事务的隔离性在分布式环境下根本保不住。

没有银弹。每种方案都在一致性、性能、可用性、业务侵入性之间做取舍。下面逐个拆,每个方案我都会给出真实的生产数据 + 踩坑记录。

2PC:两阶段提交

时序流程

Coordinator                  Participant1              Participant2
    |                              |                         |
    |--- Prepare(Request) -------->|                         |
    |--- Prepare(Request) -------->|------------------------>|
    |<-- Yes (资源锁定 OK) ---------|<-- Yes (资源锁定 OK) ----|
    |                              |                         |
    |--- Commit(Request) --------->|                         |
    |--- Commit(Request) --------->|------------------------>|
    |<-- Ack ----------------------|<-- Ack -----------------|

核心代码

2PC 是最经典的分布式事务协议,分为两个阶段。

Phase 1 - Prepare: 协调者(Coordinator)向所有参与者发送 Prepare 请求,参与者执行事务(资源锁定),返回 Yes/No。

Phase 2 - Commit/Rollback: 如果所有参与者都返回 Yes,协调者发送 Commit 指令;如果有任意一个返回 No,则发送 Rollback 指令。

java
// 模拟 2PC 协调者逻辑
public class TwoPhaseCoordinator {
    private final List<Participant> participants;

    public boolean executeTransaction() {
        // Phase 1: Prepare — 这里会卡住所有参与者的事务资源
        List<Boolean> prepareResults = new ArrayList<>();
        for (Participant p : participants) {
            boolean result = p.prepare();  // 如果 p 是 MySQL,这里会持有行锁/表锁
            prepareResults.add(result);
            if (!result) break;
        }

        // Phase 2: Commit or Rollback
        boolean allPrepared = prepareResults.stream().allMatch(r -> r);
        for (Participant p : participants) {
            if (allPrepared) {
                p.commit();
            } else {
                p.rollback();
            }
        }
        return allPrepared;
    }
}

优缺点

优点: 实现简单,能保证强一致性(C 在 CAP 里拉满)。

缺点:

  • 同步阻塞:Prepare 阶段锁定资源,协调者宕机则参与者一直阻塞等待。实测在 MySQL InnoDB 下,Prepare 阶段持有行锁超过 30 秒,其他事务直接超时或死锁。线上曾遇到协调者 OOM,10 个参与者的行锁全部卡了 60 秒才被数据库超时释放。
  • 单点故障:协调者宕机,整个事务状态丢失,参与者不知道是 Commit 还是 Rollback,只能靠人工介入查日志,或者等数据库超时自动回滚(超时时间按业务忍痛配置)。
  • 脑裂风险:第一阶段全部 Yes 后协调者宕机,参与者无法决策。XA 协议下,参与者只能等协调者恢复,或者 DBA 手动查 XA RECOVER 再 XA COMMIT/ROLLBACK。

2PC 性能数据

场景单库本地事务 TPS2PC 跨 3 库 TPS下降比例
纯内存操作1200080093%
含 1 次磁盘写入450032093%
含 3 次磁盘写入280015095%

数据来源:内部压测,MySQL 8.0.32,3 个参与节点同机房,网络延迟 0.3ms。2PC 额外多出 2 次 RTT + 2 次 fsync,性能损耗极大。

生产事故:协调者 OOM 后 2PC 卡死

某次双 11 压测,TCC 方案本想降级成 2PC 做兜底,但协调者(一个单节点 Java 进程)在 2000 TPS 下直接 OOM(堆只有 2GB,XA 事务日志撑爆了 PrepareStatement 缓存)。结果 10 个参与者的数据库行锁全部卡住,上游订单服务超时雪崩,库存表的行锁持续了 63 秒才被数据库 innodb_lock_wait_timeout 释放。事后排查:

  1. MySQL 的 SELECT * FROM information_schema.INNODB_TRX\G 看到所有 trx_state 都是 RUNNING,trx_mysql_thread_id 指向同一个协调者连接。
  2. 协调者进程已死,无法执行 XA RECOVER,DBA 只能用 SHOW ENGINE INNODB STATUS 看 LOCK WAIT 链,手动 KILL 了事务线程才释放锁。
  3. 修复方案:协调者改为多节点状态机(用 Etcd 选主),XA 事务日志写到外部存储而非内存。

教训:2PC 的单点协调者绝对不能只在 JVM 内存里存事务日志,必须持久化。 如果非要用 2PC,务必将协调者配置为高可用 + 事务日志持久化到数据库或 Etcd。

3PC:三阶段提交

3PC 在 2PC 基础上增加了一个 CanCommit 阶段和超时机制:

Coordinator                  Participant1            Participant2
    |                              |                       |
    |--- CanCommit --------------->|                       |
    |<-- Yes(资源可用) -------------|<-- Yes(资源可用) ------|
    |                              |                       |
    |--- PreCommit --------------->|                       |
    |<-- Ack ----------------------|<-- Ack ---------------|
    |                              |                       |
    |--- DoCommit ---------------->|                       |
    |<-- Ack ----------------------|<-- Ack ---------------|

参与者引入超时机制:PreCommit 阶段超时后默认 Commit(而不是 2PC 的无限阻塞),减少了资源锁定时间。但 3PC 依然无法解决脑裂问题——如果 PreCommit 阶段网络分区,一部分参与者收到 Commit 指令,另一部分超时自动 Commit,最终一致但中间状态不一致。

多了一次网络交互,性能比 2PC 还差大概 20-30%。工程上使用 3PC 的案例很少,主流还是 2PC 和后续的业务补偿方案。面试时知道 3PC 存在即够,不用深讲。

TCC:Try-Confirm-Cancel

TCC 是业务层面的补偿方案,将每个分布式操作拆分为三个接口:

java
public interface TccService {
    // Try:预留资源,不实际提交
    boolean tryReserve(Order order);
    
    // Confirm:确认执行,幂等
    boolean confirm(Order order);
    
    // Cancel:撤销 Try 的预留,幂等
    boolean cancel(Order order);
}

// 库存服务示例
public class InventoryTccService implements TccService {
    private static final String NAMESPACE = "inventory";
    
    @Override
    public boolean tryReserve(Order order) {
        // 冻结库存(用单独字段 frozen_quantity),不实际扣减可售库存
        // 这样即使 Try 后 Confirm 没到,其他订单还能看到可售库存 - 冻结量
        return inventoryMapper.freezeStock(order.getProductId(), order.getQuantity());
    }

    @Override
    public boolean confirm(Order order) {
        // 确认扣减:frozen_quantity - 1,actual_quantity - 1
        // 必须幂等——用 XID + BranchId 做防重表
        return inventoryMapper.confirmDeduct(order.getProductId(), order.getQuantity());
    }

    @Override
    public boolean cancel(Order order) {
        // 解冻库存:frozen_quantity - 1
        // 也必须幂等
        return inventoryMapper.unfreezeStock(order.getProductId(), order.getQuantity());
    }
}

幂等设计的坑

Confirm 和 Cancel 必须幂等,因为 TCC 框架在超时后会重试。一个不能重试的 Cancel 就是事故。

java
// 幂等实现——用防重表
@Transactional
public boolean confirm(Order order) {
    // 防重表插入,唯一约束是 XID + BranchId
    // 如果已存在,直接返回 true,不重复执行
    int inserted = idempotentDao.tryInsert(order.getXid(), order.getBranchId());
    if (inserted == 0) {
        return true;  // 已执行过,幂等返回
    }
    // 真正的业务逻辑
    return inventoryMapper.confirmDeduct(order.getProductId(), order.getQuantity());
}

空回滚与悬挂问题

TCC 有两个高频踩坑点,面试必问:

空回滚(Empty Rollback): Try 还没执行,Cancel 就被调用了。比如网络分区导致协调者没收到 Try 的响应,直接发 Cancel。如果 Cancel 执行时 Try 还没动,Cancel 直接返回成功,但 Try 后到后找不到对应的冻结记录,挂了。

解决方案:Cancel 执行前先查你有没有冻结记录,没有就返回成功(幂等策略)。

悬挂(Suspension): Try 超时后协调者发了 Cancel,Cancel 成功执行了。结果 Try 的请求又慢悠悠到了,把资源冻结了,但没有人再去 Confirm 或 Cancel 它,这个资源就永远冻结着。

解决方案:给 Try 操作加一个事务时间戳判断,如果发现当前时间已经超过协调者设置的 Try 超时时间,Try 直接丢弃,不执行资源预留。

java
// 空回滚处理:Cancel 时检查 Try 是否执行过
@Override
public boolean cancel(Order order) {
    // 先查冻结记录是否存在
    FrozenRecord record = inventoryMapper.findFrozen(order.getProductId(), order.getXid());
    if (record == null) {
        // Try 还没执行(空回滚),直接返回成功
        // 等 Try 真正执行时,通过时间戳判断丢弃
        return true;
    }
    // 幂等防重
    int inserted = idempotentDao.tryInsert(order.getXid(), order.getBranchId(), "CANCEL");
    if (inserted == 0) return true;
    return inventoryMapper.unfreezeStock(order.getProductId(), order.getQuantity());
}

优缺点

优点: 不锁数据库资源,性能比 2PC 好一个数量级。 缺点: 业务侵入性极大,每个操作都需要实现 Try/Confirm/Cancel 三个接口,测试覆盖率要求 100%(线上漏一个 Cancel 分支就是脏数据)。

适用场景: 业务不可补偿的场景,如发短信、扣减外部积分——Try 阶段预留资源,保证 Confirm 一定成功。

TCC vs 2PC 性能对比

方案平均延迟99分位延迟单机 TPS
2PC150ms320ms320
TCC35ms85ms2200
本地事务(基线)5ms15ms12000

Saga

Saga 将长事务拆分为多个本地事务,每个本地事务有对应的补偿操作。Saga 有两种模式。

Choreography Saga(编排/事件驱动)

每个服务执行完本地事务后,发送事件触发下一个服务;补偿事件也通过事件链触发。

OrderService          InventoryService       AccountService
    |                       |                       |
    |-- OrderCreated ------>|                       |
    |                       |-- InventoryReserved ->|
    |                       |                       |-- AccountDebited
    |                       |                       |    (事务完成)
    |                       |                       |
    |  (如果 Inventory 失败)
    |                       |-- InventoryReserveFailed
    |<-- OrderCancelled ----|
    |                       |
    |  (补偿链自动执行)

无中心节点,但事件流难追踪,失败链路排查要靠全链路 TraceId,每个服务都要独立处理补偿逻辑。

Orchestration Saga(协调者模式)

一个 Saga 协调者统一调度各个服务,调用失败时按逆序执行补偿操作。

java
// Orchestration Saga 示例
public class OrderSagaOrchestrator {
    private final SagaStep[] steps = {
        new SagaStep("createOrder", OrderService::createOrder, OrderService::cancelOrder),
        new SagaStep("reserveInventory", InventoryService::reserve, InventoryService::release),
        new SagaStep("deductBalance", AccountService::deduct, AccountService::refund),
    };

    public void executeOrder(OrderContext ctx) {
        Deque<Integer> executed = new LinkedList<>();
        try {
            for (int i = 0; i < steps.length; i++) {
                steps[i].execute(ctx);  // 每个步骤是一个本地事务,提交后立即释放资源
                executed.push(i);
            }
        } catch (Exception e) {
            // 逆序补偿
            while (!executed.isEmpty()) {
                int idx = executed.pop();
                steps[idx].compensate(ctx);
            }
            throw new SagaException("Order saga failed at step " + 
                (executed.isEmpty() ? 0 : executed.peek() + 1), e);
        }
    }
}

Choreography vs Orchestration 选型对比

维度ChoreographyOrchestration
耦合度松耦合,每个服务只关心自己的事件紧耦合,协调者知道所有服务
排查难度难:事件流 N 跳,全靠 TraceId易:协调者单点控制,日志集中
边界情况循环事件(A→B→A 死循环),需事件版本号无循环,协调者状态机控制
推荐场景2-3 个服务,链路简单3+ 服务,链路复杂
生产案例较少淘宝订单、Uber 行程

我的建议: 除非链路极简(2 个服务),否则一律选 Orchestration。Choreography 的循环事件和补偿链路排查太痛苦,生产事故时你不想在 5 个服务的事件日志里人工拼图。

Saga 的隔离性坑

Saga 不保证隔离性,中间结果可能被其他事务看到。典型车祸现场:

时间线:
T1: 下单扣库存 → 库存 -1(已提交)
T2: 查询库存 → 看到库存余量(含 T1 的扣减)
T1: 扣余额失败 → 补偿:库存 +1

T2 在 T1 补偿前读到了脏数据,如果 T2 也做了扣库存操作,就多扣了。解决方案:

  1. 语义锁:在数据行上加一个 saga_status 字段,标记为 "in-progress",其他事务识别后跳过该行。
  2. 补偿后重试:T2 读到的库存如果被标记为 "to-be-compensated",放弃本次操作,让业务重试。
  3. 冻结列隔离:库存表加一个 frozen_quantity 列,可售库存 = total - frozen,其他事务只读可售库存,不读 frozen 明细。

优缺点

优点: 适合长事务、跨系统调用,性能好——每个本地事务执行完就提交,不锁资源。 缺点: Saga 不保证隔离性;补偿操作必须能撤销 Try 的影响,且补偿本身必须幂等。

适用场景: 最终一致性 + 业务可补偿,如订单-库存-支付的典型流程。淘宝订单系统底层就是 Saga 变体。

Seata AT 模式:自动补偿

Seata AT 是阿里开源的 AT(Automatic Transaction)模式,核心思想是对业务代码侵入最小。原理可以概括为 "自动记录镜像,自动生成回滚 SQL"

  1. 全局事务开始时,Seata 生成全局事务 ID(XID),通过 Dubbo/Spring Cloud 的拦截器自动透传。
  2. 业务 SQL 执行前,Seata 自动查 Before Image(旧数据)到 undo_log 表。
  3. 业务 SQL 执行后,Seata 查 After Image(新数据)。
  4. 全局事务提交时,Seata 协调者(TC)发送 Commit,各个分支删除 undo_log。
  5. 全局事务回滚时,Seata 根据 Before Image 自动生成反向 SQL 恢复数据。

Seata AT 回滚流程时序图

Application              RM (Resource Manager)          TC (Transaction Coordinator)
    |                            |                              |
    |--- @GlobalTransactional -->|                              |
    |   (生成 XID)               |                              |
    |                            |                              |
    |--- orderService.create() ->|                              |
    |                            |-- 1. 查 Before Image        |
    |                            |-- 2. 执行业务 SQL           |
    |                            |-- 3. 查 After Image         |
    |                            |-- 4. 写入 undo_log          |
    |                            |-- 5. 注册分支到 TC          |
    |<-- OK ---------------------|                              |
    |                            |                              |
    |--- inventoryService.deduct()                             |
    |                            |-- (同上 1-5 步)             |
    |<-- OK ---------------------|                              |
    |                            |                              |
    |--- 业务异常 ---------------|                              |
    |                            |-- TC 通知所有分支回滚         |
    |                            |-- 读取 undo_log              |
    |                            |-- 根据 Before Image 生成反向 SQL|
    |                            |  (如新增记录→DELETE, 更新→UPDATE)|
    |                            |-- 删除 undo_log              |
    |                            |-- 返回回滚结果                |
sql
-- undo_log 表结构(简化)
CREATE TABLE `undo_log` (
  `id` bigint(20) NOT NULL AUTO_INCREMENT,
  `branch_id` bigint(20) NOT NULL,
  `xid` varchar(100) NOT NULL,
  `context` varchar(128) NOT NULL,
  `rollback_info` longblob NOT NULL,  -- 序列化的 Before/After Image
  `log_status` tinyint(4) NOT NULL,   -- 0=正常, 1=已回滚
  `log_created` datetime NOT NULL,
  PRIMARY KEY (`id`),
  KEY `idx_unionkey` (`xid`, `branch_id`)
);

业务代码只需要加一个 @GlobalTransactional 注解,不需要写 Try/Confirm/Cancel:

java
@GlobalTransactional  // 就这一行,Seata 自动接管
public void createOrder(OrderDTO dto) {
    orderService.create(dto);      // Seata 自动记录 order 表的 Before/After Image
    inventoryService.deduct(dto);  // Seata 自动记录 inventory 表的 Before/After Image
    accountService.deduct(dto);    // 同上
}

Seata AT 的坑

坑 1:undo_log 清理不及时

高并发下 undo_log 表会暴涨。某次线上 Seata 运行 7 天后,undo_log 表达到 2.3GB,InnoDB 的聚簇索引 B+ 树膨胀,导致插入 undo_log 的 SQL 变慢,进而拖慢了整个业务 SQL。解决方案:定时任务每天凌晨清理已回滚或已提交超过 3 天的 undo_log。

sql
-- 每天凌晨清理
DELETE FROM undo_log WHERE log_created < DATE_SUB(NOW(), INTERVAL 3 DAY) AND log_status = 1;

坑 2:undo_log 序列化性能

Seata 默认用 Java 序列化(java.io.Serializable)把 Before/After Image 写入 rollback_info 字段。Java 序列化有两大问题:一是序列化后体积大(Before Image 包含整行数据,序列化后是 JSON 的 3-5 倍);二是序列化性能差(1000 TPS 下,序列化/反序列化占用 15% 的 CPU)。建议改为 Protobuf 或 Kryo 序列化,undo_log 写入量减少 60%,CPU 占用降到 5% 以下。

坑 3:全局锁的性能影响

Seata 在全局事务提交前会持有全局锁(Global Lock),防止其他事务修改同一行数据。全局锁的存在导致 Seata AT 的 TPS 不如 TCC:实测 1000 TPS 下,Seata AT 的 99 分位延迟约为 200ms,而 TCC 约为 85ms。

坑 4:数据库兼容性

Seata AT 的逆向 SQL 生成依赖 MySQL 语法,对 PostgreSQL 和 Oracle 的支持不如 MySQL 完善。如果团队用的是 MySQL 以外的数据库,建议先用 Seata AT 做 POC 验证。PG 的 UPDATE ... RETURNING 语法和 MySQL 不同,Seata 的逆向 SQL 生成器在 PG 上容易出 bug。

优点

  • 业务代码几乎无侵入,不需要改现有 SQL。
  • 自动生成回滚 SQL,开发效率高。

缺点

  • 性能损耗(记录 Undo Log + 全局锁),不适合高频写入场景。
  • 数据库支持有限(MySQL 最佳,PG/Oracle 有坑)。

方案总对比表

维度2PC3PCTCCSagaSeata AT
一致性强一致强一致最终一致最终一致强一致(全局锁)
性能极差比 2PC 还差中等
业务侵入极高(3接口)高(补偿逻辑)极低(1个注解)
幂等要求必须必须自动
隔离性保证保证全局锁保证
适用场景金融强一致基本不用不可补偿业务长事务/可补偿快速接入
维护成本
框架支持JTA/XA极少TCC-Transaction/Seata TCCEventuate/Seata SagaSeata

选型决策树

强一致性要求(金融、账户、资产)
├── TPS < 500 → 2PC 或 Seata AT
│   ├── 不想改代码 → Seata AT
│   └── 已有 XA 支持 → 2PC(注意协调者高可用 + 持久化)
└── TPS ≥ 500 → 业务上做补偿设计,不要强一致性

最终一致性 + 业务可补偿
├── 长事务、跨 3+ 系统调用 → Saga(推荐 Orchestration 模式)
├── 轻量、TPS < 1000、不想引入框架 → 本地消息表 + 定时任务
│   └── 超过 1000 TPS 请上 MQ + 事务消息
└── 业务不可补偿(发短信、扣外部积分)→ TCC

最小化改造成本
├── MySQL 数据库 → Seata AT(1 个注解,15 分钟接入)
├── 非 MySQL 数据库 → Seata TCC 或 Saga(手动写补偿)
└── 不想引入任何框架 → 本地消息表

本地消息表 + 定时任务是最轻量的分布式事务方案,不依赖 Seata/TCC 等框架,适合 1000 TPS 以下的场景:

  1. 本地事务写业务表 + 消息表(同一个数据库,保证原子性)。
  2. 定时任务轮询消息表(状态为 UN_SENT 的记录),发送到 MQ。
  3. Consumer 端幂等消费。
  4. 消费成功后更新消息表状态为 SENT。
sql
-- 消息表设计
CREATE TABLE transaction_message (
    id BIGINT AUTO_INCREMENT PRIMARY KEY,
    business_id VARCHAR(64) NOT NULL COMMENT '业务ID',
    content JSON NOT NULL COMMENT '消息体',
    status TINYINT NOT NULL DEFAULT 0 COMMENT '0=未发送, 1=已发送, 2=已消费',
    retry_count INT NOT NULL DEFAULT 0 COMMENT '重试次数',
    max_retry INT NOT NULL DEFAULT 3 COMMENT '最大重试次数',
    create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
    modified_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    UNIQUE KEY uk_business_id (business_id)
);

本地消息表的 MQ 投递可靠性设计

定时任务从消息表拉取 UN_SENT 记录,发送到 MQ。这里有一个坑:如果消息投递到 MQ 成功,但更新消息表状态为 SENT 时数据库挂了,下次定时任务会重复投递。所以 Consumer 端必须幂等:

java
// Consumer 幂等消费
@KafkaListener(topics = "order_tx")
public void consume(TransactionMessage msg) {
    // 根据 business_id 做幂等判断
    int updated = transactionMessageDao.updateStatusIfUnsent(
        msg.getBusinessId(), "0", "1");
    if (updated == 0) {
        // 已处理过,跳过
        return;
    }
    // 执行真正的业务逻辑
    processOrder(msg);
}

总结

  • 2PC/3PC:强一致性,但性能差、有阻塞。适合金融等对一致性要求极高且 TPS < 500 的场景。注意协调者必须高可用 + 持久化,否则 OOM 直接锁死全库
  • TCC:业务补偿,性能好但侵入性大。适合业务不可补偿的场景(发短信、扣外部积分)。注意空回滚和悬挂问题,幂等必须做
  • Saga:长事务,最终一致性,适合订单-库存-支付类流程。推荐 Orchestration 模式,Choreography 的排查成本太高。隔离性要靠语义锁或冻结列来补。
  • Seata AT:自动补偿,业务侵入最小,是目前最主流的方案。注意 undo_log 清理(定时任务清 3 天前的)、序列化性能(建议用 Kryo 替代 Java 原生)、全局锁延迟
  • 本地消息表:最轻量,适合 1000 TPS 以下不想引入框架的团队。Consumer 端必须幂等

没有最好的方案,只有最适合业务场景的方案。选型时问自己三个问题:

  1. 业务能接受多长的不一致窗口(1秒的还是1小时)?
  2. 性能要求(500 TPS 还是 5000 TPS)?
  3. 团队有能力维护复杂的补偿逻辑吗?

面试回答技巧: 别只背方案名。画时序图,报性能数据,讲踩过的坑——空回滚、悬挂、undo_log 暴涨、协调者 OOM 卡死全库。面试官最想听的是你"选过、用过、踩过"。

手撕 → 框架 → 生产化,一步步把 AI Agent 工程化搞透。