第 18 章 复制与共识:WAL 思想的远距离投射

上一章讲到 fencing token 依赖”一个递增的版本号”。但”让多个节点对同一个版本号达成一致”,本身就是分布式系统最核心、最难的问题——共识(consensus)。

这一章,我们把第 14 章的”日志(WAL)“思想,投射到”多台机器”上。你会发现:

Raft 共识协议的核心,说白了就是”带选举的 WAL 复制”。

单机里,WAL 让”崩溃后能恢复”;分布式里,Raft 让”多台机器对’发生了什么’达成一致”。两者骨子里是同一个东西:日志。


引子:三台机器,怎么保证”它们记住的是同一件事”?

单机里,一个变量、一个文件、一个日志,天然是”一致的”(只有一份)。

但分布式里,你的数据要存”多份”(副本),以保证”一台机器挂了,数据不丢”。于是问题来了:

三台机器,都存了"用户余额 = 100"。

客户端发起"扣款 20",把余额改成 80。

问题:
  1. 这个"扣款 20",要写到几台机器,才算"成功"?
  2. 如果写的过程中,一台机器挂了,怎么办?
  3. 如果两个客户端同时"扣款",各扣 20,三台机器怎么保证"不会扣两次"?

这就是”复制”和”共识”要解决的问题:

  • 复制(replication):数据存多份,一台挂了不丢
  • 共识(consensus):多台机器对”操作的顺序”达成一致

而答案,就藏在一个你可能已经熟悉的东西里:日志(log)。

给你一句总纲:

共识协议(Raft/Paxos)的核心,是让多台机器维护一份”一致的日志”。

每台机器都把”操作”追加到自己的日志里,而共识协议保证:所有机器的日志,前缀是相同的(即使有的机器慢了、挂了、分区了)。

只要日志一致,重放日志得到的状态就一致——这就是”复制状态机”(Replicated State Machine)。


本章路线图

第一幕:复制与共识的基础
  Q1  什么是"复制状态机"?为什么"日志一致 = 状态一致"?
  Q2  为什么"多数派"(quorum)是 ⌊N/2⌋+1,而不是"全部"或"任意一个"?

第二幕:Raft 的核心
  Q3  Raft 是怎么把"共识"拆成"选主 + 日志复制"的?
  Q4  Leader 选举为什么要"随机化退避"?
  Q5  "日志匹配特性"是什么?它为什么是 Raft 正确性的基石?

第三幕:Raft 的难点
  Q6  成员变更(加减节点)为什么最容易出 bug?
  Q7  "读己之写"(read-your-writes)怎么保证?follower 能不能读?

第四幕:工程实战
  Q8  为什么 etcd 集群必须是奇数台?
  Q9  共识的思想,在你的业务里怎么用?

第一幕:复制与共识的基础

Q1 什么是”复制状态机”?为什么”日志一致 = 状态一致”?

复制状态机(Replicated State Machine,RSM):

核心思想:
  1. 每个副本,都从"相同的初始状态"开始
  2. 每个副本,都执行"相同顺序的操作序列"
  3. 因为"确定性"(同样的操作,产生同样的结果),
     所有副本最终达到"相同的状态"

关键:只要"操作序列一致","状态就一致"。

而”操作序列”,就是日志(log)。

日志 = 有序的操作列表:
  [op1: 充值+100, op2: 扣款-20, op3: 充值+50, ...]

每个副本维护这份日志:
  - 副本 1 的日志:[op1, op2, op3]
  - 副本 2 的日志:[op1, op2, op3]   ← 必须相同!
  - 副本 3 的日志:[op1, op2, op3]

重放日志(按顺序执行操作):
  - 副本 1 状态:余额 = 130
  - 副本 2 状态:余额 = 130
  - 副本 3 状态:余额 = 130
  → 状态一致!

所以共识协议的核心问题,变成:

怎么让多个副本的日志”保持一致”(尤其是前缀一致)?

这个问题的难点:
  1. 副本可能挂、可能慢、可能分区
  2. 操作可能乱序到达
  3. 并发操作要"排序"(谁先谁后)

共识协议(Raft/Paxos)就是解决"日志一致性"的算法。

为什么”日志”是核心?

因为第 14 章讲过:日志是”确定性”的载体。

  • 日志是”追加写”的(顺序确定)
  • 日志的每个条目是”确定的操作”(同样的操作,同样的结果)
  • 重放日志 = 确定性推导出状态

所以:只要日志一致,状态就一致。共识 = 让日志一致。

这个”复制状态机”模型,是理解所有分布式一致性系统的钥匙:

数据库的主从复制、etcd 的 Raft、Kafka 的分区、区块链的账本—— 本质都是”维护一份一致的日志,重放得到一致的状态”。

区别只在于:日志的”一致性”用什么协议保证(Raft、Paxos、拜占庭共识等)。

