土法炼钢兴趣小组的算法知识备份

【分布式系统实战】分布式事务不是你以为的那个 2PC

文章导航

分类入口
distributed
标签入口
#distributed-transactions#2pc#percolator#saga#tcc#spanner#tidb#cockroachdb#distributed-systems

目录

“分布式事务用 2PC 就行了。”

说这话的人大概没在生产环境跑过 2PC。更准确地说,没在生产环境里经历过 Coordinator 挂在 Prepare 之后、Commit 之前——所有 Participant 抱着锁,既不能提交也不能回滚,整个系统僵死三小时那种体验。

教科书把 2PC 画成两个漂亮的箭头:Prepare → Commit,干净利落。真实世界里,那两个箭头之间藏着分布式系统最凶险的故障窗口。

这篇文章不讲教科书。我们聊 2PC 的真实失败模式,聊 Google 怎么用 Percolator 绕开经典 2PC 的致命缺陷,聊 Saga 和 TCC 在业务层面的工程妥协,最后看看 Spanner、CockroachDB、TiDB 这些现代数据库各自的答案。

一、2PC 的教科书版本 vs 真实世界

教科书版本:两个阶段,三行伪代码

Two-Phase Commit 的基本思路极其简单:

# Phase 1: Prepare
Coordinator → 所有 Participant: "准备好了吗?"
Participant → Coordinator: "YES" 或 "NO"

# Phase 2: Commit / Abort
if 所有 Participant 回复 YES:
  Coordinator → 所有 Participant: "提交!"
else:
  Coordinator → 所有 Participant: "回滚!"

流程清晰到可以画在餐巾纸上。Prepare 阶段收集投票,Commit 阶段执行决定。每个 Participant 在回复 YES 之前,必须把 redo/undo 日志持久化——这意味着它承诺:无论后面发生什么,我都能提交或回滚。

看起来完美。问题是:教科书总是假设网络可靠、节点最终会恢复、磁盘不会丢数据。

教科书没说的失败模式

真实世界的 2PC 有三种经典的灾难场景:

2PC 失败模式

失败模式一:Coordinator 挂在 Prepare 之后、Commit 之前

这是 2PC 最致命的问题,也是它被称为阻塞协议的原因。

时间线:
t1: Coordinator 发送 Prepare 给 Participant A, B, C
t2: A, B, C 都回复 YES(锁已持有,日志已写)
t3: Coordinator 决定 Commit,写本地日志
t4: Coordinator 宕机 ← 灾难发生在这里
  
  此时:
  - A, B, C 都持有锁,等待 Commit 或 Abort 指令
  - 没有人知道 Coordinator 的决定是什么
  - 没有人敢单方面提交(万一 Coordinator 决定的是 Abort?)
  - 没有人敢单方面回滚(万一 Coordinator 决定的是 Commit?)
  - 所有相关行被锁住,其他事务全部阻塞

结果:全员等死。 直到 Coordinator 恢复,或者人工介入。在此期间,所有被这个事务锁住的数据都不可用。

这不是理论上的风险。任何运行过跨数据库分布式事务的团队,迟早会碰到这个场景。凌晨三点的报警,十有八九是这个。

失败模式二:Participant 网络分区

时间线:
t1: Coordinator 发送 Prepare
t2: Participant A 回复 YES,然后网络分区——与 Coordinator 断开
t3: Coordinator 收到 B, C 的 YES(A 的回复已经收到了)
t4: Coordinator 发送 Commit 给 A, B, C
t5: B, C 成功提交
t6: A 收不到 Commit 指令——持有锁,无限等待

  此时:
  - B, C 已经提交
  - A 持有锁但无法释放
  - A 上被锁住的行,所有新事务全部超时

失败模式三:日志丢失

Participant 在回复 YES 之后宕机重启,如果 redo 日志没有正确持久化(磁盘故障、fsync 没调、ext4 的 delayed allocation),它连自己参与过这个事务都不知道。Coordinator 发来 Commit,它一脸茫然。

数据不一致就这么产生了。

为什么 3PC 也救不了

Three-Phase Commit 在 Prepare 和 Commit 之间插入了一个 Pre-Commit 阶段,试图解决阻塞问题。思路是:在真正提交之前,先让所有节点知道”大家都投了 YES”,这样即使 Coordinator 挂了,任何一个 Participant 都可以接管决策。

