Skip to content

分布式事务全景:2PC、TCC、Saga 与本地消息表

本文是分布式系统系统学习系列的 L2 核心篇。前置:29. coordination-zk-etcd。 面试题汇总参考:05-distributed-transaction-2pc-3pc-tcc-saga-seata

问题:跨库/跨服务怎么做原子性

单机事务靠数据库的本地 undo/redo log 就能保证 ACID。一旦扩展成微服务架构,一个业务流程可能跨三个数据库、四个服务——扣库存在订单库、加积分在用户库、发消息在消息队列。任何一个步骤失败,已经提交的步骤没有回滚机制。

这个问题的本质是:没有全局协调者能做原子提交。分布式事务的每种方案都是对这个约束的某种工程妥协。

2PC:从"两阶段提交"看阻塞代价

两阶段提交是最直接的思路:加一个协调者,问所有人"能提交吗?",都点头就真提交。

┌──────┐     ┌─────────┐     ┌─────────┐
│Coord │     │Resource1│     │Resource2│
│      │phase1│         │     │         │
│      │─────▶│prepare  │     │         │
│      │      │────────▶│     │         │
│      │◀─────│yes/vote │     │         │
│      │      │◀────────│     │         │
│      │─────▶│         │────▶│prepare  │
│      │◀─────│         │◀────│yes/vote │
│      │phase2│         │     │         │
│      │─────▶│commit   │     │         │
│      │      │────────▶│     │         │
│      │      │         │────▶│commit   │
└──────┘     └─────────┘     └─────────┘

阶段一(prepare):协调者问每个参与者"准备好提交了吗?"。参与者写 prepare 日志,给 vote(yes/no)。阶段二(commit/abort):全票 yes 就发 commit,否则发 abort。

问题出在哪儿?

  • 阻塞:参与者 prepare 后要锁资源等协调者决策,期间不能释放。协调者挂了,参与者一直死锁。XA 协议的 XA End/XA Prepare/XA Commit 就是这个流程,MySQL 的 XA 事务给个例子:
    sql
    XA START 'xid1';
    UPDATE account SET balance = balance - 100 WHERE id = 1;
    XA END 'xid1';
    XA PREPARE 'xid1';
    -- 如果两个资源都 prepare 成功
    XA COMMIT 'xid1';
  • 单点:协调者崩了,所有参与者等它恢复。恢复后读日志才知道该 commit 还是 abort,但日志没写全就崩的话,数据可能不一致。
  • 脑裂:协调者 commit 发了,网络断了,部分参与者没收到——它们会一直停留在 prepare 状态。

3PC 加了一个 pre-commit 阶段,参与者在超时后不会一直等,而是自动 abort。但网络分区时照样可能不一致——超时时间怎么定?和分布式共识一样,没有完美的时钟就做不到完美超时。

TCC:业务层的两阶段,比 2PC 更灵活

TCC 把两阶段的资源锁定从"数据库锁行"搬到"业务代码"里,让应用层自己控制粒度。

Try:预留资源(积分占用、库存冻结)
Confirm:确认使用(扣库存、加积分)
Cancel:释放预留(回滚积分、解冻库存)

优势是灵活性高——做不到"锁行"的场景(比如调用第三方支付)也能用。代价是业务代码侵入大,而且有三个经典坑:

空回滚:Try 没执行到(网络超时或服务挂了),但 Cancel 被调了。Cancel 得识别"我根本没 Try 过",不能硬扣。做法:本地事务记录 Try 状态,Cancel 时先查状态。

悬挂:Try 超时后 Cancel 先执行了,然后 Try 的请求又到了。此时 Try 看到的预留资源还没释放——它把 Cancel 已经释放的资源又锁住了。解法:接口里带唯一 id,先判断是否有 Cancel 记录。

幂等:Confirm 和 Cancel 可能被执行多次(网络重试)。每个操作必须是幂等的:扣 100 执行两次,不会变成扣 200。

java
// TCC 幂等 + 防悬挂示例
@Compensable(
    confirmMethod = "confirmDeduct",
    cancelMethod = "cancelDeduct",
    asyncConfirm = false
)
public void tryDeduct(TransactionContext ctx, @BusinessId String orderId, int amount) {
    // 防悬挂:先查是否有 cancel 记录
    if (txRecordDao.existsCancel(orderId)) {
        return;
    }
    // 幂等:判断是否已执行
    if (txRecordDao.exists(orderId)) {
        return;
    }
    txRecordDao.insert(orderId, Status.TRYING);
    // 冻结库存
    inventoryDao.freeze(orderId, amount);
}

Saga:长事务拆补偿,不锁资源

Saga 的思路是:不搞什么两阶段锁定,直接执行每个子事务,如果失败了,反向执行补偿操作。每个子事务正常提交,所以资源不阻塞。

Saga = T1 + T2 + ... + Tn
       + C1 + C2 + ... + Cn-1  (补偿)

编排(Choreography):每个服务执行完后发事件,下一个服务订阅事件执行。Saga 失败时每个服务各自执行自己的补偿。耦合度高,依赖关系隐藏在各事件中,不好追踪。

协同(Orchestration):一个协调者(Orchestrator)管理流程,直接告诉每个服务做什么。流程在代码里清晰可见,但协调者本身是单点。

java
// 协同式 Saga 伪代码(用状态机管理)
// 订单流程:创建订单 -> 扣库存 -> 扣余额 -> 发物流
Saga saga = SagaBuilder.newSaga("order-creation")
    .step()
        .action(t -> orderService.create(t))
        .compensation(t -> orderService.cancel(t))
    .step()
        .action(t -> inventoryService.deduct(t))
        .compensation(t -> inventoryService.rollback(t))
    .step()
        .action(t -> paymentService.charge(t))
        .compensation(t -> paymentService.refund(t))
    .build();

