Chapter 05

一致性与共识

第 03 章把状态切开(分区),第 04 章把状态拷贝出多份(复制)。两步之后,同一份数据在多台机器上同时存在多个版本:主与从、quorum 里的各个副本,谁也不保证彼此一字不差。本章正面回答上一章留下的问题——当数据被复制成多份、副本之间还会落后时,一次读到底还能拿到什么保证,又凭什么能。

本章建立的认知

  • 一致性模型是一把从强到弱的梯子:越往上保证越强,越往上越贵、越难在网络分区里维持。
  • CAP 只在分区真正发生时才强制取舍;它说的远比"三选二"少,误读它会让人在错误的地方做权衡。
  • PACELC 补上 CAP 漏掉的另一半——网络健康时(99.99% 的时间),真正天天疼的取舍是延迟与一致性。
  • 多数派的任意两个集合必相交——这条集合相交事实(不是概率)是共识与选主安全的基石。
  • Raft 把共识拆成"选一个 leader + 把日志复制到多数派即提交"两件可理解的事。

5.1一致性模型:一把从强到弱的梯子

第 04 章给出了一个旋钮:写到几个副本(W)、读几个副本(R)、同步还是异步。旋钮拧到不同位置,一次读看到的"新鲜度"就不同。把所有可能的新鲜度按强弱排成一列,就是一致性模型(consistency model)——它是系统对外承诺的"读保证",定义了一次读最坏会看到多旧的数据、几个并发读之间会不会互相矛盾。

一致性模型不是"对错"的开关,而是"系统答应你看到多新的数据"的等级;越强的等级要求副本之间协调得越紧,协调要花延迟、要等多数派,分区时还可能根本无法满足。

从强到弱,常用的五级是:线性一致(linearizable)> 顺序一致(sequential)> 因果一致(causal)> read-your-writes(读到自己刚写的)> 最终一致(eventual)。逐级拆开看它们各自承诺了什么、又靠放弃什么换来更便宜:

  • 线性一致:存在一个全局实时顺序,每次读都返回"此刻之前最后一次写"的结果——对外表现得像只有一份数据,副本是透明的。代价:写必须等到足够多副本达成一致才能返回,每次读要么走多数派要么靠租约,延迟最贵;分区时一侧根本拿不到多数派,无法满足。
  • 顺序一致:所有节点看到的操作顺序一致,但这个顺序不必和真实时间对齐(你的写可能"晚一点"才对别人可见,只要大家看到的相对顺序相同)。比线性一致松一点——不要求实时性,于是不必为每次操作做实时对齐。
  • 因果一致(causal):只对存在 happens-before(因果先后,如"先发帖再有人回帖")关系的操作排序;互不相关的并发操作允许在不同副本上以不同顺序出现,即允许分叉。正因为它不强求给并发操作定一个全局顺序,它能在网络分区下继续工作,而线性一致不能——这是因果一致在工程上格外重要的原因。
  • read-your-writes:只保证同一个客户端能读到自己刚提交的写,不管别人。这是把"全局一致"缩小到"单条会话内一致"的廉价折中——第 04 章里"用户发完评论刷新却看不到"的复制延迟问题,就是这一级被破坏。
  • 最终一致(eventual):只承诺"如果停止写入,所有副本最终会收敛到同一值",但不保证"最终"是多久,期间读到任意旧值都合法。最便宜、最高可用,绝大多数无主/异步复制系统的默认级别。
强 弱 协调更紧 · 延迟更贵 · 分区下更难维持 线性一致 linearizable 像只有一份数据 · 最贵 · 分区下不可用 顺序一致 sequential 全局顺序一致 · 但不必对齐实时 因果一致 causal 只排因果序 · 允许并发分叉 · 分区下可活 read-your-writes 只保证读到自己刚写的 最终一致 eventual 停止写入后终会收敛 · 最便宜 · 最高可用 需要协调 / 共识 / 多数派 可异步复制 就近副本即可答
图 5.1一致性是一把梯子,不是一个开关——选型是在"读到多新"和"花多少延迟、分区下还能不能用"之间挑一级。注意:因果一致是分水岭,它上方的级别要靠协调(多数派/共识),它及它下方的级别才能在分区里继续工作。