但 3PC 有一个致命假设:网络分区不会发生。

一旦出现分区,一边的节点可能在 Pre-Commit 阶段,另一边的节点可能还在 Prepare 阶段。两边各自做出不同的决定——一边提交,一边回滚。一致性直接崩掉。

这不是 3PC 的 bug,这是 FLP 不可能定理的影子:在异步网络中,不存在一个确定性协议能同时保证一致性和终止性。3PC 选择了终止性(不阻塞),但牺牲了分区容忍下的一致性。

关于 FLP 定理在工程中的体现,Raft 的做法是用随机化超时绕过理论限制——详见 Raft 实现拆解:etcd 的共识算法到底长什么样。

为什么 3PC 仍扛不住网络分区

上面说了 3PC 的致命假设,下面用一个具体的状态机推演来证明它为什么必然失败。

考虑 3 个节点:Coordinator (C)、Participant A、Participant B。3PC 的状态转移为:

Coordinator 状态机:
  INIT → WAIT (发送 CanCommit)
  WAIT → PRE_COMMIT (收到全部 YES → 发送 PreCommit)
  WAIT → ABORT (收到任何 NO → 发送 Abort)
  PRE_COMMIT → COMMITTED (收到全部 ACK → 发送 DoCommit)

Participant 状态机:
  INIT → READY (收到 CanCommit → 回复 YES)
  READY → PRE_COMMITTED (收到 PreCommit → 回复 ACK)
  READY → ABORTED (超时未收到 PreCommit → 自行 Abort)
  PRE_COMMITTED → COMMITTED (收到 DoCommit)
  PRE_COMMITTED → COMMITTED (超时未收到 DoCommit → 自行 Commit ← 关键!)

现在,灾难场景:

时间线:
t1: C 发送 CanCommit 给 A, B
t2: A, B 都回复 YES
t3: C 进入 PRE_COMMIT 状态,发送 PreCommit
t4: A 收到 PreCommit → 进入 PRE_COMMITTED 状态
t5: ████ 网络分区 ████
  分区 P1: {A}(已进入 PRE_COMMITTED)
  分区 P2: {C, B}(B 还在 READY 状态,没收到 PreCommit)

t6 - 分区 P1 的视角(节点 A):
  A 在 PRE_COMMITTED 状态,超时未收到 DoCommit
  3PC 协议规定:PRE_COMMITTED 超时 → 自行提交(因为知道所有人都投了 YES)
  A → COMMITTED 是

t6 - 分区 P2 的视角(C + B):
  C 发现 A 失联,无法推进到 DoCommit
  B 在 READY 状态超时,没收到 PreCommit
  3PC 协议规定:READY 超时 → 自行 Abort
  选举新 Coordinator(B 自己),发现 B 还在 READY → 决定 ABORT
  B → ABORTED 否

结果:A 已提交,B 已回滚。一致性违反。

3PC 的根本矛盾在于:PRE_COMMITTED 状态的超时策略和 READY 状态的超时策略做出了相反的假设。PRE_COMMITTED 假设”所有人都已经知道要提交了”,所以超时后自行提交;READY 假设”可能有人投了 NO”,所以超时后自行回滚。网络分区恰好把这两种假设的节点分到了两边。

这就是 FLP 不可能定理的工程后果:在异步网络里,你无法区分”对方挂了”和”网络断了”。3PC 试图用超时来做判断,但超时在分区面前是无效的——两边都超时了,两边各自做出了”看起来合理但全局不一致”的决定。

所以现代系统不用 3PC。Raft/Paxos 用多数派投票来容忍分区,代价是少数派不可用;Percolator 把事务状态存在数据里,不依赖 Coordinator 存活。两种路线都比 3PC 实际。

二、Percolator:Google 的分布式事务方案

从增量处理系统说起

Percolator 出自 Google 2010 年的论文《Large-scale Incremental Processing Using Distributed Transactions and Notifications》。它的原始目的不是做通用数据库,而是给 Google 的网页索引系统做增量更新——每天几十 PB 的数据,全量重建太贵,需要增量处理,而增量处理需要事务保证。

