一笔转账同时修改两个分片。两边最终都提交,只解决了共同终局;报表还需要在一次读取中看到完整的转账结果。如果转账已经向客户端报告成功,随后发起的强一致读取也不能因为选择了较慢的时钟而退回转账之前。

分布式系统(25):分布式事务的决定、补偿与消息边界

Spanner 把这些要求组合在同一个数据库里:每个复制组保存可恢复的状态,跨组事务决定共同结果,并发控制约束冲突操作,时间戳和多版本读取决定快照内容。TrueTime 提供带误差边界的时间区间,提交等待把时间戳顺序与真实先后连接起来。机制推导以 OSDI 2012 原论文为准,课程结构对应 MIT 6.5840 和 Stanford CS244b;现行云产品的隔离级别单独说明。

复制、事务与时间各自约束什么

设账户 x 位于组 A,账户 y 位于组 B。一次事务要从 x 扣款并向 y 加款。每个组内部有多个副本,通过 Paxos 复制状态;A、B 作为两个业务参与组,通过两阶段提交协调这笔转账。组内复制多数与跨组全部参与者是两个不同集合:A 的多数派确认,不能代替 B 的准备结果。

flowchart TB
  T[跨分片转账 T] --> A[A 组:x 与事务协调者]
  T --> B[B 组:y 与参与者]
  A --> AP[A 组 Paxos 副本]
  B --> BP[B 组 Paxos 副本]
  A <-->|跨组 2PC| B
  L[锁与并发控制] --> A
  L --> B
  TT[TrueTime 区间] --> TS[共同提交时间戳 s]
  TS --> A
  TS --> B

2012 年论文的读写事务采用两阶段锁。读取经过相应组的 leader 并取得读锁,修改在客户端缓冲,提交时取得写锁;锁的释放受事务结束约束。跨组 2PC 在这些锁和复制状态之上工作。只读事务则预先声明为只读,在同一个时间戳上查询多版本数据,不需要沿用读写事务的锁路径。[原论文 §2.1、§4.1、§4.2]

模型假设节点按协议执行,网络可能延迟或分区,副本可能崩溃;复制日志及恢复机制提供其规定故障范围内的持久状态。时间证明还额外要求 TrueTime 给出的区间确实包含调用时的真实时间。磁盘永久丢失、拜占庭节点或错误的时间边界,都不能被当作已经包含在这段证明中的普通慢请求。

Paxos 保持组内状态一致,2PC 约束参与组的共同决定,锁约束读写冲突。即使某个时钟非常准确,也不能据此确定另一个组是否准备成功,更不能用一个时间戳替代提交记录。

TrueTime 返回区间,等待检查下界

一次 TT.now() 返回 [earliest, latest]。设调用事件发生在真实时间 r,契约是 earliest <= r <= latest。不同机器可以返回不同区间;协议依赖的是区间包含真实时间,不是所有机器的读数相等。

flowchart LR
  E[earliest = 90] --> R[真实时间 r = 100]
  R --> L[latest = 110]
  L --> S[候选提交戳 s = 111]
  S --> W[继续查询时间]
  W --> Q{earliest 大于 111?}
  Q -->|否| W
  Q -->|是| P[满足 TT.after 的等待条件]

图中的数值是教学整数时间,不是产品的毫秒参数。上界参与选择提交戳,下界参与判断它是否已经确定地过去。在 earliest=111 时还不能断定严格晚于 111;只有 earliest>111 才满足这里的 TT.after(111)

原论文的时间服务组合 GPS、原子钟及本地时钟漂移界,周期性校准并传播不确定性。这里需要保留的是契约及其实现前提,不能把 2012 年论文的测量值当成当前云服务的固定误差,也不能在普通服务器上执行一次 sleep(10ms) 就称为实现了 TrueTime。[原论文 §3、§5.3]

区间变宽而且仍包含真实时间,通常增加等待;真实时间落在区间之外,则破坏安全论证的前提。例如实际时间还只有 101,某节点却报告 [123,125],它可能过早判断时间戳 122 已过去。这与一个诚实地报告较大误差的时钟有不同后果。

一次跨组提交的完整时间线