深一层

线性一致之所以"最贵",不是实现复杂,而是它对延迟收税:要让每次读都见到全局最新写,写就不能在本地拍板,必须等到一个多数派确认(一次跨节点往返),读也得确认自己读的是最新版本。这条延迟成本和分区无关、天天都在付——这正是下面 PACELC 要补的那一半。

先想一想

一个系统只承诺因果一致。两个用户分别在两台从不通信的副本上各写了一条互不相关的状态(没有因果先后),第三个用户先后读这两台副本。他看到的两条状态顺序,必须一致吗?

展开答案

不必。因果一致只对有 happens-before 关系的操作强制排序;两条互不相关的并发写没有因果先后,允许在不同副本上以不同顺序出现(分叉),两次读看到不同顺序都合法。要让它们顺序一致,得升到顺序一致或线性一致——代价就是放弃分区下的可用性。这正是因果一致"便宜且分区可活"的来源:它只在必须排序的地方排序。

5.2CAP 精读:它到底说了什么、没说什么

CAP 是 Eric Brewer 2000 年提出、Gilbert 与 Lynch 2002 年证明的一条定理。它常被压缩成一句口号"一致性、可用性、分区容忍三选二"——这句口号是误读,而且是会把人引到错误权衡上的那种误读。先把它准确地说清楚。

CAP 里的三个字母有非常具体的含义,含义错了整条推理就错:

  • C(一致性)= 线性一致,注意不是泛泛的"数据正确",而是 5.1 梯子最顶端那一级:每次读都见到全局最新写。
  • A(可用性)= 每个非故障节点对收到的请求都返回非错误响应(哪怕数据是旧的)。这个定义很严格——多数派系统在少数派那一侧会直接返回错误,按 CAP 的字面定义它在那一侧"不可用",尽管工程上大家仍称这类系统高可用。
  • P(分区容忍)= 系统在节点间消息任意丢失/延迟时仍能继续运转。

关键在于:P 在真实网络里不是一个可选项。网络一定会丢包、会延迟、会断链,分区迟早发生,没有哪个分布式系统能选择"不容忍分区"——选了就等于宣布断网即全挂。所以"三选二"里把 P 当成可放弃的一项,是空想。CAP 真正说的是一条条件取舍:

CAP 的准确陈述

当分区真正发生时,被隔开的两侧之间消息互相到不了,此时一个节点收到读请求只有两条路:要么用本地可能陈旧的状态作答(保住 A、放弃 C),要么拒绝作答直到能确认数据最新(保住 C、放弃 A)。鱼与熊掌不可兼得,因为一侧的最新写物理上传不到另一侧。分区不发生时,CAP 不强制任何取舍——C 和 A 可以同时拥有。

分区左侧 写入 x = 2(新值) 已被本侧接受 分区右侧 本地仍是 x = 1(旧值) 收到一个读请求 ✕ 分区 新写传不过去 选 A:返回 x = 1(陈旧) 可用但不一致 选 C:拒绝作答 / 报错 一致但不可用
图 5.2分区只在被隔开的一侧逼出两难:本地有旧值,可以"给陈旧(A)"或"拒绝(C)",但给不出已被另一侧接受的新值。注意:取舍只在分区发生时出现;平时两侧能通信,新写传得过去,C 和 A 并不互斥。

把它接回本教程的主线:CAP 的全部难题,正是 03 章的切分加 04 章的复制共同制造出来的。没有复制就没有"另一侧的新写",也就没有"陈旧 vs 拒绝"的两难——一致性问题是"状态被切分又被复制"的下游产物,而不是凭空冒出来的新麻烦。

最常见的误读(会害你做错权衡)

