Chapter 03

一致性模型谱系

前两章给了两条约束:节点无法区分慢与死(第 01 章),以及没有可信全局时钟、唯一客观顺序是 happens-before(第 02 章)。这一章用这两条约束构建一条谱系——定义"一次读到底应该看到哪些写、以什么顺序",从最强的线性一致到最弱的最终一致,并说明每一级的代价与极限。

本章你将建立的 schema

  • 一致性(复制)/ 隔离(事务)/ 共识 是三件不同的事,解决三个不同问题
  • 线性一致性 = 系统像只有一份数据,代价是协调至少 1–2 个 RTT;分区时必须阻塞
  • 因果一致性是分区下仍可用的系统能达到的最强一致性;再强就得牺牲可用
  • CRDT 靠交换律 + 结合律 + 幂等律,任意顺序合并都收敛,无需协调

3.1先厘清三个 C:一致性 vs 隔离 vs 共识

三个词答三个不同问题——混淆它们会导致用错误的工具解决错误的问题。

为什么需要它

工程师经常把"系统一致性"挂在嘴边,却在同一句话里混指三件事。结果是:用隔离级别去推断跨副本行为、把 Raft 误认为是"让事务可见"的机制、把 CAP 的 C 和 ACID 的 C 当作同一回事——每一种混淆都会引发错误的架构决策。

一致性(consistency,复制语义)回答:多副本环境下,一次读应该看到哪些写、以什么顺序?这是本章的主题。

隔离(isolation,事务语义)回答:一个事务能看到另一个进行中事务的什么内容?串行化(serializability)是隔离的最高级别,它保证并发事务的执行结果等价于某个串行执行,但它对多副本下不同客户端各自读到什么顺序完全没有约束。

共识(consensus)回答:N 个节点如何对一个值(如日志条目、leader 身份)达成一致?共识是构建块,Raft 用它实现线性一致的复制日志。共识本身不直接等于"客户端读到什么"——它是实现手段,不是用户可见的语义层。

命名陷阱

"C in ACID" 指应用级不变量(外键完整性、业务规则、账户余额非负)——这是应用的责任,数据库只负责在事务提交前不破坏你声明的约束。"C in CAP" 指线性一致性(linearizability)——多副本下读到的值要符合单副本的全局实时序。两者只共用同一个字母,含义毫无关联。

同理,serializability ≠ linearizability。前者是隔离(事务内的并发控制),后者是一致性(跨副本的读写顺序)。两者正交——都满足称为 strict serializability,是数据库能提供的最强组合,也是代价最高的。

一致性 复制语义 线性一致性 因果一致性 / 最终一致 隔离 事务语义 串行化 RR / RC / RU 共识 构建块 Raft / Paxos 用于实现线性一致日志 三个域正交:混淆后果是用错工具解决错误的问题
图 3.1一致性、隔离、共识三个域各自回答不同问题、使用不同机制。注意:共识(Raft)是实现线性一致的手段,不是面向用户的语义层。
想一想

一个数据库声称"支持串行化",这对多副本下的读顺序有什么保证?

展开答案(先停 10 秒)

没有。串行化是隔离级别,保证并发事务的结果等价于某个串行执行,但它对多副本下不同节点各自返回什么值的实时顺序完全没有约束。要保证跨副本的读顺序,需要的是线性一致性(或因果一致性)。这正是 serializability ≠ linearizability 的核心。

3.2线性一致性(linearizability)

每个操作看起来在调用与返回之间某一瞬间原子生效,且符合全局实时序——系统表现得像只有一份数据。

为什么需要它

没有线性一致性时,工程师必须手动处理"副本滞后":读之前先等待、用版本号重试、把每次读都路由到 leader。线性一致性将这个负担从应用层下沉到存储层,让调用方可以把系统当作单副本来推理。

线性一致性的精确语义:对于历史中的每一个操作,存在一个"生效点"(linearization point),该点落在操作的调用时刻与返回时刻之间;所有操作按生效点排出的全序与实时顺序一致,且每次读返回最近一次在它之前生效的写的值。

实现机制:需要一个所有节点可见的串行化点。常见实现:单 leader 将所有写路由到 leader,读也要走 leader(或 leader lease);多数派 quorum 确认(如 Raft:写要被多数副本持久化才可见,读要么走 leader 要么用 ReadIndex/Lease Read)。最低开销是 1–2 个网络 RTT;分区时为保线性一致必须阻塞——这正是 CAP 定理所述的取舍。

代价的下界:Attiya 与 Welch 证明,在网络延迟不确定度为 u 的环境里,线性一致读的响应时间不快于 u/4,线性一致写不快于 u/2。延迟抖动越大,线性一致的延迟代价越高,这是理论下界,不是工程疏漏。