Q2 为什么”多数派”(quorum)是 ⌊N/2⌋+1,而不是”全部”或”任意一个”?

问题:写操作要写到几个副本,才算”成功”?

三个选项:

1. 写到"全部"副本(N 个都写成功):
   优点:一致性最强(所有副本都有最新数据)
   缺点:一台副本挂了,就写不进去(可用性差)

2. 写到"任意一个"副本(1 个写成功):
   优点:可用性最高(写一个就成功)
   缺点:一致性最差(副本可能不一致)

3. 写到"多数派"(⌊N/2⌋+1 个):
   折中:既保证一致性,又保证可用性

为什么多数派是 ⌊N/2⌋+1?

原因一:任意两个多数派,必然有交集。

N=3,多数派 = 2:
  多数派 A = {1, 2},多数派 B = {2, 3}
  → 交集 = {2}(非空!)

N=5,多数派 = 3:
  多数派 A = {1, 2, 3},多数派 B = {3, 4, 5}
  → 交集 = {3}(非空!)

性质:任意两个多数派,至少有一个共同的节点。

这个”交集非空”的性质,保证了什么?

它保证:如果"写"被一个多数派确认,那么"读"(也问多数派)一定能看到这个写。

因为:
  写被多数派 A 确认(A 里的节点都写了)
  读问多数派 B(B 里的节点都回答)
  A 和 B 有交集(至少一个节点)
  → 那个交集节点,既"写了"(在 A 里)又"被读"(在 B 里)
  → 读一定能看到写

原因二:多数派保证”最多一个写者”(互斥)。

如果有两个"领导者"都以为自己能写:
  领导 1 要写,需要多数派 A 同意
  领导 2 要写,需要多数派 B 同意
  A 和 B 有交集 → 那个交集节点,不可能同时"同意领导 1 和领导 2"
  → 最多一个领导能获得多数派 → 最多一个写者

原因三:多数派在”一致性”和”可用性”之间平衡。

全部(N 个):一致性最强,但一个节点挂 = 不可用
任意一个(1 个):可用性最强,但一致性最差
多数派(⌊N/2⌋+1):容忍 ⌊N/2⌋ 个节点挂,同时保证一致性

N=3:容忍 1 个节点挂(2 个多数派)
N=5:容忍 2 个节点挂(3 个多数派)

多数派(quorum)的本质:用”多数”来保证”任意两次操作有交集”,从而保证一致性和互斥。

这个”交集”的思想,是分布式系统最核心的数学基础。

工程启示:

  • 你的系统里,如果”读写”需要”跨多个副本”,用”多数派”保证一致性
  • “多数派”的代价是”写放大”(写一个数据,要写 ⌊N/2⌋+1 个副本)
  • 副本越多,可用性越强(容忍更多挂),但写放大越严重

第二幕:Raft 的核心

Q3 Raft 是怎么把”共识”拆成”选主 + 日志复制”的?

Raft 的设计哲学是”可理解性”(相比 Paxos 的晦涩)。它的核心是把共识拆成两个相对独立的子问题:

Raft 的两个子问题:

1. Leader 选举(Leader Election):
   选出一个"领导者",负责接收客户端请求、协调日志复制。
   任何时候,最多一个 leader。

2. 日志复制(Log Replication):
   leader 把日志条目复制到所有 follower,
   保证"日志一致"。

为什么这样拆分?

因为"共识"(多节点达成一致)很难直接做,
但如果"只有一个 leader"(选主后),
事情就简化了:

  leader 负责"排序"(决定操作的顺序),
  follower 负责"跟随"(复制 leader 的日志)。

这样,共识问题变成了:
  1. 怎么选出一个唯一的 leader(选主)
  2. 怎么让 follower 的日志和 leader 一致(日志复制)

Raft 的角色和状态机:

节点状态:
  1. Follower(跟随者):被动,接收 leader 的日志
  2. Candidate(候选者):竞选 leader
  3. Leader(领导者):主动,处理请求,复制日志

状态转换:
  Follower --超时--> Candidate --赢得多数票--> Leader
  Leader --发现更高任期--> Follower
  Candidate --超时/发现更高任期--> Follower

任期(term):

Raft 用"任期"(term)来区分"哪一任 leader":
  - 任期是递增的整数
  - 每个任期,最多一个 leader
  - 任期越高,权威越高(旧 leader 看到新任期,自动退位)

这类似"美国总统的任期":一届一个总统,新一届上任,旧总统下台。

日志复制的流程:

1. 客户端发请求 → leader
2. leader 把请求作为"日志条目"追加到自己的日志
3. leader 把日志条目发给所有 follower(AppendEntries)
4. follower 收到,追加到自己的日志,回复 leader
5. leader 收到多数派的确认 → 提交(commit)
6. leader 通知 follower 提交,并返回客户端"成功"

