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

分布式事务不是你以为的那个 2PC

目录

“分布式事务用 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_Arow_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 官方事务文档

By .