Google 的选择是:在 Bigtable 之上构建分布式事务,而不是从头写一个事务引擎。

核心思想

Percolator 的精妙之处在于:它用存储层(Bigtable)本身来承载事务状态,而不是依赖一个独立的 Coordinator。

每一行数据在 Bigtable 中有三个关键列族(Column Family):

列族 作用 存储内容
data 实际数据 (start_ts, value) — 多版本数据
lock 锁信息 (start_ts, primary_key) — 指向 primary 行
write 提交记录 (commit_ts, start_ts) — 标记数据已提交

关键设计:没有独立的 Coordinator 节点。事务状态就是数据的一部分,存在 Bigtable 里。

写流程

假设事务要修改两行数据:row_A 和 row_B。

# 获取全局时间戳作为事务开始时间
start_ts = oracle.get_timestamp()

# ========== Phase 1: Prewrite ==========

# 选择 row_A 作为 primary key(任意选一个)
primary = "row_A"

# 对 primary 行加锁并写数据
bigtable.write("row_A", {
  "data":  (start_ts, new_value_A),  # 写入新值,版本号 = start_ts
  "lock":  (start_ts, "row_A"),  # lock 指向自己(我是 primary)
})

# 对 secondary 行加锁并写数据
bigtable.write("row_B", {
  "data":  (start_ts, new_value_B),
  "lock":  (start_ts, "row_A"),  # lock 指向 primary(不是自己!)
})

# 加锁时检查冲突:
# 1. 如果 [start_ts, ∞) 范围内有 write 记录 → 写写冲突,abort
# 2. 如果已有其他事务的 lock → 锁冲突,abort 或等待

# ========== Phase 2: Commit ==========

# 获取提交时间戳
commit_ts = oracle.get_timestamp()

# 先提交 primary:写 write 记录 + 删除 lock
bigtable.write("row_A", {
  "write": (commit_ts, start_ts),  # 标记:commit_ts 版本的数据在 start_ts
  "lock":  delete(start_ts),  # 解锁
})

# 再提交 secondary:写 write 记录 + 删除 lock
bigtable.write("row_B", {
  "write": (commit_ts, start_ts),
  "lock":  delete(start_ts),
})

Primary Key 的角色:解决 Coordinator 故障

这里是 Percolator 最精彩的设计。传统 2PC 的 Coordinator 是一个独立节点,它挂了,事务状态就丢了。Percolator 把”事务是否提交”这个状态编码到 primary key 的数据里:

# 其他事务遇到 lock 时的处理逻辑
def resolve_lock(row, lock):
  primary_row = lock.primary_key
  primary_lock = bigtable.read(primary_row, "lock", lock.start_ts)
  primary_write = bigtable.read(primary_row, "write", [lock.start_ts, MAX])

  if primary_write exists:
  # primary 已提交 → 这个 secondary 也该提交
  bigtable.write(row, {
  "write": (primary_write.commit_ts, lock.start_ts),
  "lock":  delete(lock.start_ts),
  })
  elif primary_lock is None or primary_lock.is_expired():
  # primary 已回滚或超时 → 清理这个 lock
  bigtable.delete(row, "lock", lock.start_ts)
  bigtable.delete(row, "data", lock.start_ts)
  else:
  # primary 还在,等待或重试
  wait_or_retry()

与传统 2PC 的本质区别:Coordinator 的状态不在某个节点的内存或日志里,而是分散在数据本身中。任何人都能读到 primary 行来判断事务状态。Coordinator 挂了?没关系,数据还在,状态还在。

TiDB 如何使用 Percolator

TiDB 的事务模型直接基于 Percolator,底层存储换成了 TiKV(基于 RocksDB + Raft 复制)。核心改进包括:

三、Saga 模式:长事务的补偿方案

不是所有事务都能用锁

2PC 和 Percolator 都依赖锁机制——在事务完成之前,相关数据被锁住,其他事务看不到中间状态。这在数据库内部可以接受,但在跨服务的业务流程中不行。

想象一个电商下单流程:扣库存(库存服务)→ 扣余额(支付服务)→ 创建物流单(物流服务)。你不可能让库存服务持有一把锁等支付服务和物流服务都完成——那可能要几秒甚至几分钟。