关键:日志条目在"多数派确认"后,才算"提交"(committed),
  提交了的日志,保证"不会丢"(即使 leader 挂了)。

Raft 的可理解性设计(这是 Raft 相对 Paxos 的贡献):

把”复杂的共识”拆成”选主 + 日志复制”两个相对简单的部分。

这个”拆分”的价值:

  1. 每个部分相对独立,容易理解
  2. 出问题时,容易定位(是选主的问题,还是复制的问题)
  3. 正确性容易论证

工程启示:面对复杂问题,先”拆”成相对独立的子问题,逐个解决。 这和第 15 章”隔离 vs 共享”、第 2 章”进程 vs 线程”的”职责分离”是同一个思想。

Q4 Leader 选举为什么要”随机化退避”?

问题:leader 挂了,所有 follower 都要竞选,怎么避免”同时竞选”导致”谁也选不上”?

场景:leader 挂了,5 个 follower 都发现"超时了,leader 没了",
  都变成 Candidate,都发起选举。

如果它们"同时"竞选:
  - 每个都投自己一票
  - 每个都向别人要票
  - 但票分散了,谁也凑不够多数派
  - 选举失败,大家都退回 follower,等下次超时

如果下次超时"又同时":
  → 又失败 → 无限循环("活锁"!第 6 章讲过)

解法:随机化退避(randomized election timeout)。

每个 follower 的"选举超时时间"是随机的:
  election_timeout = 150ms ~ 300ms 之间的随机值

这样:
  follower 1 的超时 200ms(最先超时,先发起选举)
  follower 2 的超时 250ms
  follower 3 的超时 280ms

结果:
  follower 1 先超时,变成 Candidate,发起选举
  它拿到多数票(因为其他 follower 还没超时,还是 follower,会投给它)
  → 选举成功,follower 1 成为 leader

关键:随机化让"超时时间错开",
  避免"同时竞选",保证"大概率有一个先竞选并成功"。

为什么是”随机化”,而不是”固定”?

固定超时:如果大家都设 200ms,那还是"同时超时"(同时竞选)
随机超时:超时时间随机分布,大概率"有一个先超时"

随机化退避的本质(第 6 章"活锁"的解药):
  用"随机化"打破"同步",避免"同时撞车"。

随机化退避,是分布式系统里”打破同步、避免活锁”的通用手段:

  • Raft 选举:随机选举超时(避免同时竞选)
  • 重试退避:随机抖动(避免重试风暴,第 16 章)
  • 以太网 CSMA/CD:随机等待(避免冲突后同时重发)
  • 分布式锁的获取:随机重试间隔(避免同时抢锁)

核心思想:当多个参与者”同时”竞争一个资源时,用”随机化”让它们”错开”,避免”同归于尽”。

Q5 “日志匹配特性”是什么?它为什么是 Raft 正确性的基石?

日志匹配特性(Log Matching Property):

如果两个日志,在"某个索引"上的条目有"相同的任期号",
那么这两个日志,在"这个索引之前"的所有条目都完全相同。

这个特性保证了什么?

它保证:follower 的日志,是 leader 日志的"前缀"(prefix)。

即:follower 的日志,不会和 leader"分叉"(出现不同的历史)。

例:
  leader 日志:  [1, 1, 2, 3, 3]  (数字是任期号)
  follower 日志:[1, 1, 2]        (是 leader 的前缀)

  但不会出现:
  follower 日志:[1, 1, 2, 4]     (第 4 个条目任期 4,和 leader 的 3 不同 → 分叉)

为什么这个特性能保证?

因为 Raft 的日志复制机制(AppendEntries):
  1. leader 发日志给 follower 时,带上"前一个条目的索引和任期"
  2. follower 检查自己的"前一个条目"是否匹配
     - 匹配 → 追加新条目
     - 不匹配 → 拒绝,leader 往回退(试更早的索引)

这个"一致性检查",保证了:
  follower 的日志,只会是 leader 的"前缀",
  不会"分叉"。

为什么它是”正确性的基石”?

因为"日志一致"(前缀一致)是"状态一致"的前提(Q1 的复制状态机)。

如果日志会"分叉"(两个副本有不同的历史),
那重放日志得到的状态就不同,一致性就破坏了。

而"日志匹配特性"保证"不分叉",
所以它是 Raft 正确性(日志一致)的基石。

“日志匹配特性”的启示:

保证一致性的关键,是”前缀一致”(历史不冲突)。

这个思想在工程里很普遍:

  • Git 的分支合并:基于”共同的祖先”(前缀)才能合并,分叉了要处理冲突
  • 数据库主从:从库的 binlog 必须是主库的”前缀”(不能分叉)
  • 事件溯源:事件的”顺序”必须一致(前缀一致)

核心:让”多个副本”共享”一致的前缀”,是保证”最终一致”的基础。