仍以 A 为协调组、B 为另一个参与组。客户端已经完成读取并缓冲两边的修改。B 取得所需写锁,选择高于本组此前分配值的 prepare 时间戳 p,通过 Paxos 保存准备状态,再把 p 交给协调者。A 也取得写锁,但原论文这条路径跳过协调组自己的 prepare 阶段。[原论文 §4.2.1]

sequenceDiagram
  participant C as 客户端
  participant A as A 组协调 leader
  participant AQ as A 组副本
  participant B as B 组 leader
  participant BQ as B 组副本
  C->>A: 提交请求及 A 的写集
  C->>B: B 的写集与协调者信息
  B->>B: 取得写锁,选择 prepare 戳 p
  B->>BQ: Paxos 复制 Prepared
  BQ-->>B: 准备状态已复制
  B-->>A: Prepared,p
  A->>A: 取得写锁,选择共同提交戳 s
  par 决定复制
    A->>AQ: Paxos 复制 Commit T,s
    AQ-->>A: 决定已复制
  and 提交等待
    A->>A: 等待 TT.after(s)
  end
  A->>A: 两个条件满足后 apply
  A-->>C: 提交成功,s
  A->>B: Commit T,s
  B->>BQ: 复制结果,以 s 应用版本

s 必须达到每个非协调参与组的 prepare 下界,并且高于协调组此前分配的时间戳。详细提交算法还要求它高于协调者收到提交请求后取得的 TrueTime 上界。在离散整数示例中,可以写成:

1
s = max(all_prepare_timestamps, coordinator_previous + 1, TT.latest + 1)

论文的抽象 Start 规则用 s >= latest 证明外部顺序;§4.2.1 的具体提交路径采用更强的 s > latest。上式满足具体路径,prepare 的约束仍是非严格的 s >= p+1 只适用于这里的离散表示,不能直接当成生产时间戳分配器。

协调者复制 commit 记录后,还不能仅凭这条记录立即向外暴露已提交数据。允许协调组副本应用该 commit 之前,必须满足 TT.after(s)。复制通信与等待可以重叠,不能把图中两条支路机械相加成固定延迟公式。等待结束后,协调者向客户端与其他参与 leader 发送结果;其余组再复制结果、应用带 s 的版本并释放锁。[原论文 §4.2.1]

客户端收到成功时,某个远端副本仍可能尚未应用这笔事务。所有参与组使用同一个逻辑提交戳,不代表它们在同一物理瞬间修改内存。要防止读者从落后副本漏读,读取路径还需要检查安全时间。

提交等待怎样建立真实先后

外部一致性要求:如果 T1 已经完成,T2 才开始,那么解释事务结果的串行顺序必须把 T1 放在 T2 之前。对于有重叠的事务,真实时间没有给出这样的先后约束,仍要由并发控制产生合法顺序。

令 s1、s2 为两笔读写事务的提交戳。T1 返回成功的真实时间记为 r1;T2 在 b2 开始,其提交请求在 a2 到达协调者。提交等待确保 s1 < r1;题设给出 r1 < b2;请求的因果顺序给出 b2 <= a2;TrueTime 区间和 Start 规则给出 a2 <= s2。因此得到:

1
s1 < r1 < b2 <= a2 <= s2
flowchart LR
  S1[s1] -->|提交等待| R1[T1 返回 r1]
  R1 -->|真实先后| B2[T2 开始 b2]
  B2 -->|请求因果| A2[提交请求到达 a2]
  A2 -->|区间上界与 Start| S2[s2]

这证明了时间戳尊重非重叠事务的真实先后。完整的事务保证还依赖锁、多版本读取和原子提交;单独这串不等式不证明任意读写程序可串行化。以整笔事务作为操作讨论实时串行语义,也不能被替换成“每个键单独线性一致”。[原论文 §4.1.2;Google Cloud 外部一致性文档]

去掉等待可以直接破坏这条链。T1 在真实时间 100 取得合法区间 [90,110],选择 s1=111,却立即返回。T2 在 101 之后开始,在另一组取得合法区间 [102,104],选择 s2=105。两个区间都可以包含各自调用时的真实时间,后开始事务的时间戳却更小。

