分布式事务完全指南:从 2PC 到 TCC、Saga 与消息事务

"用户下单要扣库存、扣余额、加积分,这三件事必须同时成功或同时失败。在单库里 BEGIN-COMMIT 就行;但库存在 A 服务,余额在 B 服务,积分在 C 服务 —— 怎么办?" 这就是分布式事务问题。它没有单库 ACID 那么干净的答案 —— 所有方案都是在一致性、可用性、性能之间的精心折衷。这篇文章把 2PC、3PC、TCCSaga、消息事务讲透,讲清楚每种方案的适用边界和坑。

问题:单库事务在分布式失效

单库事务依赖数据库的两个能力:

  • WAL(预写日志)+ 锁:保证原子性。
  • 持久化 + 崩溃恢复:保证持久性。

跨多个数据库 / 服务后,这两个能力都失效:

BEGIN;
UPDATE inventory SET stock = stock - 1 WHERE sku = 'X';   -- 库存库
UPDATE accounts SET balance = balance - 100 WHERE id = 'u1';  -- 账户库
INSERT INTO points (user_id, amount) VALUES ('u1', 10);   -- 积分库
COMMIT;
-- 多库 COMMIT 不是原子的:中间网络断了怎么办?

这就需要"跨多个独立资源管理器(RM)的事务协调"机制。

2PC:两阶段提交

最经典的分布式事务协议,1970 年代提出。引入一个 事务协调者(TC),事务参与者称为 RM。流程:

阶段 1:Prepare(准备)
  TC -> 所有 RM: "准备好提交了吗?"
  RM 各自执行本地操作但不真正提交,记录日志,锁住资源
  RM -> TC: "Yes / No"

阶段 2:Commit / Rollback
  如果所有 RM 都说 Yes:
    TC -> 所有 RM: "提交"
    RM 真正提交,释放锁
  否则:
    TC -> 所有 RM: "回滚"

XA 协议就是 2PC 的标准实现。MySQL XA、Oracle DTP 都支持。Spring 的 JTA + Atomikos / Bitronix 是 Java 生态的典型 2PC 框架。

2PC 的致命问题

  • 同步阻塞:第一阶段后所有 RM 都锁住资源等 TC,直到第二阶段。如果 TC 慢或网络抖,资源被长时间锁住,业务停摆。
  • 单点故障:TC 挂了所有 RM 都不知道该提交还是回滚,卡死。
  • 脑裂:阶段 2 时 TC 发出 commit 但只有一部分 RM 收到,数据不一致。
  • 性能差:每次事务 4 次网络往返(2 准备 + 2 提交),且锁占用时间长。

这些缺陷让 2PC 在互联网级系统里几乎不被采用。金融核心系统因为对一致性要求极高、且 QPS 不高,可以接受 2PC 的代价。互联网业务普遍弃用

3PC:三阶段提交

2PC 的改进,加一个 CanCommit 阶段,引入超时机制减少阻塞:

阶段 1:CanCommit 询问能否参与(不锁资源)
阶段 2:PreCommit (类似 2PC 的 Prepare,锁资源)
阶段 3:DoCommit / Abort

3PC 在某些场景下能避免"无限阻塞",但没有解决脑裂,且复杂度更高。实际使用很少,在工业界基本被 Raft / Paxos 替代。

TCC:Try-Confirm-Cancel

2PC 锁资源等待的问题,TCC 用业务补偿解决。每个操作分三步:

Try:    检查 + 预留资源(不真正扣)
Confirm:真正扣
Cancel: 释放预留

具体例子(下单)

// Try 阶段:三个服务各自预留
inventoryService.try_deduct(orderId, sku='X', qty=1);  // 库存冻结(stock-1, frozen+1)
accountService.try_deduct(orderId, userId='u1', amount=100); // 余额冻结
pointsService.try_add(orderId, userId='u1', amount=10);   // 积分预记

// 全部 Try 成功 -> Confirm
inventoryService.confirm(orderId);   // 冻结的真正扣掉
accountService.confirm(orderId);
pointsService.confirm(orderId);

// 任一 Try 失败 -> Cancel
inventoryService.cancel(orderId);    // 解冻
accountService.cancel(orderId);
// pointsService.cancel 如果还没 Try 也要能幂等执行

关键特性:

  • Try 阶段做最严格的检查 —— 后面 Confirm 必须能成功。
  • Confirm 和 Cancel 必须幂等 —— 网络重试不会出问题。
  • 不长时间锁资源 —— Try 预留是业务级别的"冻结状态",而非数据库锁。

TCC 的优点和代价

优点:性能远好于 2PC,没有长事务和数据库锁。
代价:业务侵入大。每个服务要专门实现 try / confirm / cancel 三套接口,数据库要加 frozen / status 字段。简单业务不划算,只在高频 + 严格事务场景上(支付、金融、跨服务库存)用。

Saga 模式:长事务的最终一致方案

