分布式事务 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 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