第三幕:Raft 的难点

Q6 成员变更(加减节点)为什么最容易出 bug?

成员变更(membership change):集群里加节点、减节点。

例:3 节点集群,要加 1 个节点变 4 节点,或者减 1 个变 2 节点。

为什么最容易出 bug?

问题一:变更过程中,可能出现”两个多数派”。

场景:3 节点 {A, B, C},要变成 5 节点 {A, B, C, D, E}。

如果"直接"改配置(不是原子的):
  某些节点以为还是 3 节点(旧配置,多数派 = 2)
  某些节点以为是 5 节点(新配置,多数派 = 3)

结果:
  旧配置的多数派(2 个)和新配置的多数派(3 个),
  可能"同时"存在两个 leader!
  → 脑裂(split brain)

问题二:变更中途崩溃,状态不一致。

加节点的过程:A、B、C、D、E 要"陆续"更新配置。
如果更新到一半(A、B 更新了,C、D、E 没更新),
leader 挂了:
  → 新旧配置混杂 → 选举时可能选出两个 leader

解法:两阶段成员变更(joint consensus)。

核心思想:不是"从旧配置直接跳到新配置",
而是经过一个"中间配置"(联合共识):

1. 先进入"联合共识"配置(旧 + 新):
   配置 = {旧节点} ∪ {新节点}
   此时,任何决策需要"旧多数派"和"新多数派"都同意

2. 联合共识稳定后,再切换到"纯新配置"

这样保证:
  任何时刻,都只有一个"有效的多数派",
  不会出现"两个多数派"(脑裂)。

为什么”成员变更”这么难?

因为它涉及"共识的共识"——节点要先对"哪些节点是成员"达成共识,
然后才能用这个共识做"日志复制"。

而"改成员"本身,就是一次"共识操作",
如果这个操作"不是原子的",就会在"变更过程"中出现"不一致"。

成员变更的教训:

“改变配置”(成员、规则、拓扑)是最危险的操作之一,因为它会让”正在运行的系统”出现”新旧规则混杂”。

这个思想在工程里普遍适用:

  • 数据库改 schema(在线迁移,新旧 schema 并存)
  • 微服务改接口(灰度发布,新旧接口并存)
  • 配置变更(新旧配置并存)

核心原则:配置变更要”原子”或”两阶段”,避免”新旧混杂”导致的不一致。

Q7 “读己之写”(read-your-writes)怎么保证?follower 能不能读?

读己之写(read-your-writes):

场景:用户提交了"写",紧接着"读",应该读到"自己刚写的"。

例:
  用户改了昵称(写),刷新页面(读),应该看到新昵称。
  如果读到旧昵称,就是"读己之写"被违反了(用户体验很差)。

为什么在复制系统里,“读己之写”会出问题?

因为"写"提交到了 leader(多数派),
但"读"如果打到 follower(副本),follower 可能还没同步到最新数据:

  1. 用户写昵称 → leader 提交(多数派确认)
  2. 用户读昵称 → 打到 follower(还没同步到这个写)
  3. follower 返回旧昵称 → 读己之写被违反

怎么保证”读己之写”?

方法一:读都走 leader(最简单)
  - 写走 leader,读也走 leader
  - 保证读到"最新提交"的数据
  - 代价:leader 成为读的瓶颈

方法二:follower 读,但要"确认同步"
  - 读 follower 前,先确认 follower 已经同步到"写"的那个索引
  - 复杂,但能分担 leader 的读压力

方法三:用"会话"关联读写
  - 客户端记住"我上次写到的索引"
  - 读的时候,要求"读到那个索引之后的数据"
  - 如果 follower 没同步到,就等或者读 leader

follower 能不能读?(这是分布式系统的经典权衡)

能,但要看"一致性要求":

1. 强一致读(读最新):
   follower 读可能读到"旧数据"(没同步到最新)
   → 不满足强一致

2. 最终一致读(读稍旧的数据):
   follower 读,读到"稍旧但最终会一致"的数据
   → 满足最终一致,很多场景够用

所以:
  需要"读己之写"、"强一致"的场景 → 读 leader(或确认同步)
  能容忍"稍旧"的场景 → 可以读 follower(分担压力)

etcd/Raft 的做法:

etcd 默认"读"也要经过 leader(线性一致读),
因为 etcd 用于"配置管理、分布式锁",需要强一致。

具体:follower 收到读请求,转给 leader,
  leader 确认"自己还是 leader"后,才返回读结果。
  (防止读到"过期的 leader"的数据)

“读己之写”和”follower 读”的权衡,本质是”一致性 vs 性能”:

  • 读 leader:强一致,但 leader 压力大
  • 读 follower:性能好,但可能读到旧数据

而”读己之写”是”用户体验”的底线——用户写了自己要能读到。