sequenceDiagram
  participant A as 组 A 的 T1
  participant C as 客户端真实时间
  participant B as 独立组 B 的 T2
  A->>A: real=100,TT=[90,110],s1=111
  A-->>C: 跳过等待,提前返回
  C->>B: T1 完成后开始 T2
  B->>B: real=103,TT=[102,104],s2=105
  Note over A,B: 真实顺序 T1 在先,提交戳 111 却大于 105

这个反例使用独立组,避免同一组的时间戳单调规则恰好阻止倒序。它证明所要求的外部时间戳不变量被破坏;如果两笔事务只操作互不相关的键,仅凭这两个数字还不能声称那个键值历史必然不可线性化。要观察到漏写,还需要构造依赖这些时间戳的后续快照读取。

固定快照与副本安全时间

假设初始 x=y=0,一笔跨组事务在 s=20 同时写入 x=y=1。在快照 19 上应读到 (0,0),快照 20 上应读到 (1,1)。版本选择规则是取时间戳不大于读取时间的最新版本,等于读取时间的版本也包含在内。

flowchart LR
  X[x 的版本:0@0,1@20] --> R19[固定快照 19:x=0,y=0]
  Y[y 的版本:0@0,1@20] --> R19
  X --> R20[固定快照 20:x=1,y=1]
  Y --> R20
  X --> MIX[x 在 19 读到 0]
  Y --> MIX2[y 在 20 读到 1]
  MIX --> BAD[分别读取:可能得到 0,1]
  MIX2 --> BAD

分别发起两次强读取,可能跨过转账的提交点。每次读取本身都正确,组合结果却不代表同一个时刻。跨组只读事务选择统一的快照时间戳,再让每个组按这个时间戳读版本。论文给出的简单选择是事务开始后取 TT.now().latest,随后等待所选副本能够安全服务这一时刻。[原论文 §4.1.4、§4.2.2]

副本已经拥有某条版本,不等于它已经掌握某个快照的全部内容。论文定义两个安全界:Paxos 已应用进度,以及事务管理器由未决 prepare 产生的限制。读取时间 t 只有不超过二者的最小值,才能执行。

1
2
3
4
safe = min(paxos_safe, transaction_safe)
t <= safe 才能读取
transaction_safe = infinity # 没有未决 prepare
transaction_safe = min(unresolved_prepare) - 1 # 本文整数时间模型
flowchart TB
  R[请求快照 t] --> P{已应用进度覆盖 t?}
  P -->|否| WP[等待复制及应用推进]
  P -->|是| T{未决 prepare 是否可能影响 t?}
  T -->|是| WT[等待事务结果与应用]
  T -->|否| V[选择 version 不大于 t 的最新值]

第一个限制挡住落后副本。第二个限制处理“已知事务准备了,但尚不知道会不会提交、以什么时间戳提交”:若最早 prepare 为 p,未来提交戳可能达到 p,因此不能在尚未解决时承诺 p 及其后的快照完整。整数域中的 p-1 表示严格早于 p,不是产品精度或 API 算法。

无锁读取仍可能等待。副本追上日志需要时间,未决事务也可能因为协调者不可达而延迟结束。论文还讨论了利用未来时间戳下界 MinNextTS(n) 推进空闲组安全时间;副本必须获得相关协议证据,不能直接把本机墙钟赋给 safe。[原论文 §4.1.3、§4.2.4]

故障与工程成本

协调组 leader 崩溃后,复制的事务状态有助于新 leader 恢复决定;这个收益依赖相应多数派可达及恢复协议能够推进。若协调组多数不可达,或者所需参与组无法恢复,时间再准确也不能完成缺失的事务步骤。2PC 的跨组依赖没有被 TrueTime 消除。

flowchart TB
  F[提交或读取尚未完成] --> Q{协调组多数可达?}
  Q -->|否| N[决定复制或恢复可能停滞]
  Q -->|是| D{提交决定已持久复制?}
  D -->|否| R[按事务恢复规则继续]
  D -->|是| W{TT.after 已满足?}
  W -->|否| CW[等待可信时间下界推进]
  W -->|是| A{读副本 safe 覆盖快照?}
  A -->|否| AW[等待应用或未决事务解决]
  A -->|是| OK[服务相应快照]