把 CAP 当成设计期"三选二"的菜单,然后给自己的系统贴个"这是 CP 系统"的标签,以为从此与可用性的烦恼无关。问题是:分区是偶发事件(一年通常只有几次),CAP 的取舍只在那几次里生效;系统天天都在付的代价是别的——网络健康时强一致带来的延迟。把全部注意力押在 CAP 上,等于盯着一年几次的事,忽略了每秒都在发生的事。下一节的 PACELC 正是为补这个洞而生。

5.3PACELC:CAP 漏掉的另一半

Daniel Abadi 在 2010 年指出 CAP 只描述了一半的世界——它只谈"分区时怎么办",对"不分区时(绝大多数时间)怎么办"只字未提。PACELC 把这一半补上,读作"P-A-C / E-L-C":

如果发生分区(Partition),在可用性(A)与一致性(C)间取舍;否则(Else,网络健康时),在延迟(Latency)与一致性(C)间取舍。

前半句 PAC 就是 CAP;真正的新信息是后半句 ELC。它点破了一个天天发生、却被 CAP 框架完全遮住的事实:哪怕网络完全健康、根本没有分区,强一致依然不是免费的——要让一次读见到全局最新写,就得等一个 quorum 往返(5.1 已说明),这一来一回是实打实的延迟。想砍掉这段延迟,就只能放松一致性,让最近的副本不等协调、立即作答。

PACELC 的两个取舍维度 + 几个真实系统的站位
系统分区时(PAC)健康时(ELC)这意味着
DynamoDB / Cassandra(默认) PA:选可用,给陈旧 EL:选低延迟,就近副本立即答 默认最终一致,为砍尾延迟而生;强一致是可选项、要额外付费/付延迟
HBase / BigTable PC:选一致,少数派侧停摆 EC:健康时也走一致路径 任何时刻都优先正确,代价是延迟与少数派侧不可用
Spanner PC:选一致 EC:靠 TrueTime 做全局有序,付 commit-wait 延迟 全球强一致的代价被显式做成"等一段不确定窗口"(详见 09 章)

深一层:很多"最终一致"其实和分区无关

线上大量系统选最终一致,真实动机不是"怕分区时不可用",而是要砍掉 p99 尾延迟——也就是 PACELC 的 EL 支,发生在网络好端端的时候。如果只用 CAP 的镜头看,会把这个延迟驱动的决策错当成分区驱动的决策,进而在错误的维度上争论"这套系统到底是 CP 还是 AP"。先问"健康时愿不愿意为强一致多付一个 quorum 往返",往往比纠结分区站位更切题。

先想一想

一个团队宣称自家系统是"CP 系统"。在网络完全健康、没有任何分区的此刻,CAP 框架对它的读写施加了什么约束?

展开答案

此刻 CAP 什么都不约束——CAP 只在分区发生时生效,没有分区就没有"C 与 A 二选一"。健康时真正约束这个系统的,是 PACELC 的 ELC:它选了 C(强一致),就得为每次操作付 quorum 往返的延迟(L)。所以"CP 系统"这个标签描述的是它一年里偶发分区时的行为,而它每天的延迟特性其实写在 PACELC 的 else 支里。把这两件事分开,是看懂选型的关键。

5.4多数派 overlap:共识为什么是安全的

从"该不该一致"转到"凭什么能一致"。第 04 章已经用过 quorum:写 W 个、读 R 个,令 W+R>N 则读集与写集必有交集,于是读到的副本里至少有一个带着最新版本。共识(多个节点对"哪个值/谁是 leader"达成一致并不再反悔)站在同一块地基上,只是把它用到了更要命的地方——选主和提交。这块地基就是多数派 overlap。

N 个节点里,任意两个多数派(各 ≥ ⌊N/2⌋+1 个节点)必然至少共享一个成员——因为 2×(⌊N/2⌋+1) > N,两个都超过半数的集合不可能完全不重叠(鸽巢原理)。这是集合相交,是确定性事实,不是"碰运气才会撞上"。