工程启示:设计读路径时,明确”一致性要求”:

  • 用户自己的数据 → 读己之写(读 leader 或会话关联)
  • 别人的数据 → 可以最终一致(读 follower)
  • 关键数据(余额)→ 强一致(读 leader)

第四幕:工程实战

Q8 为什么 etcd 集群必须是奇数台?

这是一个面试高频题,答案背后是”多数派”的数学。

核心答案:奇数台能”最大化容忍故障数,同时最小化节点数”。

分析:

多数派 = ⌊N/2⌋+1,能容忍 ⌊N/2⌋ 个节点故障。

N=3:多数派=2,容忍 1 个故障
N=4:多数派=3,容忍 1 个故障
N=5:多数派=3,容忍 2 个故障
N=6:多数派=4,容忍 2 个故障

看出来了吗?
  N=3 和 N=4 的容错能力一样(都是容忍 1 个)!
  N=5 和 N=6 的容错能力一样(都是容忍 2 个)!

而偶数台(N=4)比奇数台(N=3)多了一台机器,
但容错能力没有提升 → 浪费!

所以:

奇数台(3、5、7)能最大化"容错/成本"比:
  - 3 台:容忍 1 故障(推荐的最小规模)
  - 5 台:容忍 2 故障
  - 7 台:容忍 3 故障

偶数台(4、6)是"浪费":
  - 4 台:容忍 1 故障(和 3 台一样,多一台白搭)

一个常见的误区:

"4 台是不是比 3 台更可靠?"

答案是:容错能力一样(都容忍 1 个故障),
但 4 台多了一台机器(成本更高),
而且 4 台的"多数派"是 3,写操作要写 3 个副本(比 3 台的 2 个更慢)。

所以 4 台是"更贵、更慢、但容错一样"的糟糕选择。

但注意一个边界情况:

3 台容忍 1 个故障,但"故障期间"如果"又坏一台",
就是 2 台故障,多数派(2)凑不齐 → 集群不可用。

所以生产上:
  - 高可用要求 → 5 台(容忍 2 故障,且故障 1 台后还能容忍 1 台)
  - 一般要求 → 3 台(容忍 1 故障)
  - 注意:3 台集群,坏 1 台后,要"尽快修复/补充",否则再坏 1 台就不可用

奇数台的启示:

“多数派”决定了”偶数台是浪费”。这是一个简单的数学事实,但很多人凭直觉会选错。

工程启示:设计任何”多数派”系统(副本数、仲裁节点), 先算”多数派 = ⌊N/2⌋+1”和”容错 = ⌊N/2⌋”, 然后选”最小满足容错要求的奇数”。

Q9 共识的思想,在你的业务里怎么用?

这一章的落地总结,把”共识”从”分布式系统的基础设施”变成”业务开发的思维工具”。

共识的三种落地形态:

1. 直接用共识系统(最常见):
   - 配置管理用 etcd
   - 服务发现用 Consul
   - 协调用 ZooKeeper
   → 你不需要自己实现共识,用现成的

2. 理解共识的"边界":
   - 知道 etcd 能保证什么(线性一致读、事务)
   - 知道 etcd 不能保证什么(性能有限、要奇数台)
   - 正确地用,而不是"过度依赖"

3. 把共识的"思想"用在别处:
   - "多数派"思想:跨副本读写用多数派保证一致
   - "日志复制"思想:用 WAL + 复制保证数据可靠(第 14 章)
   - "选主"思想:主节点 + 心跳 + 租约(第 17 章)

共识系统的正确用法(etcd 为例):

1. 存"关键的小数据":
   - 配置、锁、选举、服务发现
   - 不适合存"大数据"(共识开销大)

2. 利用"线性一致":
   - 分布式锁(第 17 章)
   - 选主
   - 配置的"强一致读"

3. 利用"watch":
   - 配置变更通知(事件驱动,第 8 章)
   - 服务上下线通知

4. 不要滥用:
   - 高频写(共识慢,不适合高 QPS 写)
   - 大数据(共识存储贵)

一个关键认知(共识的代价):

共识是有代价的:
  - 写要多数派确认(写放大)
  - 选主要等超时(leader 挂了,选举要几百毫秒)
  - 网络往返多(慢)

所以共识适合"关键但低频"的操作(配置、锁、选举),
不适合"高频"的操作(业务数据读写)。

业务数据的高可用,通常用"主从复制"(牺牲一致性)或"分片"(牺牲全局一致),
而不是"共识"(太贵)。

这一章的最终落脚点:

共识(Raft/Paxos)的本质,是把第 14 章的”日志”思想,从”单机”投射到”多机”:

  • 单机 WAL:一份日志,崩溃后重放恢复
  • 分布式共识:多份日志,保证”前缀一致”,重放得到一致状态

而”日志一致”的关键,是”多数派”(保证交集)和”选主”(保证唯一写者)。

