分布式事务 2PC 与 Saga 模式的选型决策:从一致性到可用性的工程权衡

cover

一、分布式事务的"不可能三角":一致性、可用性与性能的拉锯

微服务架构下,一个业务操作往往跨越多个数据源。比如电商下单需要同时扣减库存、创建订单、扣减账户余额——任何一步失败都需要回滚。但分布式环境下网络分区不可避免,CAP 定理告诉我们:强一致性与高可用无法同时满足。2PC 追求强一致但阻塞资源,Saga 牺牲隔离性换取可用性。选错模型,轻则超时雪崩,重则数据不一致。

graph TB
    A[分布式事务需求] --> B{一致性要求}
    B -->|强一致| C[2PC / TCC]
    B -->|最终一致| D[Saga 模式]
    C --> E[阻塞型<br/>资源锁定]
    D --> F[补偿型<br/>无锁定]
    E --> G[适用场景:<br/>金融转账/库存扣减]
    F --> H[适用场景:<br/>订单流转/数据同步]
    G --> I[风险: 阻塞超时<br/>性能瓶颈]
    H --> J[风险: 脏读/补偿失败<br/>需要人工介入]

二、2PC 与 Saga 的底层机制深度对比

2PC 的两阶段提交流程

2PC 引入协调者(Coordinator)角色,第一阶段(Prepare)所有参与者将操作写入 Undo/Redo Log 并锁定资源,第二阶段(Commit/Rollback)统一决定提交或回滚。核心问题在于:Prepare 成功后参与者必须等待协调者的最终指令,期间资源被锁定。

sequenceDiagram
    participant C as 协调者
    participant P1 as 参与者A
    participant P2 as 参与者B

    C->>P1: Phase 1: Prepare
    C->>P2: Phase 1: Prepare
    P1-->>C: Vote Commit (锁定资源)
    P2-->>C: Vote Commit (锁定资源)
    Note over C: 所有参与者同意,决定提交
    C->>P1: Phase 2: Commit
    C->>P2: Phase 2: Commit
    P1-->>C: ACK
    P2-->>C: ACK

    Note over C,P2: 异常场景: P2 在 Prepare 后宕机
    C->>P1: Phase 2: Commit (超时重试)
    Note over P1: 资源持续锁定,直到 P2 恢复

Saga 的补偿事务机制

Saga 将长事务拆分为多个本地事务,每个本地事务提交后立即释放资源。若某一步失败,则逆序执行之前步骤的补偿事务。Saga 分为编排式(Choreography)和协调式(Orchestration)两种实现。

sequenceDiagram
    participant O as Saga 协调器
    participant S1 as 库存服务
    participant S2 as 订单服务
    participant S3 as 账户服务

    O->>S1: 扣减库存 (正向)
    S1-->>O: 成功 (已提交,资源释放)
    O->>S2: 创建订单 (正向)
    S2-->>O: 成功 (已提交,资源释放)
    O->>S3: 扣减余额 (正向)
    S3-->>O: 失败 (余额不足)

    Note over O: 触发补偿流程
    O->>S2: 取消订单 (补偿)
    S2-->>O: 补偿成功
    O->>S1: 恢复库存 (补偿)
    S1-->>O: 补偿成功

三、生产级代码实现与最佳实践

3.1 基于 Seata 的 2PC AT 模式实现

Seata 的 AT 模式是对 2PC 的改良,通过拦截 SQL 自动生成回滚日志(Undo Log),降低业务侵入性。

// 全局事务注解 — 发起方
@GlobalTransactional(name = "create-order", timeoutMills = 30000)
public OrderResult createOrder(OrderRequest request) {
    // 步骤1: 扣减库存(远程调用库存服务)
    StockResult stockResult = stockService.deduct(
        new StockDeductRequest(request.getSkuId(), request.getQuantity())
    );
    if (!stockResult.isSuccess()) {
        throw new BusinessException("库存不足");
    }

    // 步骤2: 创建订单(本地事务)
    Order order = orderMapper.insert(
        Order.builder()
            .skuId(request.getSkuId())
            .quantity(request.getQuantity())
            .status(OrderStatus.CREATED)
            .build()
    );

    // 步骤3: 扣减账户余额(远程调用账户服务)
    AccountResult accountResult = accountService.debit(
        new AccountDebitRequest(request.getUserId(), order.getTotalAmount())
    );
    if (!accountResult.isSuccess()) {
        // 抛出异常触发全局回滚,Seata 自动根据 Undo Log 回滚步骤1和2
        throw new BusinessException("余额不足");
    }

    return OrderResult.success(order);
}

// 分支事务 — 库存服务(无需额外注解,Seata 代理数据源自动处理)
@Transactional
public StockResult deduct(StockDeductRequest request) {
    // Seata AT 模式在执行 SQL 前自动生成 Undo Log
    // 包含修改前后的数据快照,用于回滚
    int affected = stockMapper.deductStock(
        request.getSkuId(), request.getQuantity()
    );
    if (affected == 0) {
        // 库存不足,本地事务回滚,Seata 感知后标记该分支回滚
        throw new StockInsufficientException("库存扣减失败");
    }
    return StockResult.success();
}

3.2 基于 Seata Saga 状态机模式实现