Saga 的思路完全不同:不要锁,用补偿。

核心模型

一个 Saga 事务由一系列子事务组成,每个子事务 T_i 都有一个对应的补偿操作 C_i:

T1 → T2 → T3 → ... → Tn  正向执行
C1 ← C2 ← C3 ← ... ← Cn  补偿回滚(逆序)

如果 T_k 失败了,执行 C_{k-1}, C_{k-2}, ..., C_1 来撤销之前的操作。

实际例子:电商下单

# 正向子事务
T1: 库存服务.扣减库存(商品A, 数量=2)
T2: 支付服务.扣减余额(用户X, 金额=199)
T3: 物流服务.创建物流单(订单Y, 地址=Z)

# 对应的补偿操作
C1: 库存服务.恢复库存(商品A, 数量=2)
C2: 支付服务.退还余额(用户X, 金额=199)
C3: 物流服务.取消物流单(订单Y)

# 执行流程:
# 成功:T1 是 → T2 是 → T3 是 → 完成
# T3失败:T1 是 → T2 是 → T3 否 → C2 → C1 → 回滚完成

两种恢复策略

向后恢复(Backward Recovery):执行补偿操作,撤销已完成的子事务。这是最常见的策略。

向前恢复(Forward Recovery):不回滚,而是重试失败的子事务直到成功。适用于子事务最终一定会成功的场景(例如发送通知——网络恢复后肯定能发出去)。

编排 vs 协调

维度 编排模式(Orchestration) 协调模式(Choreography)
控制方式 中央编排器控制流程 各服务通过事件自行协调
耦合度 编排器知道所有步骤 各服务只知道自己的上下游
可观测性 好——流程集中管理 差——需要追踪事件链
扩展性 新增步骤改编排器 新增步骤加监听者
适用规模 步骤较多、流程复杂 步骤较少、服务独立
# 编排模式:中央编排器
class OrderSaga:
  def execute(self, order):
  try:
  # 编排器依次调用各个服务
  inventory_result = inventory_service.deduct(order.items)
  payment_result = payment_service.charge(order.user, order.amount)
  shipping_result = shipping_service.create(order)
  except PaymentError:
  # 编排器负责触发补偿
  inventory_service.restore(order.items)
  raise
  except ShippingError:
  payment_service.refund(order.user, order.amount)
  inventory_service.restore(order.items)
  raise

# 协调模式:事件驱动
# inventory_service 监听 OrderCreated 事件 → 扣库存 → 发布 InventoryDeducted 事件
# payment_service 监听 InventoryDeducted 事件 → 扣款 → 发布 PaymentCharged 事件
# shipping_service 监听 PaymentCharged 事件 → 创建物流单
# 失败时:发布补偿事件,各服务自行回滚

Saga 的局限:没有隔离性

Saga 最大的问题是中间状态对外可见。T1 已经提交(库存已扣减),T2 还没执行——此时另一个请求查库存,看到的是一个不一致的中间状态。

这不是 bug,这是 Saga 模型的本质限制。常见的缓解策略:

四、TCC:Try-Confirm-Cancel

业务层面的两阶段

TCC 可以看作是 2PC 在业务层面的变体。它把数据库层面的 Prepare/Commit 提升到了业务逻辑层面:

阶段 2PC TCC
第一阶段 数据库级别的 Prepare(写日志、加锁) 业务级别的 Try(预留资源)
第二阶段 数据库级别的 Commit 业务级别的 Confirm(确认使用)
回滚 数据库级别的 Abort 业务级别的 Cancel(释放资源)

本质区别:2PC 锁的是数据库行,TCC 锁的是业务资源。2PC 的锁由数据库管理,对业务透明;TCC 的”锁”由业务代码管理,需要开发者自己实现。

实际例子:转账

用户 A 向用户 B 转账 100 元:

// ========== Try 阶段:预留资源 ==========

// A 账户服务
public boolean tryDeduct(String userId, BigDecimal amount) {
  // 不直接扣余额,而是冻结金额
  // UPDATE account SET frozen = frozen + 100 WHERE user_id = 'A' AND balance - frozen >= 100
  Account account = accountDao.findByUserId(userId);
  if (account.getBalance().subtract(account.getFrozen()).compareTo(amount) < 0) {
  return false;  // 余额不足
  }
  accountDao.addFrozen(userId, amount);  // 冻结 100 元
  return true;
}