理解了这个”日志 + 多数派 + 选主”的三件套,你就理解了分布式一致性的核心。


思考题

思考题 1

面试官问:“为什么 etcd(Raft)集群推荐奇数个节点?3 个节点能容忍几个故障?“请回答,并说明”多数派”的数学原理。

参考答案

核心答案:奇数个节点能最大化”容错/成本”比,因为偶数个节点的容错能力和”少一个的奇数”一样(多数派决定)。3 节点容忍 1 个故障。

多数派的数学:

多数派 = ⌊N/2⌋ + 1
容错数 = ⌊N/2⌋

N=1:多数派=1,容忍 0
N=2:多数派=2,容忍 0(2 个都要活着)
N=3:多数派=2,容忍 1
N=4:多数派=3,容忍 1
N=5:多数派=3,容忍 2
N=6:多数派=4,容忍 2

关键观察:

N=3 和 N=4 的容错数一样(都是 1)
N=5 和 N=6 的容错数一样(都是 2)

所以:
  - 4 台比 3 台多了一台机器,但容错能力没提升 → 浪费
  - 6 台比 5 台多了一台,容错一样 → 浪费

偶数台 = 浪费(多一台,容错不提升,还更贵更慢)

3 节点能容忍几个故障:

3 节点,多数派 = 2,能容忍 1 个故障。

但注意:
  - 坏 1 台后,集群还能工作(剩 2 台,多数派 2 个刚好够)
  - 但"坏 1 台"期间,如果"再坏 1 台"(剩 1 台),
    多数派(2)凑不齐 → 集群不可用
  - 所以 3 节点是"容忍 1 个故障,但故障后要尽快修复"

为什么多数派能容忍 ⌊N/2⌋ 个故障:

因为"写"只需要多数派确认(⌊N/2⌋+1 个),
只要"存活的节点" >= 多数派,就能继续读写。

N=3,多数派 2:
  坏 1 台(剩 2 台),2 >= 2,还能读写 ✓
  坏 2 台(剩 1 台),1 < 2,不能读写 ✗

为什么”偶数台是浪费”(更深入):

1. 容错不提升(N=4 和 N=3 都是容忍 1 个)
2. 成本更高(多一台机器)
3. 写更慢(N=4 的多数派是 3,写要写 3 个副本,比 N=3 的 2 个慢)
4. 没有收益

所以生产上只用奇数:3、5、7。

追问:为什么最小是 3,而不是 1 或 2?

N=1:没有容错(这台挂了就全完了)
N=2:多数派=2,容忍 0 个故障(两台都要活着,一台挂了就不可用)

所以最小"能容错"的是 3 台(容忍 1 个故障)。

这道题考察什么:

  1. 知不知道”多数派 = ⌊N/2⌋+1”
  2. 知不知道”偶数台容错不提升”这个反直觉结论
  3. 知不知道 3 节点容忍 1 个故障
  4. 能不能说清”为什么多数派能容忍故障”

思考题 2

Raft 的 leader 选举为什么要”随机化超时”?如果不随机(固定超时),会出什么问题?请用”活锁”的概念解释。

参考答案

核心答案:随机化超时是为了避免”所有节点同时竞选”导致的”谁也选不上”的活锁。固定超时会导致同步竞选,反复失败。

先看”固定超时”的问题:

假设 3 个节点,leader 挂了,所有 follower 都等"固定"的超时(比如 200ms):

1. 3 个 follower 都在 200ms 时超时
2. 都变成 Candidate,都发起选举
3. 每个 Candidate 都投自己一票
4. 向别人要票,但票分散了(3 个 Candidate 各 1 票)
5. 谁都凑不够多数派(2 票)
6. 选举失败,都退回 follower,等下一个超时

如果下一个超时还是 200ms:
  → 又同时超时 → 又同时竞选 → 又失败
  → 无限循环!永远选不出 leader

这就是”活锁”(第 6 章):

所有节点都在"积极竞选"(没有阻塞),
但因为"步调一致"(同时超时),
每次竞选都"撞在一起",谁也选不上。

活锁的本质:参与者都在动,但没有进展。

随机化超时怎么解决:

每个 follower 的超时时间是"随机"的(比如 150ms ~ 300ms):

  节点 1:超时 180ms(最早)
  节点 2:超时 240ms
  节点 3:超时 290ms

流程:
  1. 节点 1 在 180ms 超时,变成 Candidate,发起选举
  2. 节点 2、3 还没超时(还是 follower),
     收到节点 1 的投票请求,投给它(因为节点 1 的任期新)
  3. 节点 1 拿到 3 票(多数派),成为 leader
  4. 选举成功!

关键:随机化让"超时时间错开",
  保证"大概率有一个节点先超时、先竞选、先成功"。

为什么”随机化”能打破活锁:

活锁的根源是"同步"(所有节点步调一致)。
随机化打破了"同步",让节点"错开",
于是"有一个先行动"的概率大增,选举就能成功。

(这和第 16 章"重试的随机抖动"、第 6 章"活锁的退避"是同一个思想)

一个细节:随机化不是”绝对避免”,是”概率性避免”。

理论上,随机超时仍可能"碰巧"两个节点同时超时(概率很低)。
但:
  1. 概率很低(超时区间越宽,同时超时的概率越低)
  2. 即使碰巧同时超时,下次超时又随机,大概率错开
  → 最终一定能选出 leader(期望时间内)

这道题考察什么:

  1. 知不知道”固定超时导致同步竞选”的问题
  2. 知不知道”活锁”的概念(都在动,但没进展)
  3. 知不知道”随机化打破同步”这个解药
  4. 能不能联系到”重试抖动”、“CSMA/CD 随机等待”这些同类思想

思考题 3

你的系统用了”主从复制”(MySQL 主从、Redis 主从),主库写,从库读。现在出现”用户改了自己的资料,马上刷新,看到的是旧资料”的问题。请分析原因,并给出解决方案。

参考答案

核心答案:这是”读己之写”问题——写到了主库,但读打到了还没同步的从库,读到旧数据。解决方案取决于”一致性要求”。

原因分析:

主从复制的架构:
  写 → 主库
  读 → 从库(分担压力)

问题:
  1. 用户改昵称 → 写到主库
  2. 主库异步同步到从库(有延迟,比如几十毫秒)
  3. 用户刷新 → 读打到从库
  4. 从库还没同步 → 返回旧昵称
  → 读己之写被违反

解决方案(按一致性要求递增):

方案一:读写都走主库(最简单,强一致)。

用户自己的数据(改昵称、改资料),读写都走主库。
代价:主库压力大,但保证"读己之写"。

适用:一致性要求高的场景

方案二:会话粘性(session stickiness)。

同一个用户的请求,路由到"同一个从库":
  - 写后,把"写的主库的 binlog 位置"记在会话里
  - 读时,要求"读到那个位置之后的数据"
  - 如果从库没同步到,就等,或者读主库

复杂,但能兼顾"从库读 + 读己之写"。

方案三:写后短时间读主库。

写操作后,短时间内(比如 1 秒)读主库,
1 秒后(从库大概率同步了)读从库。

简单,但"1 秒"这个阈值是拍脑袋,可能不准。

方案四:用”版本号”判断。

读从库时,带上"用户上次写时的版本号/时间戳":
  - 从库返回的数据,如果版本号 < 用户写的版本号,说明没同步
  - 此时读主库(或重试)

精确,但需要"版本号"机制。

方案五:接受最终一致(如果业务能容忍)。

如果"读到旧资料"影响不大(比如延迟 1 秒看到新昵称,可接受),
就不处理(接受最终一致)。

关键:评估"读己之写"违反的代价:
  - 用户改昵称,晚 1 秒看到 → 可接受
  - 用户改密码,马上登录失败 → 不可接受

一个更广的视角:

"读己之写"是分布式系统的经典问题,本质是"复制延迟":
  主从复制、多副本、缓存,都有"写后读"的延迟窗口。

解法都是"写后读走权威副本(主库/主节点)",或"会话关联"。

我的推荐(务实的回答):

1. 先评估"哪些场景要求读己之写":
   - 用户改自己的资料 → 要求读己之写(否则体验差)
   - 看别人的资料 → 可以最终一致

2. 对"要求读己之写"的场景:
   - 简单方案:读写都走主库(如果主库扛得住)
   - 复杂方案:会话关联(写后读主,或版本号判断)

3. 对"可容忍最终一致"的场景:
   - 读从库(分担压力)

这道题考察什么:

  1. 知不知道”读己之写”问题
  2. 知不知道”主从延迟”导致”读到旧数据”
  3. 能不能根据”一致性要求”选择方案
  4. 知不知道”评估代价”(不是所有场景都要求强一致)

思考题 4(开放题)

“单机 WAL 和分布式 Raft,本质是同一个东西。“你认同吗?请从”日志、重放、一致性、多数派”几个角度,说明它们的对应关系,以及”单机”和”分布式”带来的本质差异。

参考答案

开放题,考察你能不能把”单机日志”和”分布式共识”抽象成统一的思想。

认同,但有重要的”本质差异”。

相同点:都围绕”日志”。

单机 WAL(第 14 章):
  核心:一份日志,记录"操作序列"
  崩溃恢复:重放日志
  一致性:单机天然一致(只有一份)

分布式 Raft:
  核心:多份日志,记录"操作序列"
  共识:让多份日志"前缀一致"
  一致性:通过多数派保证

共同:都是"日志 = 操作序列 = 状态的推导过程"

对应关系:

概念单机 WAL分布式 Raft
日志WAL 文件Raft 日志(每个节点一份)
重放崩溃后重放副本重放日志
一致性单份天然一致多数派保证一致
提交fsync 落盘 = 提交多数派确认 = 提交
失败进程崩溃节点崩溃、分区
恢复重放 redo/undo新 leader 复制日志

关键的对应:

1. "fsync 落盘" ↔ "多数派确认":
   单机:日志 fsync 到磁盘 = 持久化(崩溃不丢)
   分布式:日志复制到多数派 = 持久化(节点挂不丢)

2. "redo 重放" ↔ "follower 追日志":
   单机:崩溃后重放 redo,补上没写完的
   分布式:新 leader 复制日志,follower 追上

3. "commit 分水岭" ↔ "多数派提交":
   单机:commit 落盘 = 事务生效
   分布式:多数派确认 = 日志提交

本质差异(单机 vs 分布式带来的):

差异一:一致性从"天然"变成"协议保证"。

  单机:日志只有一份,天然一致(没有"不一致"的可能)
  分布式:日志有多份,可能"分叉",
    需要"协议"(Raft)来保证"前缀一致"

差异二:失败从"崩溃"变成"崩溃 + 分区 + 消息丢失"。

  单机:失败 = 进程崩溃(简单,重启重放即可)
  分布式:失败 = 节点崩溃、网络分区、消息乱序
    (复杂,需要选主、多数派、心跳)

差异三:时间从"共享"变成"漂移"。

  单机:共享时钟(过期、超时都准确)
  分布式:时钟漂移(过期、超时都不可靠,第 17 章)

一个统一的视角:

WAL 和 Raft,本质都是”用日志来管理’状态的变化历史’,从而获得’可恢复、可一致’的能力”。

区别只在于:

  • 单机:日志一份,一致性天然
  • 分布式:日志多份,一致性靠协议(多数派 + 选主)

而”分布式”带来的所有复杂性(选主、多数派、心跳、分区处理), 本质上都是因为”日志有了一份以上,且节点不可靠”。

这道题的价值:

它让你看到,“单机”和”分布式”不是两套无关的知识,而是”同一个思想”在不同约束下的展开:

  • 单机:约束简单(一份日志、进程崩溃、共享时钟)→ 解法简单(WAL)
  • 分布式:约束复杂(多份日志、节点分区、时钟漂移)→ 解法复杂(共识协议)

理解了”单机的 WAL”,你就理解了”分布式共识”的”简化版”; 理解了”分布式的复杂性从何而来”(多份 + 不可靠), 你就能在”单机”和”分布式”之间自由切换思维。

这就是这本书贯穿始终的主题:抽象出本质,就能举一反三。


一页纸总结

┌──────────────────────────────────────────────────────────────┐
│  第 18 章 · 复制与共识:WAL 思想的远距离投射                      │
├──────────────────────────────────────────────────────────────┤
│                                                              │
│  【复制状态机】                                                 │
│    相同初始状态 + 相同操作序列 = 相同状态                         │
│    日志一致 = 状态一致(共识 = 让日志一致)                       │
│                                                              │
│  【多数派 quorum】                                              │
│    多数派 = ⌊N/2⌋+1,容忍 ⌊N/2⌋ 个故障                          │
│    任意两个多数派必有交集 → 保证一致性 + 互斥                      │
│    偶数台容错不提升(浪费)→ 用奇数台(3/5/7)                    │
│                                                              │
│  【Raft 的拆分】                                                │
│    把共识拆成"选主 + 日志复制"(可理解性设计)                    │
│    选主:随机化超时(避免同步竞选 = 活锁)                        │
│    复制:AppendEntries + 一致性检查(保证前缀一致)               │
│                                                              │
│  【日志匹配特性】                                               │
│    同索引同任期 → 之前全部相同(follower 是 leader 的前缀)        │
│    是"日志一致"的基石                                           │
│                                                              │
│  【难点】                                                      │
│    成员变更:可能出现"两个多数派"(脑裂)→ 两阶段(joint consensus)│
│    读己之写:读 follower 可能读到旧数据 → 读 leader 或会话关联      │
│                                                              │
│  【WAL ↔ Raft 的对应】                                         │
│    fsync 落盘 ↔ 多数派确认;redo 重放 ↔ follower 追日志          │
│    单机:一份日志天然一致;分布式:多份日志靠协议一致                │
│                                                              │
│  【一句话】                                                     │
│    共识 = 让多份日志"前缀一致"(多数派 + 选主);                   │
│    分布式的一切复杂性,都来自"日志有了一份以上,且节点不可靠"。     │
│                                                              │
│  【下一章预告】                                                  │
│    共识解决"多机一致",但还有"隔离"的多机版本——                   │
│    下一章讲虚拟化与沙箱:抽象层再叠一层,VM、WASM、gVisor。        │
└──────────────────────────────────────────────────────────────┘