// Saga 状态机 JSON 定义(简化版)
// 定义状态转换与补偿逻辑
{
  "Name": "createOrderSaga",
  "Comment": "订单创建 Saga 流程",
  "StartState": "DeductStock",
  "States": {
    "DeductStock": {
      "Type": "ServiceTask",
      "ServiceName": "stockService",
      "ServiceMethod": "deduct",
      "CompensateState": "CompensateStock",
      "Next": "CreateOrder",
      "Input": ["$.stockRequest"],
      "Output": {"stockResult": "$.stockResult"},
      "Status": {"#root.success": "SU", "#root.fail": "FA"}
    },
    "CompensateStock": {
      "Type": "ServiceTask",
      "ServiceName": "stockService",
      "ServiceMethod": "compensateDeduct",
      "Input": ["$.stockRequest"]
    },
    "CreateOrder": {
      "Type": "ServiceTask",
      "ServiceName": "orderService",
      "ServiceMethod": "create",
      "CompensateState": "CancelOrder",
      "Next": "DebitAccount",
      "Input": ["$.orderRequest", "$.stockResult"]
    },
    "CancelOrder": {
      "Type": "ServiceTask",
      "ServiceName": "orderService",
      "ServiceMethod": "cancel",
      "Input": ["$.orderRequest"]
    },
    "DebitAccount": {
      "Type": "ServiceTask",
      "ServiceName": "accountService",
      "ServiceMethod": "debit",
      "CompensateState": "RefundAccount",
      "Next": "Succeed",
      "Input": ["$.accountRequest"]
    },
    "RefundAccount": {
      "Type": "ServiceTask",
      "ServiceName": "accountService",
      "ServiceMethod": "refund",
      "Input": ["$.accountRequest"]
    },
    "Succeed": {"Type": "Succeed"},
    "Fail": {"Type": "Fail"}
  }
}

// Java 触发 Saga 执行
public OrderResult createOrderSaga(OrderRequest request) {
    // 构造 Saga 输入参数
    Map<String, Object> params = new HashMap<>();
    params.put("stockRequest", new StockDeductRequest(
        request.getSkuId(), request.getQuantity()));
    params.put("orderRequest", request);
    params.put("accountRequest", new AccountDebitRequest(
        request.getUserId(), request.getTotalAmount()));

    // 启动状态机实例
    StateMachineInstance instance = stateMachineEngine.start(
        "createOrderSaga", null, params
    );

    // 等待执行完成(生产环境建议异步回调)
    if (ExecutionStatus.SU.equals(instance.getStatus())) {
        return OrderResult.success();
    } else {
        // Saga 执行失败,补偿已自动触发
        return OrderResult.fail(instance.getException().getMessage());
    }
}

3.3 补偿事务的幂等性保障

// 补偿操作必须幂等 — 同一请求多次执行结果一致
@Transactional
public void compensateDeduct(StockDeductRequest request) {
    // 1. 幂等检查:查询补偿记录表
    CompensateRecord record = compensateMapper.selectByBizId(
        request.getBizId(), "STOCK_DEDUCT"
    );
    if (record != null && record.getStatus() == CompensateStatus.DONE) {
        // 已补偿过,直接返回,避免重复恢复库存
        log.info("补偿操作已执行,跳过: bizId={}", request.getBizId());
        return;
    }

    // 2. 执行补偿逻辑
    stockMapper.restoreStock(request.getSkuId(), request.getQuantity());

    // 3. 记录补偿状态(同一事务内)
    if (record == null) {
        compensateMapper.insert(CompensateRecord.builder()
            .bizId(request.getBizId())
            .type("STOCK_DEDUCT")
            .status(CompensateStatus.DONE)
            .build());
    } else {
        compensateMapper.updateStatus(
            record.getId(), CompensateStatus.DONE);
    }
}

四、2PC 与 Saga 的架构权衡分析

4.1 性能与资源占用对比

维度2PC (AT 模式)Saga (状态机)
资源锁定Prepare 阶段锁定,提交后释放无锁定,每步提交后立即释放
吞吐量低(锁定期间阻塞其他事务)高(无锁定,并发友好)
延迟取决于最慢参与者的 Prepare 时间每步独立提交,总延迟为各步之和
回滚代价低(Undo Log 自动回滚)高(需执行补偿事务,可能多次重试)

4.2 一致性保证差异

2PC 保证 ACID 中的隔离性——事务执行过程中其他事务看不到中间状态。Saga 不保证隔离性:步骤1提交后,其他事务可以读到库存已扣减但订单未创建的中间状态。这就是所谓的"脏读"问题。

4.3 适用边界与禁用场景

2PC 适用场景:

  • 金融转账、库存扣减等对一致性要求极高的场景
  • 参与者数量少(3 个以内)、事务持续时间短(秒级)

2PC 禁用场景:

  • 参与者超过 5 个,协调者成为瓶颈
  • 事务持续时间超过 10 秒,锁定资源过久
  • 跨公司/跨数据中心的网络不可靠环境

Saga 适用场景:

  • 订单流转、数据同步等可容忍最终一致性的场景
  • 长事务(分钟级甚至小时级)
  • 参与者多、网络不可靠的微服务环境

Saga 禁用场景:

  • 不允许脏读的金融核心场景
  • 补偿事务无法实现(如发送邮件后无法撤回)
  • 补偿代价远大于正向操作的场景

五、总结

2PC 与 Saga 不是非此即彼的选择,而是根据业务特性匹配不同模型。核心判断依据:业务能否容忍中间状态被观察到?如果能,Saga 的无锁设计带来更高吞吐;如果不能,2PC 的强一致性保障更可靠。实际生产中,混合使用是常见策略——核心链路用 2PC,非核心链路用 Saga。无论选择哪种模型,都必须实现幂等性、超时重试和人工干预入口,这是分布式事务从"能跑"到"可靠"的分水岭。

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