“分布式事务用 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 有三种经典的灾难场景:
失败模式一: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 的数据里:
- Primary 行有 write 记录 → 事务已提交。Secondary 行的锁可以安全清理。
- Primary 行有 lock 但没有 write → 事务未提交。可以安全回滚(清理所有 lock)。
- Primary 行的 lock 已过期(TTL 超时)→ Coordinator 可能已经挂了。其他事务可以接管清理。
# 其他事务遇到 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 复制)。核心改进包括:
- Placement Driver (PD) 作为全局时间戳分配器(TSO),替代 Google 内部的 Oracle
- 异步 Commit 优化:Secondary 行的提交可以异步进行,不阻塞客户端
- 1PC 优化:只涉及单个 Region 的事务可以跳过 Prewrite,直接一阶段提交
- Raft 层保证每个 Region 的数据可靠性——详见 Raft 实现拆解:etcd 的共识算法到底长什么样
三、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 基础上的关键优化:
- 异步提交:Primary 提交后立即返回客户端,Secondary 异步提交
- 1PC:单 Region 事务跳过两阶段
- Pipelined Pessimistic Lock:悲观锁模式下,加锁操作可以流水线化
对比表
| 维度 | 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 的思路最暴力:用硬件解决时间问题,让软件变简单。
- 时钟机制:TrueTime API,返回时间区间
[earliest, latest],误差 1-7ms - 提交协议:2PC + Paxos。每个 shard 是一个 Paxos 组,Coordinator 本身也做 Paxos 复制
- 关键优化:Commit Wait——提交时等待不确定性窗口过去,确保后续事务能看到这次写入
- 代价:每次写入至少等 ~7ms(TrueTime 误差上界),但因为误差极小,实际影响有限
CockroachDB:HLC + Read Refresh
CockroachDB 没有原子钟,所以用 HLC 替代 TrueTime,把复杂度从写路径转移到读路径。
- 时钟机制:HLC(物理时间 + 逻辑计数器),节点间通过 RPC 消息自动同步
- 提交协议:Parallel Commit——Prewrite 和事务记录(STAGING)并行写入,1 RTT 提交
- 关键优化:Read Refresh——读事务遇到不确定性窗口内的写入时,不阻塞等待,而是将读时间戳推到写入之后重试
- 代价:max_offset 默认 500ms,极端情况下读事务需要重启
TiDB:Async Commit + 1PC
TiDB 基于 Percolator 模型,用中心化 TSO 保证全局时间戳有序,然后在两阶段提交上做优化。
- 时钟机制:TSO(Timestamp Oracle),PD 节点集中分配,严格全序
- 提交协议:Percolator 2PC,但 Primary 提交后立即返回客户端,Secondary 异步提交
- 关键优化:1PC——只涉及单个 Region 的事务跳过 Prewrite,直接一阶段原子提交
- 代价: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 复制)、有超时机制、有人工兜底流程。别让凌晨三点的报警成为你的日常。
延伸阅读:
- Raft 实现拆解:etcd 的共识算法到底长什么样 – 2PC 的 Coordinator 高可用靠什么?靠共识算法
- 高可用的谎言 – “我们系统是高可用的” 这句话到底意味着什么
参考资料:
- Peng, D. & Dabek, F. (2010). Large-scale Incremental Processing Using Distributed Transactions and Notifications. OSDI.
- Corbett, J. C. et al. (2013). Spanner: Google’s Globally-Distributed Database. ACM TOCS.
- Taft, R. et al. (2020). CockroachDB: The Resilient Geo-Distributed SQL Database. SIGMOD.
- Garcia-Molina, H. & Salem, K. (1987). Sagas. ACM SIGMOD.
- Fischer, M. J., Lynch, N. A. & Paterson, M. S. (1985). Impossibility of Distributed Consensus with One Faulty Process. JACM.
- TiDB 事务模型文档 – TiDB 官方事务文档