Saga 把一个长事务拆成 N 个本地事务,每个都有对应的补偿事务。任一步失败,顺次执行前面已完成步骤的补偿。

步骤 1:扣库存  ->  补偿:补回库存
步骤 2:扣余额  ->  补偿:补回余额
步骤 3:加积分  ->  补偿:扣回积分
步骤 4:发短信  ->  补偿:发"订单取消"短信

两种实现风格:

编排式(Choreography)

每个服务发出"事件",其他服务订阅自己感兴趣的:

OrderService:发 OrderCreated 事件
  ↓
InventoryService:订阅 OrderCreated -> 扣库存 -> 发 InventoryDeducted
  ↓
AccountService:订阅 InventoryDeducted -> 扣余额 -> 发 PaymentDone
  ↓
任一失败,该服务发对应的"失败事件",前面服务订阅后执行补偿

优点:服务间解耦,事件总线异步。
缺点:流程隐式分散在各服务里,排查难。

编制式(Orchestration)

一个"Saga 协调者"显式编排所有步骤:

class OrderSaga {
    void execute(Order order) {
        try {
            step1 = inventoryService.deduct(order);
            step2 = accountService.deduct(order);
            step3 = pointsService.add(order);
        } catch (Exception e) {
            // 反向补偿
            if (step3 succeeded) pointsService.remove(order);
            if (step2 succeeded) accountService.refund(order);
            if (step1 succeeded) inventoryService.add(order);
            throw e;
        }
    }
}

优点:流程清晰集中,易调试。
缺点:协调者本身复杂,且要持久化进度以防它崩了。

主流框架:Seata(阿里)、AxonCamunda。Saga 是互联网常用的"长事务"方案。

消息事务:最终一致的工程主流

大多数互联网业务的分布式事务,最后归到一个模式:本地事务 + 消息。核心思想:

  • 本地操作 + 发消息 这两件事必须原子(要么都做,要么都不做)。
  • 消费消息的下游通过"消息驱动"做自己的本地操作。
  • 最终一致(异步收敛)。

本地消息表

-- 本地事务里同时改业务表 + 写消息表
BEGIN;
UPDATE orders SET status = 'PAID' WHERE id = 1001;
INSERT INTO outbox (event_type, payload) VALUES ('OrderPaid', '...');
COMMIT;

-- 后台轮询 outbox,发到消息队列
SELECT * FROM outbox WHERE sent = false LIMIT 100;
for each msg:
    mq.publish(msg);
    UPDATE outbox SET sent = true WHERE id = msg.id;

这种"Outbox 模式"保证"业务改 + 发消息"绝不脱节。Debezium / CDC 工具能直接从 binlog 读 outbox 表,无需轮询。

RocketMQ 事务消息

RocketMQ 原生支持事务消息:

1. Producer 发 "half message"(消息暂不投递给 Consumer)
2. 执行本地事务
3. 本地事务成功 -> commit half message(变成正式消息)
   本地事务失败 -> rollback half message
   半小时无应答 -> MQ 回调 Producer "你的本地事务到底成没成?"

# Java
producer.sendMessageInTransaction(msg, executor);
class MyExecutor implements TransactionListener {
    public LocalTransactionState executeLocalTransaction(...) {
        // 执行本地事务,返回 COMMIT / ROLLBACK / UNKNOWN
    }
    public LocalTransactionState checkLocalTransaction(...) {
        // 被 MQ 回查时调用
    }
}

幂等性:分布式事务的隐形必须

所有分布式事务方案都依赖"重试 + 幂等"。TCC 的 confirm / cancel 要幂等,Saga 的补偿要幂等,消息事务的消费要幂等。实现幂等的常见手段:

-- 1. 唯一约束 + 重复忽略
INSERT INTO points (order_id, ...) VALUES (...) ON DUPLICATE KEY DO NOTHING;

-- 2. 业务状态机:只在某个状态下允许变更
UPDATE orders SET status = 'PAID' WHERE id = ? AND status = 'CREATED';
-- 若返回 0 行,说明已经被处理过

-- 3. 幂等 token
INSERT INTO idempotent_log (token) VALUES (?);  -- 第一次成功
-- 第二次因主键冲突失败 -> 知道已经处理过

-- 4. 版本号(乐观锁)
UPDATE accounts SET balance = ?, version = version + 1
WHERE id = ? AND version = ?

方案对比

方案       一致性强度    性能   业务侵入   适用场景
2PC/XA     强一致        差     中         金融核心、传统企业
TCC        强一致        中     大         高频金融业务
Saga       最终一致      好     中         长流程业务、跨服务
消息事务   最终一致      好     小         互联网常规场景

选型决策树

  1. 能用单库事务吗? 能 → 用单库,不要分布式。
  2. 能接受最终一致(几秒到几分钟)吗? 能 → 消息事务 / Saga(优先消息事务,实现简单)。
  3. 必须强一致?是金融核心吗? 是 → TCC。
  4. 实在不行? 上 2PC / XA,但接受性能差。

实战中的几个坑