Saga 的假设:补偿操作总能成功。如果补偿也失败,就得走人工介入或定时轮询重试。所以补偿操作要设计成幂等 + 可重试。

本地消息表:最大努力最终一致

这可能是工程上用得最多的方案——不是因为它快,而是因为它简单可靠。

思路:业务操作和消息写入在同一个本地事务中完成,另一个服务异步消费。不保证"同时提交",只保证"最终一致"。

sql
-- 表结构:本地消息表
CREATE TABLE local_message (
    id BIGINT AUTO_INCREMENT PRIMARY KEY,
    business_key VARCHAR(64) NOT NULL,     -- 业务唯一键(幂等用)
    message_body TEXT NOT NULL,            -- JSON 消息体
    status TINYINT DEFAULT 0,             -- 0:待发送 1:已发送 2:已确认
    retry_count INT DEFAULT 0,            -- 重试次数
    next_retry_at DATETIME,               -- 下次重试时间
    created_at DATETIME DEFAULT NOW(),
    confirmed_at DATETIME,
    UNIQUE KEY uk_business_key (business_key)
);
java
// 发送和补偿逻辑
@Transactional
public void createOrderAndSendMessage(Order order) {
    // 1. 业务操作
    orderDao.insert(order);
    inventoryDao.deduct(order.getProductId(), order.getQuantity());
    // 2. 本地消息:和业务在同一个本地事务中
    messageDao.insert(new LocalMessage(
        order.getId().toString(), 
        serialize(order), 
        Status.PENDING
    ));
}

// 定时补偿:扫描超过 30 秒还在 PENDING 的消息
@Scheduled(fixedDelay = 15000)
public void retryPendingMessages() {
    List<LocalMessage> pending = messageDao.findByStatusAndNextRetryBefore(
        Status.PENDING, LocalDateTime.now());
    for (LocalMessage msg : pending) {
        boolean sent = mqProducer.send(msg);
        if (sent) {
            messageDao.updateStatus(msg.getId(), Status.SENT);
        } else {
            messageDao.incrementRetry(msg.getId(), LocalDateTime.now().plusSeconds(30));
        }
    }
}

关键点

  • 业务和消息插入在同一个本地事务——本地要么全做要么全不做,这是可靠性的基础
  • 消费方必须幂等(用 business_key 去重)
  • 补偿轮询会有延迟(30秒~1分钟),不适合强实时场景
  • 和 MQ 的事务消息本质思想一致(MQ 篇 30 提到的事务消息就是把这个逻辑搬到 broker 端)

Seata AT:自动代理 undo log

Seata 是阿里开源的分布式事务框架,支持四种模式。AT 模式最常用,因为它对业务代码零侵入

At 的原理:解析 SQL,生成前后镜像,自动记录 undo log。提交时先写入 undo log,执行 SQL,等全局事务结束后再清除 undo log。

yaml
# Seata AT 配置(Spring Boot 示例)
seata:
  enabled: true
  application-id: order-service
  tx-service-group: my_test_tx_group
  service:
    vgroup-mapping:
      my_test_tx_group: default
    grouplist:
      default: 127.0.0.1:8091
  # undo log 表在业务库里自动创建
  client:
    undo:
      log-table: undo_log
java
// 业务代码完全不变,加个 @GlobalTransactional 就行
@GlobalTransactional
public void createOrder(OrderRequest req) {
    orderService.create(req);       // 执行业务——Seata 自动代理 DataSource
    inventoryService.deduct(req);   // 自动记录 undo_log
    pointService.add(req);          // 任何一个失败,自动回滚 undo_log
}

AT 的代价:全局锁粒度比 TCC 粗(行锁持续到全局事务结束),不适合高并发长事务。TCC 适合并发高、资源紧张的场景,AT 适合代码量少、不想改业务的场景。

常见误区与小结

  • 2PC 不是解决方案,是起点。大多数生产系统不用裸 2PC,用 Seata AT 或 XA 的极少。知道 2PC 是为了理解后面的方案为什么做了那些妥协。
  • TCC 的 Try 和 Cancel 要设计成幂等的。空回滚和悬挂不是边缘情况,是正常流程——网络延迟和超时是常态。
  • Saga 不能保证隔离性。A 事务的中间状态对 B 事务可见,要自己处理脏读。Saga 用于"最终一致"场景,不是"强一致"。
  • 本地消息表不是低端方案。最大努力最终一致覆盖了 90% 的非核心流程(发短信、通知、积分),而且没有分布式事务的协调者开销。
  • Seata AT 不是万能的。AT 的全局锁在高并发下容易成为瓶颈。长事务(超过 10 秒)建议用 TCC 或 Saga。

小结:分布式事务没有银弹——2PC 太慢、TCC 侵入大、Saga 无隔离、本地消息表有延迟。选型公式:强一致性且短事务用 AT/XA,业务逻辑复杂且高并发用 TCC,长流程无强依赖用 Saga,非核心流程用本地消息表。下一篇 31. sharding-consistent-hashing会讲数据怎么分片,分布式事务解决的是"写到哪",分片解决的是"读/写哪份"。

参考

参考:Seata 官方文档(https://seata.apache.org)AT 模式实现说明。 参考:《Designing Data-Intensive Applications》第 9 章"分布式事务"。 参考:Fowler 关于 Saga 的原始文章(https://microservices.io/patterns/data/saga.html)。

手撕 → 框架 → 生产化,一步步把 AI Agent 工程化搞透。
粤ICP备2026104257号-1