两笔事务都读到 x=0,随后都想把 x 写成 1。锁住其中一个写者并不难。更麻烦的是,两笔事务分别写 x 和 y,同时读取对方正在修改的对象。它们没有争抢同一个写锁,仍可能一起通过不完整的验证,留下任何串行顺序都解释不了的结果。

分布式系统(26):Spanner 的时间区间、提交等待与一致快照

FaRM 把这类冲突检测放在内存事务的提交路径上,又用 RDMA 把读取和日志复制做得很轻。低 CPU 的数据搬运并没有消除事务协议:乐观并发控制要回答“这组读写能否串行化”,复制要回答“故障后写值还在不在”,原子提交要回答“多个 region 是否形成同一个终局”。本文以 SOSP 2015 论文为主,2014 年版本只用于解释协议演进。

先固定论文的系统模型

FaRM 面向单数据中心的内存驻留数据。全局地址空间被切成 2 GiB 的 region;每个 region 有一个 primary 和 f 个 backups,并放到不同的 failure domains。对象只从 primary 读取:本地对象用普通 load,远端对象可用 one-sided RDMA。应用线程与存储服务运行在同一批机器上。[SOSP 2015 §2–§3]

flowchart LR
  C[事务协调线程] -->|本地 load / one-sided RDMA read| PA[region A primary]
  C -->|one-sided RDMA read| PB[region B primary]
  PA --> BA[A backups]
  PB --> BB[B backups]
  CM[配置管理器 + ZooKeeper + 租约] -.成员关系.-> PA
  CM -.成员关系.-> PB

论文讨论 crash failure,不包括 Byzantine 节点。安全性依赖时钟漂移有界,活性依赖消息延迟最终有界。可用性还要求某个网络分区同时拥有 FaRM 机器多数、能够访问 ZooKeeper 多数,并且每个对象至少留有一个副本。把这些条件删掉,再说“网络分区时仍然可用”,已经不是论文结论。[SOSP 2015 §5]

论文所谓 NVRAM 是一套具体设计:断电时由电池或机架 UPS 继续供电,把 DRAM 内容写入 SSD。它既不是普通易失内存,也不能不加说明地替换成今天泛称的 persistent memory。后文谈 NIC hardware ACK,含义也被这个持久域前提约束。

执行期可以暂时看到不一致

事务从 primary 读取对象,保存地址与版本号;写值先留在协调者的本地缓冲区。单个对象读取是原子的,只返回已提交值,同一事务再次读取同一对象也稳定。可是跨对象读集未必来自一个原子快照。

假设约束是 x=0 或 y=0。T1 先读 x=0,再读 y;两次读取之间,另一个事务可能改变 y。FaRM 不要求正在执行的应用始终看到一个可串行化快照。它保证的是:如果这笔事务最终成功提交,验证必须证明它的读写能够放进一个 strict-serializable 的历史。

这一区别常被 opacity 掩盖。本文讨论的 2015 FaRM(后称 FaRMv1)不为中止事务提供 opacity:事务代码必须能够承受临时不一致并继续走到 commit 或 abort,不能因为看见组合上古怪的中间状态就越界、崩溃或产生不可撤回的外部副作用。[SOSP 2015 §3–§4]

2019 年的 FaRMv2 已经改变了这条边界:它引入全局时间、时间戳排序与多版本,为提交和中止事务提供 opacity。FaRMv2 又允许逐事务选择 snapshot isolation 或 non-strict 模式,所以也不能把默认保证无条件套到所有事务上。本文余下协议步骤仍专指 2015 版本。[FaRMv2 §1–§2、§4.2]

LOCK 和 VALIDATE 共同完成 OCC 认证

读写事务提交时,协调者先处理写集。它给每个写对象的 primary 发送 LOCK 记录,其中包含事务 ID、涉及的 region,以及该 primary 上对象的地址、旧版本和新值。primary 的 CPU 轮询日志,再用 CAS 检查版本并加锁。版本已变化或对象已被锁住,事务就失败。