// B 账户服务
public boolean tryAdd(String userId, BigDecimal amount) {
  // 记录待入账金额(可选:有些实现 Try 阶段不做操作)
  accountDao.addPending(userId, amount);
  return true;
}

// ========== Confirm 阶段:确认执行 ==========

// A 账户服务
public void confirmDeduct(String userId, BigDecimal amount) {
  // 真正扣款:减余额,减冻结
  // UPDATE account SET balance = balance - 100, frozen = frozen - 100 WHERE user_id = 'A'
  accountDao.deductBalance(userId, amount);
  accountDao.releaseFrozen(userId, amount);
}

// B 账户服务
public void confirmAdd(String userId, BigDecimal amount) {
  // 真正入账:加余额,清除待入账
  // UPDATE account SET balance = balance + 100, pending = pending - 100 WHERE user_id = 'B'
  accountDao.addBalance(userId, amount);
  accountDao.releasePending(userId, amount);
}

// ========== Cancel 阶段:释放资源 ==========

// A 账户服务
public void cancelDeduct(String userId, BigDecimal amount) {
  // 释放冻结金额
  // UPDATE account SET frozen = frozen - 100 WHERE user_id = 'A'
  accountDao.releaseFrozen(userId, amount);
}

// B 账户服务
public void cancelAdd(String userId, BigDecimal amount) {
  // 清除待入账
  accountDao.releasePending(userId, amount);
}

幂等性:TCC 的硬性要求

Confirm 和 Cancel 必须是幂等的。因为网络超时时,协调器不知道操作是否已经执行成功,唯一的选择是重试。如果 Confirm 不幂等,重试可能导致重复扣款。

// 幂等实现:用事务记录表
public void confirmDeduct(String txnId, String userId, BigDecimal amount) {
  // 先检查是否已经执行过
  if (txnLogDao.exists(txnId, "CONFIRM")) {
  return;  // 已执行,直接返回
  }
  accountDao.deductBalance(userId, amount);
  accountDao.releaseFrozen(userId, amount);
  txnLogDao.insert(txnId, "CONFIRM");  // 记录已执行
}

空回滚和悬挂

两个 TCC 特有的陷阱:

空回滚:Try 阶段因为网络超时没有到达参与者,协调器认为 Try 失败,发起 Cancel。参与者收到 Cancel,但从没收到过 Try——它需要正确处理这个”凭空出现”的 Cancel 请求。

悬挂:先收到 Cancel(空回滚),后收到迟到的 Try。如果 Try 正常执行,就会冻结一笔永远不会被 Confirm 或 Cancel 的资源。

// 防悬挂的 Try 实现
public boolean tryDeduct(String txnId, String userId, BigDecimal amount) {
  // 检查是否已经执行过 Cancel(空回滚)
  if (txnLogDao.exists(txnId, "CANCEL")) {
  return false;  // 已经回滚过了,拒绝 Try
  }
  // 正常的 Try 逻辑
  accountDao.addFrozen(userId, amount);
  txnLogDao.insert(txnId, "TRY");
  return true;
}

// 防空回滚的 Cancel 实现
public void cancelDeduct(String txnId, String userId, BigDecimal amount) {
  if (!txnLogDao.exists(txnId, "TRY")) {
  // Try 从未到达,记录空回滚
  txnLogDao.insert(txnId, "CANCEL");
  return;  // 什么都不做
  }
  // 正常的 Cancel 逻辑
  accountDao.releaseFrozen(userId, amount);
  txnLogDao.insert(txnId, "CANCEL");
}

五、现代数据库怎么做:Spanner、CockroachDB、TiDB

教科书 2PC 的问题在工业界催生了三种截然不同的解法。

Google Spanner:TrueTime + 2PC

Spanner 的核心洞察:2PC 的很多问题源于时间不确定性。 如果你能让所有节点的时钟严格同步,很多事情就简单了。

Google 的做法极其暴力:在每个数据中心部署 GPS 接收器和原子钟,构建 TrueTime API。TrueTime 不返回一个时间点,而是返回一个时间区间 [earliest, latest],保证真实时间一定在这个区间内。不确定性窗口通常在 1~7 毫秒。

