单机事务靠锁和 WAL 就能保证 ACID——但当一个业务操作跨越多个数据库、多个微服务时,谁来保证”要么全成功,要么全回滚”?这就是分布式事务要解决的核心问题。在 事务与并发控制 中,讨论了单机事务的 ACID 保证与并发控制机制;当数据分布到多个节点上,这些机制就不再直接适用了。
分布式事务的本质困难在于:没有全局锁、没有共享内存、没有原子时钟——每个节点都可能独立失败,网络可能分区,消息可能延迟或重复。本章从 2PC 出发,逐步分析 3PC、Saga、TCC 和最终一致性方案,帮助你在强一致性与可用性之间做出合理的工程取舍。
前置知识
- Ch04 事务与并发控制:单机事务的 ACID 保证是理解分布式事务的基础
- Ch12 数据复制:复制引入了跨节点一致性问题
- Ch13 数据分区:分区让事务跨越多个节点
2PC 由 Jim Gray 在 1978 年提出,成为分布式事务标准方案,但阻塞问题困扰了工程师数十年。1987 年 Hector Garcia-Molina 提出的 Saga 模式在 2010 年代微服务浪潮中重新被发掘,成为主流方案。
一、分布式事务挑战
1.1 从单机到分布式
在单机数据库中,事务的原子性由 Undo Log 和 WAL 共同保证,隔离性由锁或 MVCC 实现——所有操作共享同一块内存和磁盘,协调者就是数据库引擎本身。但当业务操作跨越多个独立数据库时,情况发生了根本变化:
| 维度 | 单机事务 | 分布式事务 |
|---|---|---|
| 协调机制 | 进程内调用,共享内存 | 网络通信,消息传递 |
| 失败模型 | 进程崩溃,磁盘故障 | 节点崩溃、网络分区、消息丢失 |
| 原子性保证 | Undo Log + WAL | 需要分布式提交协议 |
| 隔离性保证 | 锁 / MVCC | 全局锁代价极高,通常降级为最终一致性 |
| 性能开销 | 微秒级锁获取 | 毫秒到秒级网络往返 |
1.2 CAP 定理的约束
在 数据复制 中讨论了 CAP 定理:在网络分区(P)发生时,系统只能在一致性(C)和可用性(A)之间二选一。分布式事务方案同样受此约束:
没有任何分布式事务方案能同时做到强一致性、高可用性和高性能。2PC 追求强一致性但牺牲可用性,Saga 追求可用性但牺牲强一致性——选择方案的本质是在这三者之间做取舍。
二、2PC — 两阶段提交
2.1 基本流程
两阶段提交(Two-Phase Commit, 2PC)是最经典的分布式提交协议。它引入一个协调者(Coordinator)角色,所有参与事务的节点称为参与者(Participant)。协议分为两个阶段:
阶段一:Prepare(准备阶段)
协调者向所有参与者发送 PREPARE 请求。每个参与者检查自身是否能够提交:
- 如果可以提交,将操作写入本地日志并锁定资源,回复
YES - 如果无法提交,回复
NO
阶段二:Commit/Abort(提交阶段)
- 如果所有参与者都回复
YES,协调者发送COMMIT - 如果任何一个参与者回复
NO,或等待超时,协调者发送ABORT
2.2 阻塞问题
2PC 最大的问题是阻塞:协调者在发送 COMMIT 前崩溃时,已回复 YES 的参与者将永远等待,资源无法释放。
# 2PC 参与者的阻塞状态(简化示意)class Participant: def handle_prepare(self, txn_id): """阶段一:准备""" if self.can_commit(txn_id): self.lock_resources(txn_id) # 锁定资源! self.write_prepare_log(txn_id) # 写入 Prepare 日志 return "YES" return "NO"
def handle_commit(self, txn_id): """阶段二:提交""" self.commit(txn_id) self.release_locks(txn_id) # 只有提交/回滚后才能释放锁 self.write_commit_log(txn_id)
# 问题:如果协调者崩溃,参与者将永远停在"已锁定"状态 # 直到协调者恢复并重新发送决定2PC 的阻塞问题是结构性的——参与者一旦回复 YES 就必须等待协调者的最终决定,在此期间它不能单方面提交或回滚,否则会违反原子性。这意味着协调者成为了单点故障:它崩溃时,所有已 Prepare 的事务都被阻塞。
2.3 XA 规范
XA(eXtended Architecture)是 X/Open 组织定义的 2PC 工业标准,被主流数据库和中间件广泛支持:
// Java 中使用 XA 事务XADataSource xaDs1 = createXADataSource("jdbc:mysql://db1:3306/order");XADataSource xaDs2 = createXADataSource("jdbc:mysql://db2:3306/inventory");XAResource xaRes1 = xaDs1.getXAConnection().getXAResource();XAResource xaRes2 = xaDs2.getXAConnection().getXAResource();
TransactionManager tm = getTransactionManager();tm.begin();tm.getTransaction().enlistResource(xaRes1);tm.getTransaction().enlistResource(xaRes2);// 在两个数据源上执行操作... 事务管理器自动执行 2PCtm.commit(); // 内部执行 Prepare → CommitXA 规范定义了三个核心组件:
| 组件 | 角色 | 职责 |
|---|---|---|
| AP(Application Program) | 应用程序 | 定义事务边界,发起全局事务 |
| TM(Transaction Manager) | 事务管理器 | 协调者,管理 2PC 流程 |
| RM(Resource Manager) | 资源管理器 | 参与者,管理本地资源(数据库等) |
XA 事务的状态转换如下:
2.4 2PC 的优缺点
| 优点 | 缺点 |
|---|---|
| 实现原理简单,容易理解 | 同步阻塞,资源锁定时间长 |
| 强一致性保证 | 协调者单点故障 |
| 工业标准(XA)支持广泛 | 性能随参与者数量线性下降 |
| 多数数据库原生支持 | 网络分区时可能导致数据不一致 |
三、3PC — 三阶段提交
3.1 基本流程
三阶段提交(Three-Phase Commit, 3PC)是 2PC 的改进版本,通过增加一个**预提交(PreCommit)**阶段来减少阻塞:
三个阶段的职责:
| 阶段 | 名称 | 职责 | 是否锁定资源 |
|---|---|---|---|
| 阶段一 | CanCommit | 询问参与者是否”可能”提交 | 不锁定 |
| 阶段二 | PreCommit | 通知参与者准备提交,锁定资源 | 锁定 |
| 阶段三 | DoCommit | 执行实际提交 | 释放锁 |
3.2 超时机制
3PC 的关键改进是引入了超时机制:参与者在 PreCommit 阶段之后,如果等待协调者的 DoCommit 超时,可以自行决定提交。这是因为:
- 如果参与者到达了 PreCommit 阶段,说明所有参与者在 CanCommit 阶段都回复了 Yes
- 此时提交是”大概率正确”的选择
# 3PC 参与者的超时处理class ThreePhaseParticipant: def handle_can_commit(self, txn_id): if self.can_commit(txn_id): return "YES" # 不锁定资源 return "NO"
def handle_pre_commit(self, txn_id): self.lock_resources(txn_id) self.write_precommit_log(txn_id) return "ACK"
def handle_do_commit(self, txn_id): self.commit(txn_id) self.release_locks(txn_id)
def on_timeout(self, txn_id): if self.state == "PRE_COMMITTED": self.handle_do_commit(txn_id) # 超时自行提交 elif self.state == "INIT": self.abort(txn_id) # 未到 PreCommit,自行回滚3.3 3PC 的局限性
3PC 并不能完美解决 2PC 的问题。在网络分区(而非单纯超时)的场景下,3PC 仍可能导致数据不一致:部分参与者因超时自行提交,另一部分因未收到 PreCommit 而回滚。3PC 假设了”网络分区不会同时与节点故障发生”——但这在现实中并不成立。因此,3PC 在实际系统中很少被采用。
3PC 与 2PC 的对比:
| 维度 | 2PC | 3PC |
|---|---|---|
| 阶段数 | 2 | 3 |
| 阻塞风险 | 高(协调者故障→全部阻塞) | 低(超时可自行决定) |
| 网络分区安全性 | 阻塞但不一致 | 可能不一致 |
| 实际采用 | 广泛(XA 规范) | 极少 |
| 网络往返次数 | 2 轮 | 3 轮 |
四、Saga 模式
4.1 核心思想
Saga 模式由 Hector Garcia-Molina 和 Kenneth Salem 在 1987 年提出,其核心思想是:将长事务拆分为多个本地短事务,每个本地事务都有对应的补偿操作。如果某个步骤失败,则逆序执行已完成步骤的补偿操作。
Saga 的关键区别在于:它不保证事务的隔离性——中间状态对外可见。这是它与 2PC 的根本取舍:用隔离性换可用性。
4.2 编排式 vs 协同式
Saga 有两种实现方式:
编排式(Orchestration):由一个中央协调器(Orchestrator)控制整个流程。
// 编排式 Saga 示例public class OrderSagaOrchestrator { public SagaDefinition<OrderState> saga() { return step() .invokeParticipant(this::createOrder) .withCompensation(this::cancelOrder) .step() .invokeParticipant(this::deductInventory) .withCompensation(this::restoreInventory) .step() .invokeParticipant(this::deductBalance) .withCompensation(this::restoreBalance) .build(); }}协同式(Choreography):没有中央协调器,各服务通过事件驱动协作。
// 协同式 Saga — 订单服务@Transactionalpublic Order createOrder(CreateOrderRequest req) { Order order = new Order(req); order.setStatus("PENDING"); orderRepository.save(order); eventBus.publish(new OrderCreatedEvent(order)); // 触发下游 return order;}
@Transactional@EventHandlerpublic void handlePaymentFailed(PaymentFailedEvent event) { Order order = orderRepository.findById(event.getOrderId()); order.setStatus("CANCELLED"); // 补偿:取消订单 orderRepository.save(order); eventBus.publish(new OrderCancelledEvent(order));}
// 协同式 Saga — 库存服务@Transactional@EventHandlerpublic void handleOrderCreated(OrderCreatedEvent event) { inventoryRepository.deduct(event.getProductId(), event.getQuantity()); eventBus.publish(new InventoryDeductedEvent(event));}
@Transactional@EventHandlerpublic void handleOrderCancelled(OrderCancelledEvent event) { inventoryRepository.restore(event.getProductId(), event.getQuantity());}两种方式的对比:
| 维度 | 编排式 | 协同式 |
|---|---|---|
| 协调方式 | 中央编排器控制流程 | 事件驱动,服务间松耦合 |
| 可见性 | 流程集中可见,易于监控 | 流程分散,难以追踪全貌 |
| 耦合度 | 编排器依赖所有服务 | 服务间无直接依赖 |
| 扩展性 | 新增步骤需修改编排器 | 新增步骤只需订阅事件 |
| 适用场景 | 复杂流程、需要集中监控 | 简单流程、服务自治 |
| 典型框架 | Axon Framework、Seata | 自定义事件总线 |
4.3 补偿操作的设计原则
补偿操作不是”回滚”——它是在业务层面撤销之前操作的效果。设计补偿操作时需要遵循以下原则:
原则 1:补偿操作必须是幂等的(网络重试可能导致多次执行)原则 2:补偿操作本身不能失败(否则需人工介入)原则 3:补偿是"语义撤销"而非"物理回滚"(反向转账 vs 删除记录)原则 4:必须逆序补偿(先补偿最后完成的步骤)五、TCC 模式
5.1 Try-Confirm-Cancel
TCC(Try-Confirm-Cancel)是业务层面的分布式事务方案,将每个操作分为三个阶段:
5.2 TCC 实现示例
// TCC 模式实现示例(以库存服务为例)public class InventoryTccService { // Try 阶段:冻结库存 @Transactional public boolean tryDeduct(String xid, String productId, int quantity) { Inventory inv = inventoryRepository.findByProductId(productId); if (inv.getTotal() - inv.getFrozen() < quantity) return false; inv.setFrozen(inv.getFrozen() + quantity); // 冻结,而非扣减 tccBranchRepository.save(new TccBranch(xid, productId, quantity, "TRY")); return true; } // Confirm 阶段:确认扣减 @Transactional public boolean confirm(String xid) { TccBranch branch = tccBranchRepository.findByXid(xid); Inventory inv = inventoryRepository.findByProductId(branch.getProductId()); inv.setTotal(inv.getTotal() - branch.getQuantity()); inv.setFrozen(inv.getFrozen() - branch.getQuantity()); branch.setStatus("CONFIRMED"); return true; } // Cancel 阶段:释放冻结 @Transactional public boolean cancel(String xid) { TccBranch branch = tccBranchRepository.findByXid(xid); if (branch.getStatus().equals("CANCELLED")) return true; // 幂等 Inventory inv = inventoryRepository.findByProductId(branch.getProductId()); inv.setFrozen(inv.getFrozen() - branch.getQuantity()); branch.setStatus("CANCELLED"); return true; }}5.3 TCC 与 Saga 的对比
| 维度 | TCC | Saga |
|---|---|---|
| 资源隔离 | Try 阶段冻结资源,隔离性好 | 无资源冻结,隔离性差 |
| 一致性 | 准实时一致性 | 最终一致性 |
| 业务侵入 | 高(需实现 Try/Confirm/Cancel) | 中(需实现补偿操作) |
| 回滚方式 | Cancel 释放预留资源 | 补偿操作语义撤销 |
| 性能 | Try 阶段锁定资源,有性能开销 | 无锁定,性能较好 |
| 适用场景 | 资金类、库存类(隔离性要求高) | 流程类、长事务(可用性要求高) |
TCC 的 Try 阶段”冻结资源”本质上是一种业务层面的锁——它不是数据库行锁,而是在业务数据中增加一个”冻结”字段。这使得 TCC 比 2PC 的数据库级锁更灵活,但也意味着每个服务都需要额外实现冻结/确认/取消的逻辑。
六、最终一致性
6.1 核心思想
最终一致性(Eventual Consistency)放弃了强一致性的要求,允许系统在短时间内处于不一致状态,但保证如果没有新的更新操作,系统最终会达到一致状态。这与 一致性与共识 中讨论的线性化形成鲜明对比——线性化要求所有操作看起来像发生在单一时间点上,而最终一致性只要求”最终”一致。
6.2 本地消息表
本地消息表是最常用的最终一致性方案,其核心思想是:将消息与业务操作放在同一个本地事务中,保证业务操作和消息发送的原子性。
-- 本地消息表(Outbox Pattern)实现
-- 1. 创建消息表CREATE TABLE outbox ( id BIGINT PRIMARY KEY AUTO_INCREMENT, topic VARCHAR(100) NOT NULL, -- 消息主题 payload JSON NOT NULL, -- 消息内容 status ENUM('NEW', 'SENT', 'FAILED') DEFAULT 'NEW', created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, retry_count INT DEFAULT 0, INDEX idx_status_created (status, created_at));
-- 2. 业务操作 + 写入消息表(原子性保证)BEGIN;INSERT INTO orders (user_id, product_id, amount, status)VALUES (1001, 2001, 99.00, 'CREATED');
INSERT INTO outbox (topic, payload)VALUES ('order-created', '{"orderId": 10001, "userId": 1001, "amount": 99.00}');COMMIT;
-- 3. 消息发送器(定时轮询)-- 由后台线程定期执行UPDATE outbox SET status = 'SENT'WHERE id = ? AND status = 'NEW';
-- 4. 失败重试UPDATE outboxSET retry_count = retry_count + 1, status = CASE WHEN retry_count >= 3 THEN 'FAILED' ELSE 'NEW' ENDWHERE id = ? AND status = 'FAILED';6.3 可靠消息最终一致性
基于消息队列的可靠消息方案,通过半消息和事务回查保证消息不丢失:
// RocketMQ 事务消息示例public class OrderService { @Autowired private RocketMQTemplate rocketMQTemplate;
public void createOrder(CreateOrderRequest req) { // 发送半消息:对消费者不可见,等待本地事务确认 rocketMQTemplate.sendMessageInTransaction( "order-topic", MessageBuilder.withPayload(req).build(), req); }
@Transactional public void executeLocalTransaction(Message msg) { orderRepository.save(new Order(parseMessage(msg))); // 本地事务提交成功 → 消息对消费者可见 }
public RocketMQLocalTransactionState checkLocalTransaction(Message msg) { // 事务回查:不确定本地事务状态时调用 Order order = orderRepository.findById(parseOrderId(msg)); return order != null ? COMMIT : ROLLBACK; }}6.4 消费者的幂等性
最终一致性方案中,消息可能被重复投递,消费者必须保证幂等性:
// 幂等消费者:唯一消息 ID 去重@Transactionalpublic void handleOrderCreated(OrderCreatedEvent event) { String msgId = event.getMessageId(); if (idempotentRepo.existsByMessageId(msgId)) return; // 重复消息跳过 inventoryService.deduct(event.getProductId(), event.getQuantity()); idempotentRepo.save(new IdempotentRecord(msgId, "PROCESSED"));}幂等性实现的常见方式:
| 方式 | 原理 | 优点 | 缺点 |
|---|---|---|---|
| 唯一消息 ID | 数据库唯一约束去重 | 简单可靠 | 需要额外表 |
| 状态机 | 检查业务状态是否已变更 | 无需额外存储 | 状态设计需谨慎 |
| 乐观锁 | 版本号控制并发更新 | 天然幂等 | 需业务字段支持 |
| Redis 去重 | SET NX 原子操作 | 高性能 | 需处理 Redis 故障 |
七、方案对比
7.1 核心维度对比
| 维度 | 2PC | 3PC | Saga | TCC | 最终一致性 |
|---|---|---|---|---|---|
| 一致性 | 强一致 | 强一致 | 最终一致 | 准实时一致 | 最终一致 |
| 可用性 | 低(阻塞) | 中(超时提交) | 高 | 中 | 高 |
| 性能 | 低 | 低 | 高 | 中 | 高 |
| 业务侵入 | 低(数据库层面) | 低 | 中(补偿操作) | 高(Try/Confirm/Cancel) | 中(幂等处理) |
| 隔离性 | 完全隔离 | 完全隔离 | 无隔离 | 资源冻结隔离 | 无隔离 |
| 实现复杂度 | 低 | 中 | 中 | 高 | 中 |
| 典型框架 | XA、Seata AT | 几乎不用 | Seata Saga、Axon | Seata TCC | RocketMQ、本地消息表 |
7.2 场景选型指南
7.3 性能对比
以一个涉及 3 个服务的分布式事务为例,各方案的性能特征:
| 指标 | 2PC | Saga | TCC | 最终一致性 |
|---|---|---|---|---|
| 同步等待时间 | 2 × RTT × N | 1 × RTT × N | 2 × RTT × N | 1 × RTT |
| 资源锁定时间 | 整个事务期间 | 无锁定 | Try→Confirm 期间 | 无锁定 |
| 吞吐量 | 低 | 高 | 中 | 最高 |
| 延迟 | 高 | 中 | 中 | 低 |
性能对比中的 RTT(Round-Trip Time)是网络往返延迟。在跨机房部署时,RTT 可能从数据中心内的 0.5ms 增加到跨地域的 30-100ms,这使得 2PC 的多轮同步通信代价极高。这也是为什么跨地域场景几乎不使用 2PC 的原因。
八、总结
8.1 核心观点回顾
分布式事务的本质是在一致性、可用性、性能之间做取舍。没有万能方案,只有适合场景的选择:
- 2PC/XA 适合强一致性要求高、参与者少、网络稳定的场景(如同一数据中心的跨库事务)
- 3PC 理论上减少了阻塞,但网络分区下的不一致风险使其在实践中几乎不被采用
- Saga 适合长流程、多步骤的业务场景(如订单流程、旅行预订),用补偿操作替代回滚
- TCC 适合需要资源隔离的场景(如资金、库存),但业务侵入性高
- 最终一致性 适合高吞吐、可容忍短暂不一致的场景(如通知、日志),是微服务架构的主流选择
8.2 与前后章节的联系
分布式事务不是孤立的话题,它与系列中多个章节紧密关联:
8.3 工程实践建议
1. 优先避免分布式事务 — 合理划分服务,将强关联数据放同一服务2. 选择合适的方案 — 不要为"强一致性"强行用 2PC,多数场景可接受最终一致性3. 做好补偿和重试 — 所有操作必须幂等,补偿操作必须有重试机制4. 可观测性 — 记录事务步骤状态,实现事务看板排查悬挂事务5. 测试故障场景 — 模拟网络分区、节点崩溃,验证补偿与幂等正确性分布式事务没有银弹。理解每种方案的原理、取舍和适用场景,才能在面对真实问题时做出正确的选择。
支持与分享
如果这篇文章对你有帮助,欢迎支持作者或分享给更多人
部分信息可能已经过时