取得全部写锁以后,协调者进入 VALIDATE:重新读取那些“读过但不写”的对象,检查版本和锁位。写对象不在这里重复验证,因为 LOCK 已经核对过它们的版本。

flowchart TB
  E[执行:读取版本,缓冲写值] --> L[LOCK 写对象]
  L --> C{版本相同且未加锁?}
  C -->|否| A[ABORT 并释放已取锁]
  C -->|是| V[VALIDATE 只读集合]
  V --> R{版本相同且未被别人锁住?}
  R -->|否| A
  R -->|是| S[OCC 认证完成]

为什么既查版本又查锁?看一组 write skew:

1
2
3
初始:x=0, y=0;约束:不能同时为 1
T1:读 x,y;写 x=1
T2:读 x,y;写 y=1

T1 锁 x,T2 锁 y,两者都能完成 LOCK。此时新值尚未安装,x、y 的版本也尚未增加。如果 VALIDATE 只看版本,双方都会误以为读集没有变化,然后一起提交,得到 (1,1)。正确验证还会看到对方持有的锁,至少一笔事务中止。

sequenceDiagram
  participant T1
  participant X as primary(x)
  participant Y as primary(y)
  participant T2
  T1->>X: LOCK x,成功
  T2->>Y: LOCK y,成功
  T1->>Y: VALIDATE y 的版本与锁位
  Y-->>T1: y 被 T2 锁住,失败
  T1->>X: ABORT,释放 x
  T2->>X: VALIDATE x
  X-->>T2: 版本未变且已解锁,通过

在这个固定验证顺序里,T1 中止,T2 提交,终态为 (0,1)。也可能由另一种调度留下 (1,0);关键不是必须谁赢,而是 (1,1) 不能由两笔成功事务产生。

读写事务的序列化点取在“全部写锁取得”的时刻。LOCK 证明写对象从先前读取到这个点没有改变,VALIDATE 证明其余读对象在这个点仍有效。只读事务没有写锁,其序列化点取最后一次读取。[SOSP 2015 §4]

五个阶段不是五种同义的“提交”

通过 OCC 认证后,还没有解决副本故障和跨 region 原子性。2015 年论文的完整路径是:

flowchart LR
  L[LOCK<br/>写 primary 加锁] --> V[VALIDATE<br/>认证纯读集合]
  V --> B[COMMIT-BACKUP<br/>完整 redo 到所有 backups]
  B --> P[COMMIT-PRIMARY<br/>决定证据到 primaries]
  P --> T[TRUNCATE<br/>懒惰清理日志]

COMMIT-BACKUP 把与 LOCK 相同的完整写值写入所有 backups 的 NVRAM 日志。协调者必须等待所有目标 NIC 的 hardware ACK,但不等待 backup CPU 处理记录。只有这道屏障完成后,才能发送 COMMIT-PRIMARY。

COMMIT-PRIMARY 只需要事务 ID。primary 消费它后安装新值、版本加一并解锁,写入才变得可见。协调者收到至少一个 primary 的 ACK,就可以向应用报告成功;收到全部 primary ACK 后,再懒惰发送或捎带 TRUNCATE。backup 到处理 truncate 时才把 redo 安装到对象副本。

sequenceDiagram
  participant C as coordinator
  participant P1 as primary A
  participant P2 as primary B
  participant B1 as backups A
  participant B2 as backups B
  C->>P1: LOCK + 写值
  C->>P2: LOCK + 写值
  P1-->>C: locked
  P2-->>C: locked
  C->>C: VALIDATE 纯读集合
  par 完整 redo
    C->>B1: COMMIT-BACKUP
    B1-->>C: NIC ACK
  and
    C->>B2: COMMIT-BACKUP
    B2-->>C: NIC ACK
  end
  C->>P1: COMMIT-PRIMARY(txid)
  C->>P2: COMMIT-PRIMARY(txid)
  P1-->>C: ACK,至少一个即可答复应用
  P2-->>C: ACK
  C-->>P1: 稍后 TRUNCATE
  C-->>P2: 稍后 TRUNCATE

