分布式系统(27):FaRM 的乐观验证、RDMA 与提交路径
两笔事务都读到 x=0,随后都想把 x 写成 1。锁住其中一个写者并不难。更麻烦的是,两笔事务分别写 x 和 y,同时读取对方正在修改的对象。它们没有争抢同一个写锁,仍可能一起通过不完整的验证,留下任何串行顺序都解释不了的结果。
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 | |
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 | |
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 的部署,不能直接套用论文结论。
下一篇转向缓存一致性。那里没有事务提交协议替应用兜底:一次先失效、后旧值回填的竞态,就足以让数据库已经更新而缓存重新变旧。
参考资料
- Dragojević 等,2015,No compromises: distributed transactions with consistency, availability, and performance:§2–§5 为系统、提交与恢复主依据,§6–§7 仅作实验环境与版本对照。
- Dragojević 等,2014,FaRM: Fast Remote Memory:§2、§3.2、§3.4,用于 RDMA 与早期 optimized 2PC 路径。
- Kung 与 Robinson,1981,On Optimistic Methods for Concurrency Control:OCC 验证与串行历史的基础对照。
- Gray 与 Lamport,Consensus on Transaction Commit:§2–§5,用于 2PC 的共同决定与阻塞边界。
- Dragojević 等,2019,FaRMv2: Fast, General, Distributed Transactions with Opacity:§1–§2、§4.2,用于后续版本边界。
- MIT 6.5840 Spring 2026 FaRM lecture notes及课程日程:OCC 反例、机制边界和课程结构的独立教学核验。
- MIT 6.5840 FaRM FAQ:研究系统与适用边界。资料版本、实际阅读位置、反向检索和不能支持的结论见
writing-plans/distributed-systems/research/27-farm.md。