类比 · 带边界

线性一致性像"单机内存":写完后任意路径的读都看到最新值。边界:这个比喻在单机上成立因为总线保证原子性;分布式环境里"总线"换成了网络 RTT,一旦网络分区,系统必须在"可用但过期"和"阻塞但保新鲜"之间选一个。

具体例子:客户端 A 向数据库写 x = 1,收到成功响应后打电话告诉客户端 B。B 随后向任意副本发起读,线性一致保证 B 必须看到 x = 1,而不是旧值 x = 0。

想一想

一个系统把所有写路由到单 leader,但允许 follower 直接响应读请求——它能提供线性一致性吗?

展开答案(先停 10 秒)

不能。Follower 可能滞后于 leader,从 follower 读到的值可能比已经对外承诺的最新写更旧。要线性一致,读要么走 leader,要么用 ReadIndex(leader 确认自己仍是有效 leader 并等到提交点)或 Lease Read(leader 持有时间租约内可本地响应)。直接从 follower 读最多能提供因果一致或最终一致,取决于实现。

3.3顺序一致性(sequential consistency)

存在一个覆盖所有操作的单一全序,保持每个进程内部的程序序,但不要求对齐真实时钟序。

为什么需要它

顺序一致性是多处理器内存模型的经典语义(Lamport 1979 年提出)。在分布式系统里,它提供比线性一致性更弱(开销更低)但比因果一致性更强的保证——有时刚好是应用需要的折中点。

顺序一致性与线性一致性的唯一区别是:线性一致性要求全序对齐实时(wall-clock)序;顺序一致性只要求全序保持每个进程内的程序序(操作在单进程内的先后),不要求跨进程的实时顺序。

用"打电话"例子说明差异:线性一致下,A 写完 x = 1 打电话通知 B,B 任意副本的读都必须看到新值,因为电话建立了真实时间的先后。顺序一致下,系统只保证存在某个一致的全序——但那个全序不必和真实时间对齐。B 的读仍允许看到旧值,因为 B 的读在真实时间上确实晚于 A 的写,但系统的全序里可以把它排在更早。顺序一致不尊重跨进程的侧信道(side channel)。

类比 · 带边界

顺序一致像"每人各自的日记序":每人自己的操作顺序是准确的,但两个人之间的相对顺序可以被系统重排。边界:如果应用依赖跨客户端的实时因果(A 告诉 B 之后 B 去读),顺序一致不够——需要线性一致。

3.4因果一致性(causal consistency)

只保证因果相关操作(happens-before)在所有节点上看起来同序;因果无关的操作可以在不同节点上以任意顺序出现。

为什么需要它

线性一致性在网络分区时必须阻塞。许多应用(社交 feed、协同编辑、购物车)愿意接受"因果无关的操作顺序不统一",但不能接受"因果相关的操作顺序颠倒"(先看到回答后看到问题)。因果一致性恰好在这两条线之间:够用、且分区时仍可保持可用。

机制:副本用第 02 章的 happens-before 追踪依赖。COPS(Clusters of Order-Preserving Servers)给每个写附带显式的依赖列表——列出它依赖的所有之前的写及其版本;副本在将一个写设为"可见"之前,先检查它的所有依赖是否已经到齐(依赖到齐才暴露,否则缓存等待)。这称为 causal+ 一致性。

理论地位:因果一致性提供 sticky availability(只要客户端始终连同一个副本,始终可用),是分区下可用系统能达到的最强一致性——Mahajan、Alvisi 和 Dahlin 的 CACM 2013 论文证明了再强就必须牺牲可用(CAP 边界)。

例子对比:聊天中"问题 → 回答"有因果关系(回答 happens-after 问题)。因果一致保证所有节点都先看到问题再看到回答。最终一致则不保证:某个慢副本先收到回答消息再收到问题消息,让读者困惑。

想一想

两条消息"Alice 发了一张图"和"Bob 发了一条文字"之间没有因果关系。因果一致下,不同副本看到它们的顺序可以不同吗?

展开答案(先停 10 秒)

可以。因果无关的操作在因果一致下可以被不同副本以不同顺序看到——这是它比线性一致更弱的地方。如果应用不能接受这种顺序差异(比如需要全局统一的消息序号),就需要更强的模型(顺序一致或线性一致),并承担相应的协调开销。

3.5最终一致性、会话保证、BASE

停止写入后副本最终收敛,但收敛前任何顺序保证都没有——除非应用层叠加会话保证。

为什么需要它