这条时间线里至少有四个不同事件:OCC 的序列化点、恢复协议能够推导出的 commit decision、应用收到成功、backup 把值安装到原位。把它们统称为“提交完成”,很容易在故障切点上得出错误结论。

提交前还要预留 primary 与 backup 日志中后续记录所需的全部空间。backup 的前台路径没有一个由 CPU 执行、可以拒绝资源不足的传统 prepare 处理器;如果锁住数据后才发现日志无处可写,协议可能失去活性。[SOSP 2015 §4]

两道 ACK 屏障分别保护什么

假设事务同时修改 region A、B。如果只把 redo 写到 A 的 backup,就让两个 primary 安装并暴露新值,随后协调者与两个 primary 故障,恢复时 B 的新值无处可找。系统曾向外暴露 (A=1,B=1),却只能恢复 (A=1,B=?)。所以全部 COMMIT-BACKUP ACK 是写集完整性的屏障。

为什么应用成功之前还要一个 COMMIT-PRIMARY ACK?读集只保存在协调者。若协调者通过 VALIDATE 后直接回答成功,却在任何 primary 记录提交证据前与 backups 一起故障,幸存 primary 可能只剩 LOCK,无法区分“验证已成功、应提交”和“验证前崩溃、应中止”。至少一个提交证据进入论文定义的持久域,恢复才不会否定已经报告的成功。

flowchart TB
  X[故障切点] --> Q{任一 region 有<br/>COMMIT-PRIMARY / COMMIT-RECOVERY?}
  Q -->|是| C[恢复为 commit]
  Q -->|否| B{至少一处 COMMIT-BACKUP,<br/>其他写 region 证据兼容?}
  B -->|是| C
  B -->|否| A[恢复为 abort]

论文 §5.3 的恢复过程会汇总各副本日志。任一写 region 看见 COMMIT-PRIMARY 或恢复期提交记录,就投提交;没有这类记录时,完整的 backup 证据与其他 region 的兼容状态仍可推出提交。只有 LOCK 的事务可以中止。恢复可能替一笔尚未答复应用的事务选择 commit 或 abort,但不能撤销已经暴露或已经确认的结果。

OCC、复制、原子提交和 RDMA 各管一段

机制 负责的问题 单靠它解决不了什么
LOCK + VALIDATE 检测写写与读写冲突,建立可串行化的序列化点 故障后找回写值、跨 region 共同终局
primary-backup 日志 保存 redo 与决定证据,在论文故障范围内恢复 读集验证、并发事务的串行化
原子提交路径 约束所有写 region 对同一事务形成共同终局 保证没有并发异常,或让网络分区必然进展
RDMA + NIC ACK 低 CPU 地读取 primary、搬运日志,并给出特定持久域完成信号 事务认证、决定、成员关系、租约或 Byzantine 容错

2014 年 FaRM 论文明确把路径称为 OCC 加 optimized 2PC,当时 prepare/lock 也会发到 backups。2015 版把协议与复制内存进一步融合:LOCK 只到 primaries,验证后才向 backups 写完整 redo,再通知 primaries 提交。[NSDI 2014 §3.4;SOSP 2015 §1、§7]

因此,“FaRM 不用 2PC”和“FaRM 就是教科书 2PC”都太粗糙。更准确的说法是:它仍承担原子提交的职责,但把 prepare、冲突认证、复制屏障和提交通知按 RDMA/NVRAM 数据路径重新组合。RDMA 是运输方式,不是正确性协议。

超时、分区和恢复时不能承诺什么

客户端超时只说明没有收到结果。事务可能还停在 LOCK、已经复制全部 backup 日志,或者已经有 primary 提交证据;调用者不能根据超时自行推断 abort,再用一个新的业务身份盲目重做。实际 API 需要事务状态查询或应用层幂等设计,本文没有把 FaRM 研究原型扩写成产品接口。