这一条确定性交集,撑起了共识的全部安全性:

  • 选主不会选出两个 leader:当选 leader 需要拿到一个多数派的票。两个候选人都想当选,就各需要一个多数派,但两个多数派必相交——那个交集里的节点不可能把票同时投给两人(一个 term 内每个节点只投一票),所以同一任期里最多一个 leader。脑裂在这里被从根上掐死。
  • 已提交的数据不会丢:一条日志被"提交"意味着它已写入一个多数派。下一任 leader 的选举多数派,与这个提交多数派必相交——交集里那个节点手上就有这条已提交日志,新 leader 必然看得到它,于是不会用一段缺了它的历史去覆盖。
  • 少数派只能停摆,不能分叉:被分区切到少数派那一侧的节点凑不齐多数派,既选不出 leader 也提交不了新日志——它只能停下来等,而不会另起一段相互矛盾的历史。可用性在这一侧被牺牲(正对应 CAP 的 C>A),换来的是整个系统永不分叉。
N = 5,多数派 = 3 A B C D E 多数派① 提交多数派 {A, B, C} 多数派② 新选举多数派 {C, D, E} 交集 = {C} C 同时在两个多数派里 → 已提交数据必被新 leader 看见
图 5.3提交多数派 {A,B,C} 与下一次选举多数派 {C,D,E} 在 C 处必然相交——C 手里有那条已提交日志,所以新 leader 一定看得到它。注意:这是集合相交的确定性,不是概率;正因如此共识才敢承诺"已提交永不丢、永不出现两个 leader"。

把 04 章的 quorum 接上来

04 章用 W+R>N 保证读集与写集相交,从而"读得到最新值";这里用的是同一把锁——只不过共识让 W 和 R 都取多数派(各 ⌊N/2⌋+1),把"读到最新"升级成"对一个值达成不可反悔的一致"。也别忘了 04 章的警告:sloppy quorum 会打破交集——写落到 R 集之外的兜底节点时,读写不再相交,"读到最新"的保证随之失效。共识系统因此从不用 sloppy quorum。

5.5Raft:把共识拆成两件可理解的事

多数派 overlap 说明了共识"为什么安全",但没说"具体怎么跑"。Paxos 给出过一套正确的跑法,却以晦涩著称,工程实现里 bug 频出。Raft(Ongaro & Ousterhout, 2014)的设计目标直白写在标题里——可理解的共识:它和 Paxos 拥有完全相同的多数派安全保证,但把整件事拆成两个相对独立、各自好懂的子问题。

Raft 今天已是事实标准,etcd(Kubernetes 的元数据存储)、Consul、TiKV、Kafka 的 KRaft 模式都用它,已经把 Paxos 挤到了教科书里。它把共识拆成的两件事是:

① 选主(leader election)

任何时刻最多一个 leader,所有写都先到 leader。每个节点有一个随机的选举超时;谁先超时谁就自增 term(任期号,单调递增的逻辑时钟)、变成候选人、向所有节点拉票,拿到一个多数派的票就当选。随机超时是点睛之笔:它让多个节点几乎不会同时超时去抢,避免选票被平分而反复流选——用随机性打破对称,而不是靠精确的时钟。安全则来自 5.4:一个 term 内多数派只会产生一个 leader。

② 日志复制(log replication)

leader 把每条写当作一个日志条目追加到自己的日志,再并行发给 followers。当一个条目被复制到多数派后,leader 才把它标记为 committed(已提交)并应用到状态机、回复客户端。又是同一条多数派规则:提交即"进了一个多数派",于是 5.4 保证它跨越选主永不丢失。followers 的日志和 leader 不一致时,以 leader 为准强制对齐——单一强 leader 让整条日志被线性化成一个唯一序列,这正是 Raft 比 Paxos 好实现的根本原因(Paxos 允许多个提案者并发,日志不天然成线)。