# Spanner 的提交流程(简化)
1. 获取 TrueTime 区间 [t_earliest, t_latest]
2. 选择 commit_ts = t_latest(保证在未来)
3. 等待 commit_wait:直到 TrueTime.now().earliest > commit_ts
4. 此时可以确定:所有节点都能观察到这个提交发生在 commit_ts
5. 执行 2PC 提交

Commit wait 是 Spanner 的关键:通过等待不确定性窗口过去,确保因果序与时间戳序一致。代价是每个事务至少有几毫秒的延迟。

CockroachDB:HLC + 并行提交

CockroachDB 没有 Google 的硬件条件(GPS + 原子钟),所以用 Hybrid Logical Clock (HLC) 替代 TrueTime。HLC 结合物理时钟和逻辑时钟,在大多数情况下提供足够好的时间戳排序。

CockroachDB 的另一个创新是并行提交(Parallel Commit):

# 传统 2PC:串行
Prewrite all → Write txn record (COMMITTED) → Resolve locks

# CockroachDB 并行提交:
Prewrite all intents (包含 in-flight 列表)
  ↓ 同时
Write txn record (STAGING, in-flight=[intent1, intent2, ...])

# 其他事务遇到 STAGING 状态的事务记录时:
# 检查所有 in-flight intent 是否都已写入
# 如果是 → 事务等价于 COMMITTED
# 如果否 → 事务等价于 ABORTED

这把提交延迟从两个网络往返降到了一个。

TiDB:Percolator + Raft

TiDB 的事务模型在前面已经详细讲过。它在 Percolator 基础上的关键优化:

对比表

维度 Spanner CockroachDB TiDB
时钟方案 TrueTime(GPS + 原子钟) HLC(混合逻辑时钟) TSO(中心化时间戳)
事务模型 2PC + Paxos 2PC + Raft(并行提交) Percolator + Raft
隔离级别 外部一致性(最强) Serializable Snapshot Isolation / RC
时钟依赖 硬件原子钟 NTP + 逻辑时钟 中心化 PD 节点
Commit 延迟 ≥ TrueTime 不确定性(~7ms) 1 RTT(并行提交) 2 RTT(标准),1 RTT(async commit)
开源 否(Cloud Spanner 是托管服务) 是(BSL → Apache 2.0) 是(Apache 2.0)
适用场景 全球级强一致 分布式 SQL 替代方案 HTAP + MySQL 兼容

六、现代数据库的混合提交

教科书把提交协议讲成 2PC 或 3PC 二选一。现实中的分布式数据库早就不玩这个了——每家都在 2PC 骨架上叠了自己的”黑科技”来压延迟、扛分区。

Spanner:TrueTime + Commit Wait

Spanner 的思路最暴力:用硬件解决时间问题,让软件变简单。

CockroachDB:HLC + Read Refresh

CockroachDB 没有原子钟,所以用 HLC 替代 TrueTime,把复杂度从写路径转移到读路径。

TiDB:Async Commit + 1PC

TiDB 基于 Percolator 模型,用中心化 TSO 保证全局时间戳有序,然后在两阶段提交上做优化。

对比总结

维度 Spanner CockroachDB TiDB
时钟机制 TrueTime(原子钟+GPS) HLC(NTP + 逻辑时钟) TSO(中心化分配)
提交协议 2PC + Paxos + Commit Wait Parallel Commit(1 RTT) Percolator + Async Commit / 1PC
写入延迟 ≥ TrueTime 误差(~7ms) 1 RTT(~2-5ms 同机房) 2 RTT 标准 / 1 RTT async commit
一致性保证 External Consistency(最强) Serializable + 不确定性窗口 Snapshot Isolation(默认)
硬件依赖 原子钟 + GPS 天线 普通服务器 + NTP 普通服务器 + NTP
单点风险 无(Paxos 复制) 无(Raft 复制) TSO 需要高可用(PD 集群)
跨区域延迟 受 Commit Wait 约束 较低(本地生成时间戳) 受 TSO 网络往返约束

没有最优解——Spanner 用钱买确定性,CockroachDB 用算法换硬件,TiDB 用中心化换简单性。选哪个取决于你的钱包和你的一致性需求。