最终一致性是 Dynamo、Cassandra 等系统选择的基线模型,用最大可用性换取最弱的读保证。没有任何附加约束时,客户端会遭遇非单调读(先读到版本 5,再读到版本 3)、读到已被覆盖的旧写、或看到逻辑上不合理的顺序。为了让应用不必完全裸露在这些异常之中,会话保证提供了一组轻量补丁。

最终一致性的典型异常:

  • 非单调读(non-monotonic reads):客户端第一次读到版本 5,路由到另一副本后读到版本 3。
  • 读旧值(stale reads):写已经成功,但读到的仍是写之前的值。
  • 丢失更新(lost update):两个并发写,系统选择保留其中一个,另一个静默消失。
四条会话保证(session guarantees)
保证语义防止什么异常典型用法
read-your-writes 客户端总能看到自己之前发出的写 改密码后立刻登录被旧密码拒绝 sticky session 或 token 携带版本
monotonic reads 客户端的读只能看到单调增加的版本 刷新页面后数据"倒退"到更旧的状态 版本向量 + 副本选择
monotonic writes 同一客户端的写按发出顺序被副本应用 操作被乱序应用导致逻辑状态错乱 单调递增的写序号
writes-follow-reads 写在它读到的版本之后 先读到 v2 再写 v3,副本却把 v3 应用在 v1 之后 携带读版本作为写依赖

BASE vs ACID:BASE(Basically Available, Soft state, Eventual consistency)是最终一致系统的哲学——先接受写、后台异步收敛,乐观地假设冲突少。ACID 是悲观的:写前必须满足所有不变量,冲突时阻塞或回滚。两者不是优劣之分,是对不同失败概率和业务场景的不同赌注。

3.6CRDT:无需协调也能收敛

一类数据结构,其合并操作满足交换律、结合律、幂等律,任意顺序、任意次数合并都收敛到同一结果,副本无需协调。

为什么需要它

最终一致系统在收敛时面临冲突:两个副本各自有一个不同的值,系统必须决定留哪个。LWW(last-write-wins)靠时间戳选择,但 02 章已经说明物理时间戳不可靠,结果是静默丢数据。CRDT 用代数性质彻底规避了这个选择:合并操作本身就是数学意义上无损的,不存在"哪个赢"的问题。

底层机制:CRDT 的状态构成一个 join-semilattice(并半格):存在偏序关系,任意两个状态有唯一的最小上界(least upper bound,lub)。合并操作就是取 lub。更新只允许让状态"向上走"(单调递增),永远不会让状态退回到偏序的更低点。这保证了:

  • 交换律:merge(A, B) = merge(B, A)——副本接收顺序无关
  • 结合律:merge(merge(A, B), C) = merge(A, merge(B, C))——批量合并顺序无关
  • 幂等律:merge(A, A) = A——重复收到同一状态无副作用

两种实现风格:

state-based CRDT(CvRDT):副本传播整份状态,接收方取 lub 合并。实现简单,但带宽随状态大小线性增长;适合状态小、更新稀疏的场景。

op-based CRDT(CmRDT):副本只传播操作(delta),带宽省;但要求传输层保证因果有序 + exactly-once 投递——恰好是分布式环境里更难保证的两件事,反而加重了底层要求。

常见例子:

  • G-Counter:每个节点维护自己的计数槽,值为各槽求和,合并取逐槽 max。不能递减,适合页面浏览量等只增场景。
  • PN-Counter:两个 G-Counter,一个记加、一个记减,值 = 加总 − 减总。
  • OR-Set(Observed-Remove Set):每次加元素附带唯一 tag,删除只删带该 tag 的条目。并发加删时,加操作胜出——因为删除只针对已观察到的 tag,并发加的 tag 未被删除操作观察到。
  • LWW-Register:用时间戳决定最终值,有损(静默丢弃较旧的写);实现最简单但需要时钟同步。

实际应用:Redis Enterprise 的 Active-Active CRDT、Riak 的分布式计数器与 Set、Automerge 和 Yjs 协同文档编辑(第 08 章前沿部分会展开)。

陷阱

Op-based CRDT 要求传输层保证因果有序投递和 exactly-once,但这两条在真实网络里默认都不满足(TCP 给 exactly-once,但不给因果序;消息队列给顺序,但通常只在单分区内)。在没有额外基础设施保障的情况下直接使用 op-based CRDT,合并结果就不收敛。