N = 5:客户端写 → leader 复制到多数派 → 提交 客户端 Leader term = 7 写 x Follower 1 ✓ Follower 2 ✓ Follower 3 Follower 4 已 ack 尚未 ack leader + F1 + F2 = 3 达到多数派 → committed
图 5.4leader 不必等全部 follower——只要含自己在内凑齐多数派(这里 3/5),条目即 committed,剩下两个慢 follower 稍后补齐。注意:提交门槛是"多数派"而非"全部",这既给了容错(最多挂 ⌊N/2⌋ 个仍能提交),又通过 5.4 的交集保证已提交项跨越换主不丢。

5.6选型对比与两个常见陷阱

把全章拧成一张选型表。最核心的取舍是强一致 vs 最终一致——这是 5.1 梯子两端,也是 5.2 / 5.3 那两套框架真正落地的地方:

强一致 vs 最终一致:到底各牺牲了什么
维度强一致(线性一致)最终一致
读到的数据 永远是全局最新写 可能读到任意旧值,停写后才收敛
健康时的代价(PACELC 的 EL) 每次操作付一个 quorum 往返延迟 就近副本立即答,延迟最低
分区时的行为(PACELC 的 PAC) 少数派侧停摆(不可用)以保正确 两侧都继续作答(高可用),事后收敛
实现基础 共识 / 多数派 quorum(Raft) 异步复制 + read-repair + anti-entropy
适用 账户余额、库存、配置元数据、选主 点赞计数、浏览数、信息流、商品目录

表里最容易被忽略的是"代价落在哪"这一行:强一致的痛主要落在健康时的延迟(天天付),最终一致的痛主要落在分区时的不一致窗口(偶发但要会兜底)。看错痛点,就会在错误的地方做优化。

陷阱一:把 CAP 当设计期"三选二",自封 "CP 系统"

如 5.2 所述,分区是偶发事件,CAP 取舍只在那几次生效。给系统贴个 CP/AP 标签当作"权衡已做完",会让人忽视天天在疼的 PACELC else 支——健康时的延迟与一致性取舍。正确做法是把两个问题分开问:分区时要 A 还是 C(PAC),以及健康时愿为强一致多付多少延迟(ELC)。

陷阱二:以为"有 leader 就自动线性一致"

一个被网络分区切出去、但自己还不知道的陈旧 leader(stale leader),会拿着过期数据继续响应读——它以为自己还是 leader,其实多数派那边已经选出新 leader 了。要堵住这个洞,读不能只信 leader 的本地状态,必须走 quorum 读,或者用 leader lease(租约):leader 只在租约有效期内才敢服务读,租约到期前若联系不上多数派就主动下台。换句话说,写走多数派只保证了写的安全,读的线性一致需要单独再付一次代价。这条会在 08 章讲 fencing 时和脑裂防御接上。

先想一想

一个用 Raft 的强一致 KV 存储,写都安全地复制到多数派了。一个客户端只向当前 leader 发读请求,且 leader 直接返回本地值。这次读一定是线性一致的吗?

展开答案

不一定。如果这个 leader 已被分区切走、却还没意识到(陈旧 leader),它的本地值可能落后于新 leader 已提交的写,这次读就读到了陈旧数据,破坏线性一致。Raft 的写多数派只保证了写的安全,读的线性一致要额外手段:要么读也走一轮多数派确认(ReadIndex),要么 leader 持有效租约期间才服务读。这正是陷阱二——"有 leader ≠ 读线性一致"。

自测

先合上教程、自己写下答案,再展开对照——能复述出"为什么"才算过关,记住结论不算。

  1. CAP 到底说了什么、又没说什么?为什么"三选二"是误读?
  2. 为什么任意两个多数派一定相交?这条相交事实分别保证了选主和提交的什么安全性质?
  3. 强一致与最终一致主要的代价分别落在哪里?(提示:一个落在分区可用性,一个落在健康时延迟)
  4. 因果一致为什么能在网络分区下继续工作,而线性一致不能?