坑 1:补偿逻辑没人维护。 Try / Cancel 写好后从来不验证。某天分布式事务真的失败,补偿代码因为业务变化早已不能用,数据脏掉。规范:补偿路径要专门测试,定期模拟失败场景。

坑 2:消息丢失。 本地事务成功但消息没发出去(MQ 故障)。Outbox 表 + 后台轮询是基本保证。

坑 3:消息重复消费。 重试 / MQ 重发导致下游收到两次。消费方必须幂等。

坑 4:补偿失败怎么办? 主流程失败开始补偿,补偿又失败 → 数据卡在中间状态。规范:补偿失败要告警,人工兜底;或者无限重试 + 监控阻塞队列。

坑 5:超时处理。 长事务里某步 Try 之后服务挂了,Confirm/Cancel 都没发出 —— 永远卡在中间。规范:每个事务有超时,超时后扫描器自动补偿。

Seata:阿里巴巴的分布式事务框架

Seata 是 Java 生态最流行的分布式事务框架,支持 AT、TCC、Saga、XA 四种模式。其中 AT(Automatic Transaction)模式最特别 —— 它对业务几乎无侵入:

@GlobalTransactional
public void placeOrder(Order order) {
    inventoryService.deduct(order);    // 普通 @Service 调用
    accountService.deduct(order);
    pointsService.add(order);
}

Seata AT 在背后做的事:

  1. 每个 SQL 执行前,自动记录"更新前的数据"到 undo_log 表。
  2. 本地 SQL 提交后,向 Seata 服务器报告"已提交"。
  3. 全局事务回滚时,Seata 通知各分支用 undo_log 反向恢复。

原理是"每个分支的本地事务先提交,出错时用 undo_log 回滚",绕过了 2PC 长锁的问题。代价:需要在数据库加 undo_log 表,且不能跨数据库类型(目前主要支持 MySQL)。

本地事务表 vs 分布式事务框架:选哪个

消息事务(Outbox 模式)和 Seata / Atomikos 等框架是不同抽象层级的方案:

  • 消息事务:业务感强,需要自己设计"事件 + 消费幂等",但灵活、轻量、最适合互联网业务。
  • 分布式事务框架:对业务侵入小(像写本地事务),但底层复杂、依赖中央协调器、性能受协调器影响。

大多数项目从消息事务起步,只有特殊场景(老系统不能改、强一致要求)才上 Seata 这类框架。

事务消息的工程坑

坑 1:消息发出但本地事务回滚。Outbox 模式天然避免这个(本地事务 + 消息表是同一个本地事务)。RocketMQ 半消息也避免。但"先 commit 数据库再发 MQ"的朴素写法会踩。

坑 2:消息发出但消费者一直失败。需要死信队列 + 人工兜底。死信队列里的消息要监控,否则积压成灾。

坑 3:消息顺序保证。"扣库存 → 发短信"乱序后,可能"扣库存失败也发短信"。Kafka 通过 key 路由到同一 partition 保证顺序;消息总线无序时,消费方要处理乱序(根据业务状态判断当前事件是否合法)。

Outbox 模式的 CDC 加速

传统 Outbox 用后台任务轮询表,有延迟。用 Debezium 这类 CDC 工具直接监听 MySQL binlog,变化几毫秒就传到 Kafka:

# Debezium 连接器配置
{
  "connector.class": "io.debezium.connector.mysql.MySqlConnector",
  "database.hostname": "mysql.internal",
  "table.include.list": "myapp.outbox",
  "transforms": "outbox",
  "transforms.outbox.type": "io.debezium.transforms.outbox.EventRouter"
}

这种"Database as Source of Events"是事件驱动架构的现代标配,Outbox + CDC 让分布式事务在毫秒级完成。

写在最后

分布式事务的本质是"用工程换一致性"。所有方案都不像单库事务那么干净,都是在"正确性、性能、复杂度"间的权衡。最好的分布式事务是"不做分布式事务" —— 把业务边界划好,让一个事务能在单库里完成;实在不行,再选合适的协议。

给一个工程心得:分布式事务通常不是"用什么协议"的问题,而是"业务边界划得对不对"的问题。把"用户下单一定要扣库存 + 扣账户 + 加积分"这种强耦合的需求拆解,改成"下单完成 → 异步通知库存 / 账户 / 积分各自处理",一致性要求从"同时成功"变成"最终一致",难度立刻降一个量级。架构能力 = 看清"什么时候真的需要强一致"的能力。

—— 别看了 · 2026
声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理 邮箱1846861578@qq.com。
技术教程

CAP 与 BASE 完全指南:分布式系统的一致性权衡

2026-5-15 16:09:28

技术教程

Raft 与 Paxos 共识算法完全指南:从原理到 etcd 实战

2026-5-15 16:09:29

0 条回复 A文章作者 M管理员
    暂无讨论,说说你的看法吧
个人中心
购物车
优惠劵
今日签到
有新私信 私信列表
搜索