七、选型指南

没有银弹。每种分布式事务方案都是在一致性、可用性、性能、复杂度之间做取舍。

大对比表(含上面新增的混合提交)

维度 2PC Percolator Saga TCC
一致性 强一致 快照隔离 最终一致 最终一致
隔离级别 Serializable Snapshot Isolation 无隔离性 业务级隔离
可用性 低(Coordinator 单点) 高(状态在数据中) 高 高
性能 差(全局锁) 中等(行级锁) 好(无锁) 中等(业务锁)
延迟 2+ RTT 2 RTT 取决于步骤数 2+ RTT
实现复杂度 低(协议简单) 中(需要 MVCC 存储) 中(需要补偿逻辑) 高(Try/Confirm/Cancel + 幂等 + 防悬挂)
业务侵入 无 无 高(需定义补偿) 极高(需拆分三阶段)
适用场景 同质数据库间的短事务 大规模 KV 存储上的事务 跨服务长流程 跨服务需要中间状态可控
典型用户 MySQL XA、PostgreSQL TiDB、Google Percolator 电商订单、旅行预订 金融转账、库存预留

怎么选

你在做数据库内部的分布式事务? → Percolator 或类 Spanner 方案。别自己造轮子,用 TiDB、CockroachDB 这类现成的分布式数据库。

你在做跨微服务的业务编排? → Saga 或 TCC。流程简单、失败可补偿 → Saga。中间状态必须可控、资源必须预留 → TCC。

你的场景对一致性要求极高、且不能有中间状态泄露? → 考虑把业务下推到同一个分布式数据库里,用数据库原生事务。跨服务的强一致是个伪需求——如果你真的需要它,说明你的服务拆分可能有问题。

你就是想用 2PC? → 可以。但请确保:Coordinator 有高可用(Raft 复制)、有超时机制、有人工兜底流程。别让凌晨三点的报警成为你的日常。


延伸阅读:

参考资料:

  1. Peng, D. & Dabek, F. (2010). Large-scale Incremental Processing Using Distributed Transactions and Notifications. OSDI.
  2. Corbett, J. C. et al. (2013). Spanner: Google’s Globally-Distributed Database. ACM TOCS.
  3. Taft, R. et al. (2020). CockroachDB: The Resilient Geo-Distributed SQL Database. SIGMOD.
  4. Garcia-Molina, H. & Salem, K. (1987). Sagas. ACM SIGMOD.
  5. Fischer, M. J., Lynch, N. A. & Paterson, M. S. (1985). Impossibility of Distributed Consensus with One Faulty Process. JACM.
  6. TiDB 事务模型文档 – TiDB 官方事务文档

同主题继续阅读

把当前热点继续串成多页阅读,而不是停在单篇消费。

2026-04-13 · distributed

【分布式系统百科】3PC 的理论改进与 Saga 的工程妥协

2PC 阻塞时协调者宕机怎么办?3PC 试图用额外一轮消息解决,却撞上网络分区。Saga 放弃全局锁,用补偿事务换取可用性。本文从 Skeen 论文推导 3PC 的非阻塞证明,分析其分区缺陷,再到 Saga 编排/协同、补偿陷阱、Temporal 工程实践和 TCC 资源预留——把分布式事务的理论与工程讲清楚。

2026-04-13 · distributed

【分布式系统百科】Percolator 模型:Google 的乐观事务方案

Percolator 在 Bigtable 之上用三列设计实现了跨行分布式事务,其核心思路是把事务协调状态编码进数据本身,从而消除了对专用协调者节点的依赖。本文拆解其两阶段提交流程、冲突检测与锁清理机制,并分析 TiDB 对该模型的工程改进。

2026-04-13 · distributed

【分布式系统百科】分布式事务实战对比:TiDB vs CockroachDB vs YugabyteDB

自 Google 在 2012 年发表 Spanner 论文以来,分布式数据库领域掀起了一场深刻的变革。Spanner 向世界证明了一个长期被认为不可能的命题:在分布式系统中,我们可以同时获得水平扩展能力、强一致性和 SQL 语义。这打破了传统 NoSQL 系统"为了扩展性而牺牲一致性"的惯例,开启了所谓的 NewSQ…


By .