可信区间越宽,提交等待通常越长;跨地域复制越慢,复制门槛越难满足;热点写锁和长事务又会延迟相关事务。它们可能重叠,不能仅根据时钟误差估算端到端提交时间。只读快照避免锁竞争,但仍有副本等待、版本保留和历史回收的成本。

显式历史读取允许选择过去,从而在满足时间界时减少追赶最新状态的等待。过旧的快照可能已经被回收;当前产品的时间界文档说明了相关失败条件。历史读取的含义也不包含“看见调用前所有完成写”。Timestamp bounds

2012 年论文与当前产品 API

截至 2026-09-22 核对的官方文档,Spanner 支持 Serializable 与 Repeatable Read 两种隔离级别,默认是 Serializable。Repeatable Read 使用快照隔离,可能出现 write skew;显式选择它时,不能继续套用默认隔离的外部一致性承诺。隔离级别说明

现行产品的 Serializable 也可以使用乐观并发控制,并非所有读写事务都沿本文的 2012 年锁式路径执行。乐观模式需要相应的读集验证等机制。讨论 API 保证时应看隔离级别、事务类型和读取时间界;讨论实现时还要说明并发控制模式。并发控制文档REST v1 TransactionOptions

flowchart LR
  O[2012 原论文] --> M[锁式读写事务及论文证明]
  API[当前 API] --> S[Serializable:对应强事务保证]
  API --> RR[Repeatable Read:快照隔离边界]
  API --> H[显式历史时间界:读取过去]
  S --> C[仍需确认事务类型与并发控制模式]

提交戳也不应直接用作天然全局唯一业务 ID。时间戳与真实先后的关系是协议规定的顺序性质,不意味着每笔事务都获得唯一的物理完成时刻。应用若需要幂等键,仍应定义业务身份。

本地实验:检查必要条件及移除后的反例

实验位于仓库 examples/distributed-systems/spanner26/,使用 Python 标准库执行有限状态轨迹。真实时间是审计器保存的整数,协议只能读取模拟 TrueTime 区间;推进时间不会调用本机 sleep。Paxos 复制完成是显式状态转移,不包含共识实现、真实网络或云服务调用。

1
2
3
4
5
cd examples/distributed-systems
mkdir -p .build/spanner26/tmp
export TMPDIR="$PWD/.build/spanner26/tmp"
export TMP="$TMPDIR" TEMP="$TMPDIR" PYTHONDONTWRITEBYTECODE=1
python3 -B spanner26/check.py

实验分别检查跨组提交的两个完成条件、去掉等待后的独立组时间戳逆序、违反时间区间契约的后果,以及 MVCC 固定快照和安全读取门槛。期望正常轨迹保持约束,错误变体产生具体反例。有限轨迹通过只说明这些输入下的模型行为,不能验证实际 GPS、原子钟、Paxos、任意并发调度或 Spanner 服务。

实验运行与页面验证记录随文保存在实验记录验证边界结构化观察

两个推演练习

宽区间与错误区间。 提交戳是 111。时钟先返回 [80,120],随后返回 [100,140],两次都包含真实时间。事务能否因为 latest 已大于 111 而结束等待?若真实时间为 101,却收到 [112,114],情况有何变化?

等待检查 earliest,前两次都不满足 earliest>111。第三个区间会使等待条件通过,但它不包含真实时间,违反了证明前提。增加超时重试不能把错误区间变成可信区间。

无锁读取的等待。 副本的已应用进度界为 130,最早未决 prepare 为 120。读取快照 125 应返回旧版本还是等待?若去掉 prepare 检查,需要什么额外事实才能断言旧版本正确?

此时 safe=min(130,119)=119,应等待。仅有应用进度不能排除未决事务最终影响快照 125;必须获得它的结果及相应应用证据,或者能排除它影响本次读取的更细粒度证据。本文没有实现原论文的键范围优化,不能把旧值返回当作这种优化。

下一篇讨论 FaRM:当目标转为数据中心内的低延迟事务,乐观验证、RDMA 与故障恢复分别承担哪些工作,以及它们怎样与原子提交结合。

参考资料