第 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 的贡献):
把”复杂的共识”拆成”选主 + 日志复制”两个相对简单的部分。
这个”拆分”的价值:
- 每个部分相对独立,容易理解
- 出问题时,容易定位(是选主的问题,还是复制的问题)
- 正确性容易论证
工程启示:面对复杂问题,先”拆”成相对独立的子问题,逐个解决。 这和第 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 个故障)。
这道题考察什么:
- 知不知道”多数派 = ⌊N/2⌋+1”
- 知不知道”偶数台容错不提升”这个反直觉结论
- 知不知道 3 节点容忍 1 个故障
- 能不能说清”为什么多数派能容忍故障”
思考题 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(期望时间内)
这道题考察什么:
- 知不知道”固定超时导致同步竞选”的问题
- 知不知道”活锁”的概念(都在动,但没进展)
- 知不知道”随机化打破同步”这个解药
- 能不能联系到”重试抖动”、“CSMA/CD 随机等待”这些同类思想
思考题 3
你的系统用了”主从复制”(MySQL 主从、Redis 主从),主库写,从库读。现在出现”用户改了自己的资料,马上刷新,看到的是旧资料”的问题。请分析原因,并给出解决方案。
参考答案
核心答案:这是”读己之写”问题——写到了主库,但读打到了还没同步的从库,读到旧数据。解决方案取决于”一致性要求”。
原因分析:
主从复制的架构:
写 → 主库
读 → 从库(分担压力)
问题:
1. 用户改昵称 → 写到主库
2. 主库异步同步到从库(有延迟,比如几十毫秒)
3. 用户刷新 → 读打到从库
4. 从库还没同步 → 返回旧昵称
→ 读己之写被违反
解决方案(按一致性要求递增):
方案一:读写都走主库(最简单,强一致)。
用户自己的数据(改昵称、改资料),读写都走主库。
代价:主库压力大,但保证"读己之写"。
适用:一致性要求高的场景
方案二:会话粘性(session stickiness)。
同一个用户的请求,路由到"同一个从库":
- 写后,把"写的主库的 binlog 位置"记在会话里
- 读时,要求"读到那个位置之后的数据"
- 如果从库没同步到,就等,或者读主库
复杂,但能兼顾"从库读 + 读己之写"。
方案三:写后短时间读主库。
写操作后,短时间内(比如 1 秒)读主库,
1 秒后(从库大概率同步了)读从库。
简单,但"1 秒"这个阈值是拍脑袋,可能不准。
方案四:用”版本号”判断。
读从库时,带上"用户上次写时的版本号/时间戳":
- 从库返回的数据,如果版本号 < 用户写的版本号,说明没同步
- 此时读主库(或重试)
精确,但需要"版本号"机制。
方案五:接受最终一致(如果业务能容忍)。
如果"读到旧资料"影响不大(比如延迟 1 秒看到新昵称,可接受),
就不处理(接受最终一致)。
关键:评估"读己之写"违反的代价:
- 用户改昵称,晚 1 秒看到 → 可接受
- 用户改密码,马上登录失败 → 不可接受
一个更广的视角:
"读己之写"是分布式系统的经典问题,本质是"复制延迟":
主从复制、多副本、缓存,都有"写后读"的延迟窗口。
解法都是"写后读走权威副本(主库/主节点)",或"会话关联"。
我的推荐(务实的回答):
1. 先评估"哪些场景要求读己之写":
- 用户改自己的资料 → 要求读己之写(否则体验差)
- 看别人的资料 → 可以最终一致
2. 对"要求读己之写"的场景:
- 简单方案:读写都走主库(如果主库扛得住)
- 复杂方案:会话关联(写后读主,或版本号判断)
3. 对"可容忍最终一致"的场景:
- 读从库(分担压力)
这道题考察什么:
- 知不知道”读己之写”问题
- 知不知道”主从延迟”导致”读到旧数据”
- 能不能根据”一致性要求”选择方案
- 知不知道”评估代价”(不是所有场景都要求强一致)
思考题 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。 │
└──────────────────────────────────────────────────────────────┘