网络分区时,幸存分区要同时满足成员多数、ZooKeeper 多数和每个对象至少一个副本等条件,重配置与短租约还要阻止旧成员继续发 RDMA。失去这些条件,安全做法可能是停止进展,而不是靠 one-sided write 穿过配置边界。

时钟漂移界服务于租约和 precise membership 的安全性,最终有界消息延迟服务于活性。任一前提被破坏,都不能仅用“多重试几次”修复。论文也不处理恶意节点伪造日志、ACK 或成员身份;拜占庭模型要到后续第 33 篇再讨论。

本地有限模型:让职责边界变成可检查输出

实验源码位于 examples/distributed-systems/farm27/check.py,只使用 Python 标准库。它不是 FaRM 实现,也不发送 RDMA。模型固定执行三类轨迹:写写冲突、write skew 及其错误变体、四个恢复证据切点。

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

Python 3.12.3 的正式运行观察到:写同一对象时 T1 取得锁并提交,T2 中止;正确 write-skew 验证得到 T1 中止、T2 提交和 (x=0,y=1);忽略锁位的变异让两者都提交,得到不可串行化的 (1,1)。恢复模型分别得到 LOCK-only 中止、全部 backup 记录提交、一个 primary 提交证据存活时提交,并检出“漏写 B 的 backup 后先暴露再丢 primary”无法保持原子耐久。

第一次运行还抓到了实验自身的错误断言:模型原先误以为正确验证会令两笔事务都中止。实际固定顺序下,T1 中止并释放 x 锁,T2 随后可以通过。修正预期后才得到正式通过结果。失败过程没有被计作协议反例。

运行记录见实验证据,结构化结果见观察数据,验证边界见验证说明。这些结果不覆盖真实 RDMA/NIC、NVRAM 掉电、ZooKeeper、租约、任意调度、网络分区或性能。

两个推演练习

锁位为什么不能省。 T1 已锁 x、T2 已锁 y,但两者都还没有增加版本。若 VALIDATE 只比较版本,两个事务各会看到什么?仅仅把“提交后版本加一”提前到什么时候,才可能挡住异常,又会引入什么未提交值和恢复问题?

在原协议的时点,两边都看到旧版本,所以都会错误通过。提前发布版本会把未决定事务的状态暴露给读取和恢复,不能只移动一行代码;必须重新定义锁、可见性、abort 回滚和恢复证据。检查锁位直接表达“这个读对象正在被并发写者占用”。

哪个 ACK 可以晚一点。 某事务写 A、B 两个 region。A 的 backup 已 ACK,B 的 backup 未 ACK;两个 primary 都健康。此时能否让 primary 安装?若所有 backup 都 ACK,但还没有任何 COMMIT-PRIMARY ACK,能否向应用报告成功?

两个答案都是否。前者在 primary 故障后可能丢失 B 的 redo;后者在协调者及相关副本故障后可能只留下无法证明验证成功的 LOCK。第一道屏障保护完整写集,第二道屏障保护已经向应用确认的决定。

工程结论

FaRM 的关键并不是把“网络调用”换成“远程内存访问”,而是让正确性协议贴合硬件完成语义:读走 one-sided RDMA,写锁仍由 primary CPU 检查,backup 日志等待 NIC ACK,事务决定在 primary 留下足够的恢复证据。省掉远端 CPU 工作,并不等于省掉验证、复制或原子终局。

这种设计适合单数据中心、数据可驻留内存、故障域与时钟条件可控、愿意为专门硬件和恢复协议付出复杂度的环境。跨地域高延迟、恶意参与者、普通易失内存,或无法维持 precise membership 的部署,不能直接套用论文结论。

下一篇转向缓存一致性。那里没有事务提交协议替应用兜底:一次先失效、后旧值回填的竞态,就足以让数据库已经更新而缓存重新变旧。

参考资料