协调成本 ↑ 分区可用性 ↑ 线性一致性(linearizability) 每操作在 invoke–response 间原子生效 · 符合实时序 · 需协调 (1–2 RTT) 顺序一致性(sequential consistency) 存在单一全序 · 保进程内程序序 · 不保实时序 因果一致性(causal consistency) happens-before 关系同序 · 因果无关可乱序 · 分区可用 最终一致性(eventual consistency) 停写后收敛 · 收敛前无顺序保证 · 最大可用性 CAP 边界 分区时最强
图 3.2一致性谱系阶梯:向上协调成本增加,向下分区可用性增加。注意:因果一致性是 CAP 边界上的最强点——再往上(顺序/线性)就必须在分区时阻塞。
副本 A [A=3, B=1] sum = 4 副本 B [A=2, B=4] sum = 6 merge = 逐槽取 max 合并结果 [A=max(3,2)=3, B=max(1,4)=4] sum = 7(两副本收敛同值) 无论顺序如何合并,结果相同(交换律 + 结合律 + 幂等律)
图 3.3G-Counter:两副本各自维护计数槽,合并时逐槽取 max,收敛到同一总值。注意:副本 A 的 sum=4、副本 B 的 sum=6,但合并后 sum=7,高于两者——这是正确的,说明两副本各自计数了不同的事件,合并不重复计算。

自测

  1. "C in ACID" 和 "C in CAP" 各指什么?两者有关联吗?
  2. 线性一致性比顺序一致性多要求了什么约束?用"电话通知"的例子说明。
  3. 为什么因果一致性是"分区下可用系统能达到的最强一致性"?再强一级(顺序一致)会失去什么?
  4. CRDT 靠哪三条代数性质保证副本无需协调也能收敛?分别阻止什么问题?
展开答案

1. "C in ACID" 是应用级不变量(外键完整性、业务规则)——应用的责任,数据库在事务提交前检查约束。"C in CAP" 是线性一致性——多副本下读写的实时顺序符合单副本语义。两者只共用字母,含义完全不同,没有关联。

2. 线性一致性多要求全序对齐真实时钟序(实时性)。电话例子:A 写完 x=1 打电话给 B,这建立了真实时间的先后。线性一致下 B 的任意副本读都必须返回 x=1(实时序被尊重);顺序一致下 B 可能仍读到 x=0,因为系统只保证存在某个一致全序,不保证那个全序对齐真实时间(侧信道不被尊重)。

3. Mahajan 等人证明:因果一致性提供 sticky availability——分区时只要客户端连同一副本就不阻塞。顺序一致和线性一致都需要跨副本协调以确定全序或实时序,这在分区时会阻塞。因此,要在分区下保持可用,系统最多只能保证因果一致,再强就必须在分区时拒绝服务或阻塞。

4. 交换律(merge(A,B)=merge(B,A))——副本接收顺序无关,网络乱序不影响结果;结合律(merge(merge(A,B),C)=merge(A,merge(B,C)))——批量合并顺序无关,多跳传播不累积错误;幂等律(merge(A,A)=A)——重复收到同一状态不改变结果,重传安全。三条合在一起,任意时刻、任意顺序合并都收敛到同一结果。

进阶挑战 · 刚好够不着

用向量时钟搭出因果一致性

02 章的向量时钟追踪 happens-before。设计一个副本协议:副本在让一个写"可见"之前,需要检查什么?每个写应该携带哪些元数据?当一个写依赖的前驱写还没到达时,副本应如何处理?

提示(卡住再展开)

每个写携带其发出时的向量时钟快照(即它的依赖版本向量)。副本维护自己当前已应用的向量时钟。一个写可以变为"可见"的条件:对于它依赖向量中的每一个条目 (node, version),副本的当前向量在该 node 上的分量 ≥ version。如果不满足,先缓存该写,等依赖到齐后再暴露。这就是 COPS 的依赖检查机制。注意:纯向量时钟在节点数多时空间开销大,实际系统往往用版本向量的压缩形式或逻辑时间戳代替。

参考

  • Kyle Kingsbury, Jepsen consistency model map — 可视化各一致性模型的包含关系,是工程实践中最常引用的参考图。
  • Martin Kleppmann, Designing Data-Intensive Applications, Ch. 5 & Ch. 9 — 本章大量内容的系统阐述,尤其是线性一致性的形式定义和实现代价分析。
  • Viotti & Vukolić, "Consistency in Non-Transactional Distributed Storage Systems", ACM Computing Surveys 2016 — 50+ 一致性模型的系统综述,含形式定义与包含关系证明。
  • Shapiro, Preguiça, Baquero, Zawirski, "A Comprehensive Study of Convergent and Commutative Replicated Data Types", INRIA 2011 — CRDT 的奠基论文,给出 state-based 和 op-based 的形式框架与常用数据结构实现。