展开参考答案

1. CAP 说的是:当分区发生时,被隔开的一侧只能在"用本地陈旧状态作答(保 A 弃 C)"和"拒绝作答(保 C 弃 A)"之间二选一,因为另一侧的最新写物理上传不过来。它没说三个里随便丢一个——P 在真实网络里不可选(网络必然会分区),分区不发生时 C 和 A 可以同时拥有。"三选二"误把 P 当成可放弃项,并把只在分区时生效的取舍当成永久菜单。

2. 多数派各 ≥ ⌊N/2⌋+1,两个多数派合起来 > N 个名额,但只有 N 个节点,按鸽巢必有至少一个节点同时属于两者(确定性,非概率)。选主:两个候选人各需一个多数派,交集里的节点一个 term 只投一票,故同任期最多一个 leader(防脑裂)。提交:新选举多数派与上次提交多数派相交,交集节点持有那条已提交日志,新 leader 必然看见 → 已提交数据不丢、不会被缺它的历史覆盖。

3. 强一致的代价主要落在 健康时的延迟(PACELC 的 EL)——每次操作要等一个 quorum 往返,天天都在付;外加分区时少数派侧停摆。最终一致的代价主要落在 分区时(及异步窗口里)的不一致——读得到旧值、需要应用层兜底,但换来最低延迟与最高可用。看错痛点会在错误维度上优化。

4. 因果一致只对有 happens-before 关系的操作强制排序,互不相关的并发操作允许在不同副本上分叉、各自先行。分区时两侧无法协调出一个全局顺序,但各自仍能给本地操作和有因果依赖的操作排序,所以能继续作答。线性一致要求一个对齐实时的全局顺序、每读必见最新写,分区时一侧拿不到另一侧的写,这个全局顺序根本无法维持,只能停摆。

进阶挑战

全球账户余额:既要强一致,又要低延迟读

设计一个面向全球用户的账户余额系统:余额必须强一致(绝不能花掉不存在的钱),同时希望读余额的延迟尽量低。给出你的方案,并明确回答:它在 PACELC 里站在哪个位置(PAC?ELC?)、读路径怎么在"不牺牲线性一致"的前提下压低延迟、跨地域写为什么注定要付一段延迟。

一些可参考的思路(先自己写)

站位通常是 PC/EC:余额优先正确,分区时宁可少数派侧拒写(PC),健康时也走一致路径(EC),代价是延迟。核心张力在 5.3——强一致写跨地域必须等一个 quorum 往返,这段延迟由光速钉死(呼应 01 章延迟阶梯),无法消除,只能想办法不让读也付这笔钱。压低读延迟而不破线性一致的几条路:① leader lease,leader 在租约内可本地服务读,免去每次读的 quorum 往返(但要防陷阱二的陈旧 leader);② 把余额数据按用户就近放主(地理分区,把多数派副本放在用户所在大区,让写 quorum 往返限制在区域内);③ Spanner 式 TrueTime + commit-wait(09 章),用有界时钟不确定性换全局有序,从而支持低延迟的"快照读"。关键自检:你的"低延迟读"是真线性一致,还是偷偷降级成了 read-your-writes / 因果一致?说清楚降到哪一级、为什么这一级对余额够用或不够用。

参考来源

  • Gilbert & Lynch, Brewer's Conjecture and the Feasibility of Consistent, Available, Partition-Tolerant Web Services(2002,CAP 的形式化证明)。
  • Eric Brewer, CAP Twelve Years Later: How the "Rules" Have Changed(IEEE Computer, 2012)。
  • Daniel Abadi, Consistency Tradeoffs in Modern Distributed Database System Design(PACELC 原文,2012)。
  • Ongaro & Ousterhout, In Search of an Understandable Consensus Algorithm (Raft),raft.github.io。
  • Jepsen, Consistency Models,jepsen.io/consistency;Kleppmann《DDIA》第 9 章(一致性与共识)。