5 共识:Paxos 与 Raft
共识到底要保证哪三条性质?Paxos 的 prepare/promise/accept/accepted 怎样防止两个值都被选中?Raft 的 term、选举限制、日志匹配和提交规则分别堵住了哪个洞?分区之后旧 leader 还能读、还能写吗?ReadIndex、lease read、follower read 各自押了什么假设?提案重试为什么会在状态机里留下两条命令?
能画出 Paxos 两阶段和 Raft 选举/复制的时序;能用「当前 term 提交」规则判断一段 Raft 实现是否安全;能设计线性一致读路径并说清它依赖不依赖时钟;能从 etcd 指标里区分 leader 抖动、apply 滞后和磁盘瓶颈;能在状态机里做请求去重,避免提案重试的重复副作用。
一个 5 节点的配置中心,leader 和一个 follower 在机房 A,另外三个节点在机房 B。A 与 B 之间的链路抖动了 8 秒。A 上的客户端仍在写配置,请求超时;B 上的三个节点选出新 leader,提交了新配置。链路恢复后,客户端把超时的请求重发到新 leader,配置版本又增加了一次。没有节点崩溃,没有磁盘损坏,也没有人改错配置,但「谁有权决定下一个值」这个问题没有被正确回答。
第 3 章讲了复制:把日志从一台机器搬到另一台。第 4 章讲了一致性:读到的值能不能满足线性一致。这两章留下同一个空洞:当多个副本都能接受写、而网络又随时可能分区时,凭什么只有一个顺序? 复制负责「多存几份」,一致性负责「读到的算不算数」,共识负责中间那件事——让一组进程对「下一个值是什么、排在第几位」达成一个不会反悔的决定。
共识是分布式系统里最像「地基」的部分。etcd、Consul、ZooKeeper、TiKV、CockroachDB、Kafka 的 KRaft 控制器,乃至 Kubernetes 的元数据,最终都要落到一个共识日志上。但共识只保证日志的顺序和持久性,不保证业务只执行一次,也不保证读一定线性一致;这两件事分别要靠第 5.12 节的去重和第 5.8 节的读路径。把边界划清楚,比背下 Raft 的几个 RPC 更重要。
本章路线:5.1 定义问题和状态机复制;5.2 讲 FLP、故障模型与 quorum;5.3–5.4 用 Paxos 建立「提案号 + 多数派」的安全直觉,并看它为什么会活锁;5.5–5.8 讲 Raft 的选举、日志、成员变更、快照和三种读路径;5.9–5.13 进入工程:旧主分区、etcd 观测、Jepsen 案例、提案重试去重、自己实现 Raft 的五个坑;5.14–5.15 收束成陷阱清单和出口检查。
5.1 共识问题:协议要保证什么
先把「共识」和「复制」分开。假设三个副本 S1、S2、S3,客户端先后发了两条命令:x=1、x=2。如果 S1 先收到 x=1 再收到 x=2,S2 先收到 x=2 再收到 x=1,两个副本的最终状态可能都是 x=2,但中间状态和读到的历史不同;如果命令是 append("a")、append("b"),最终列表的顺序就会分叉。复制只保证字节到了,共识要保证所有副本按同一个顺序执行。
一个共识实例(consensus instance)通常这样描述:有 N 个进程,每个进程可以提议(propose)一个值;协议结束后,每个正确的进程要选择一个值(decide),并满足三条性质。
| 性质 | 含义 | 被违反时会发生什么 |
|---|---|---|
| 一致性(agreement) | 没有两个正确的进程选择不同的值 | 两个客户端看到不同的「已提交」结果,无法合并 |
| 有效性(validity) | 被选择的值必须是某个进程提议过的值 | 协议可以永远输出一个谁都没提过的默认值,毫无意义 |
| 终止性(termination) | 每个正确的进程最终都能选择一个值 | 系统永远不返回,客户端只能超时 |
有些教材还会加一条完整性(integrity):每个进程最多选择一次,且选择后不能改。它通常由实现保证,不单独列为共识的核心性质。工程上还要区分「安全性质」和「活性性质」:agreement、validity、integrity 是安全性质,违反一次就不可挽回;termination 是活性性质,可以暂时不满足,等网络恢复后再满足。这个区分是理解 FLP 和所有超时机制的关键。
共识:一组进程对某个值达成一致,满足 agreement、validity、termination。状态机复制(state machine replication,SMR):把客户端命令写进一个有序日志,每个副本按相同顺序把命令应用到确定性状态机;只要日志顺序一致、命令确定,所有副本的状态就一致。
SMR 把「一次共识」扩展成「一串共识」:日志的第 1 槽、第 2 槽、第 3 槽各是一次共识实例。Paxos 用槽位(slot)组织,Raft 用连续的日志索引组织。共识决定顺序,状态机决定语义;两者之间还隔着去重和读路径。
为什么需要有效性?如果没有它,协议可以规定「所有进程直接决定 0」,agreement 和 termination 都满足,但客户端写的 x=1 永远不会生效。有效性把共识和「随便选一个默认值」区分开。为什么需要终止性?如果协议永远不决定,客户端只能超时,然后重试;重试又会引入新的提案,系统可能永远在「正在达成共识」的路上。
状态机复制的最小例子:
客户端 日志(共识决定顺序) 状态机
set x=1 → [1: set x=1] [2: set x=2] → S1: x=2
set x=2 → 同一顺序 → S2: x=2
S3: x=2
这张图里有三个容易忽略的前提:
- 命令必须是确定性的:如果命令里有
now()、随机数、自增序列,不同副本执行同一条命令可能得到不同结果;顺序一致也会状态分叉。把非确定性结果在提案前固化(客户端给时间戳/ID),或者让 leader 把结果写进日志。 - 日志必须是连续的:状态机只能按 1、2、3 的顺序 apply;中间缺一个槽位,后面的命令即使已经选中也不能 apply。Multi-Paxos 用 no-op 填洞,Raft 用连续索引 + commitIndex 避免洞。
- 共识的对象是命令,不是最终状态:不要试图让副本对「当前余额」达成一致,而要让它们对「扣款命令」达成一致;状态由确定性 apply 推导出来。
这也是为什么「共识」和「复制」不能互相替代:复制可以把命令搬到所有副本,但只有共识能保证它们按同一个顺序 apply。
如果日志顺序变成 [set x=2, set x=1],三个副本仍然一致,但客户端看到的最终值是 x=1,与它以为的「后写的赢」相反。共识不保证「后写的赢」,只保证「大家按同一个顺序赢」;哪个顺序算「后」,由协议决定的日志位置说了算,不由墙钟说了算。
这里有一个常见的概念混淆:把「多数派都有这条日志」当成「这条日志已经提交」。多数派有,只说明它有机会被提交;如果 leader 在提交前崩溃,后续 leader 仍可能覆盖它(第 5.6 节会讲 Raft 的 Figure 8 反例)。提交是一个协议动作,不是一次磁盘计数的观察。
另一个混淆是把共识当成「所有节点都同意」。共识只要求正确的、参与决策的多数派同意;一个暂时失联的 follower 可以落后,之后通过日志复制追上来。要求所有节点都同意,会让任何一台机器故障都阻塞整个系统,这不是共识,是「全体一致」的陷阱。
| 维度 | 复制(第 3 章) | 共识(本章) |
|---|---|---|
| 解决的问题 | 数据放在几个副本上 | 哪个值/哪条命令排在哪个位置 |
| 典型机制 | 主从、多主、无主、quorum | Paxos、Raft、Zab |
| 安全边界 | 复制 lag、冲突、双主 | 两个 leader、两个值、丢已提交日志 |
| 失败后果 | 读到旧值、写冲突 | 已确认的写丢失、状态机分叉 |
| 与读的关系 | 决定副本是否新 | 决定「已提交」的边界 |
最后强调本章的故障模型:崩溃-恢复(crash-recovery)+ 网络分区 + 部分同步(partial synchrony)。我们不考虑拜占庭节点(会撒谎、会伪造消息),也不考虑磁盘静默损坏。拜占庭共识需要 3f+1 个节点和签名等额外机制,是另一个话题;把崩溃模型下的结论套到拜占庭环境,会低估所需副本数。
5.2 FLP、故障模型与 quorum
初学者常问:「既然异步网络里消息可能丢、可能慢,那共识是不是不可能?」答案是:在完全异步、确定性算法、允许一个进程崩溃的模型下,要求所有正确进程都终止,确实不可能。 这就是 FLP 不可能性定理(Fischer、Lynch、Paterson,1985)。
在一个完全异步的分布式系统中,即使消息可靠、网络最终会送达,只要允许一个进程崩溃,且算法是确定性的,就不存在一个共识协议能同时保证 agreement、validity 和 termination。
注意限定词:完全异步、确定性、一个崩溃、必须终止。少一个限定词,结论就可能不同;多一个「部分同步」假设,工程上就有活路。
FLP 的直觉不复杂:在异步模型里,你无法区分「进程已经崩溃」和「进程只是极慢」。假设协议在某一步必须等某个进程的消息才能决定;调度器可以一直延迟这个消息,让协议停在「还差一步」的状态。如果协议为了终止而选择不等,它又可能在一个「其实还活着、只是消息晚到」的场景里做出错误决定。调度器可以把系统在两种几乎相同的全局状态之间来回摆,让任何确定性协议都无法安全地打破平局。
这不是说「共识不能做」。它说的是:安全性和活性不能同时无条件保证。工程系统的选择是:安全性质永远不妥协(绝不出现两个值、绝不丢已提交日志),活性性质只在「网络最终稳定、时钟漂移有界」的假设下保证。超时、随机化、leader 选举,都是在这个边界上做工程。
| 故障模型 | 进程会怎样 | 网络会怎样 | 本章是否覆盖 |
|---|---|---|---|
| 崩溃-停止(crash-stop) | 崩溃后不再恢复 | 可靠或可能丢 | 是(简化) |
| 崩溃-恢复(crash-recovery) | 崩溃后重启,内存丢失、磁盘保留 | 可能丢、可能分区 | 是(主模型) |
| 遗漏(omission) | 可能漏发/漏收消息 | 可能丢 | 是(分区的一种) |
| 网络分区 | 进程都活着,但互相不可达 | 双向/单向/非传递 | 是 |
| 时钟故障 | 时钟跳变、漂移 | 正常 | 是(lease read 的重点) |
| 拜占庭 | 任意撒谎、伪造 | 任意 | 否 |
超时在共识里扮演什么角色?它不是「证明对方死了」,而是一个不完美的故障检测器:在部分同步假设下,超时最终能发现真正崩溃的进程,但也会误判「只是慢」的进程。误判的代价是多余的选举,不是安全性的丧失——只要投票规则和 quorum 规则不被绕过。把超时当证据,是很多实现错误的起点;把超时当怀疑,才是正确用法。
quorum 是共识的另一个支柱。大多数协议用多数派(majority):N 个节点中需要 floor(N/2)+1 个。为什么是多数派?因为任意两个多数派必然相交。
| N | 多数派大小 | 能容忍的崩溃数 f | 备注 |
|---|---|---|---|
| 1 | 1 | 0 | 没有容错,退化成本地日志 |
| 2 | 2 | 0 | 任何一台挂掉都无法形成多数派 |
| 3 | 2 | 1 | 最常见的入门配置 |
| 4 | 3 | 1 | 成本比 3 节点高,容错没变 |
| 5 | 3 | 2 | 生产常见;可跨三个可用区 |
| 6 | 4 | 2 | 仍然只容忍 2,成本更高 |
| 7 | 4 | 3 | 跨五个可用区时才会考虑 |
把相交性写成计数:N=5、quorum=3,两个 quorum 的最小并集是 3+3-5=1,所以至少共享 1 个节点;N=4、如果误用 quorum=2,两个 2 的集合可以是 {A,B} 和 {C,D},交集为 0。quorum 的大小不是「看起来过半数」,而是由「任意两个集合必须相交」反推出来的。
加权 quorum 也一样:假设 A、B、C 各 2 票,D、E 各 1 票,总权重 10,quorum 设为 6;任意两个 6 票的集合在 10 票里必然相交。加权 quorum 常用于跨机房:把更多权重放在核心机房,牺牲一点容错换更低的跨机房 RTT。但权重配置本身也要通过共识变更,不能两边各算一套。
两个多数派相交,意味着「上一次决定的证据」至少会被一个节点带到下一次投票里。Paxos 的 promise 里带着已接受的值,Raft 的投票者会比较候选人的日志,都是同一件事:让相交的那个节点成为信息的传递者。如果 quorum 不相交,两个 leader 可以各自拿到「多数」,却没有任何节点知道对方已经决定过什么,安全性就无从谈起。
quorum 不是「超过一半的人举手」,而是「任意两个决策集合一定有一个共同的见证者」。多数派只是最容易实现的一种 quorum;加权 quorum、分层 quorum 也可以,只要满足相交性。
两个常见误区:
- 「N=4 时 quorum=2」:2 不是多数派,两个 2 的集合可以不相交({A,B} 和 {C,D});N=4 的多数派是 3。
- 「quorum 只看节点数,不看配置」:成员变更时,旧配置的多数派和新配置的多数派可能不相交;必须用联合共识(5.7)或一次只改一个节点。
还有一个容易被忽略的细节:quorum 是相对于当前配置的。如果集群从 3 节点扩到 5 节点,而某个节点还用旧配置计算多数派,它可能和一个新配置的多数派做出不同的决定。配置本身也必须通过共识来变更,不能靠人工改配置文件然后重启。
5.3 Paxos:prepare/promise/accept/accepted
Paxos 是共识协议里最经典的一个。它不要求稳定的 leader,也不要求全局时钟;它只要求提案号(ballot)全序和多数派相交。理解 Paxos 的两阶段,是理解 Raft 为什么这样设计的最好铺垫。
Paxos 里有三种角色,一个节点可以同时扮演多种:
| 角色 | 职责 | 关键状态 |
|---|---|---|
| Proposer | 提出提案号 n 和值 v,推动两阶段 | 当前 ballot、待提议的值 |
| Acceptor | 对提案做出 promise/accept 的承诺 | promised、accepted_ballot、accepted_value |
| Learner | 学习已经被选中的值 | 已学习的值、日志槽位 |
一次单值 Paxos 的消息流:
Proposer Acceptor A1 A2 A3 A4 A5
│ prepare(n=10) │ │ │ │ │
├──────────────────────────▶│────────────────▶│───▶│───▶│───▶│
│ │ │ │ │ │
│◀── promise(10, (8,x=0)) ──┤ │ │ │ │
│◀── promise(10, none) ─────┼────────────────┤ │ │ │
│◀── promise(10, none) ─────┼────────────────┼────┤ │ │
│ │ │ │ │ │
│ 选 x=0(已接受值中 ballot 最大) │ │ │ │
│ accept(10, x=0) │ │ │ │ │
├──────────────────────────▶│────────────────▶│───▶│───▶│───▶│
│◀── accepted(10) ──────────┤ │ │ │ │
│◀── accepted(10) ──────────┼────────────────┤ │ │ │
│◀── accepted(10) ──────────┼────────────────┼────┤ │ │
│ 多数派接受 → x=0 被选中(chosen) │ │ │ │
四个消息的语义:
- prepare(n):proposer 请求 acceptor 用一个比 n 小的提案号做承诺。
- promise(n, accepted):acceptor 承诺「不再接受小于 n 的提案」,并返回自己已经接受过的最大 ballot 及其值(如果有)。
- accept(n, v):proposer 请求 acceptor 接受「ballot 为 n、值为 v」的提案。
- accepted(n, v):acceptor 接受了提案;当多数派接受时,这个值被选中。
acceptor 的两条硬规则:
- 收到
prepare(n)时,如果n > promised,就更新promised = n,返回已接受的最大 ballot;否则拒绝。 - 收到
accept(n, v)时,如果n >= promised,就接受,更新promised = n、accepted_ballot = n、accepted_value = v;否则拒绝。
proposer 的一条硬规则:在收到的 promise 中,如果有任何 acceptor 已经接受过值,必须选择其中 ballot 最大的那个值;只有所有 promise 都没有已接受值时,才能提自己的值。 这条规则是 Paxos 安全性的核心,也是最容易被实现者省略的一步。
ballot 还有两个工程约束:
- 全局唯一且全序:常用
(epoch, node_id)或(round, node_id);如果两个 proposer 用同一个 ballot 提不同的值,acceptor 会把它们当成同一个提案的不同版本,可能选出两个值。 - 只能单调递增:proposer 重试时复用原 ballot,或者换一个更大的 ballot;不要在失败后换一个更小的 ballot,那会被 acceptor 直接拒绝。
这两个约束看起来是「编号规范」,实际上是安全性的组成部分:ballot 是 Paxos 里唯一的全序坐标,坐标乱了,quorum 相交也救不了。
为什么必须这样做?看一个反例:
假设 acceptor A1、A2、A3 在 ballot 8 已经接受了 x=0,所以 x=0 已经被选中。现在 proposer P2 用 ballot 10 发起 prepare,收到 A1、A4、A5 的 promise;A1 返回「我在 ballot 8 接受过 x=0」。如果 P2 忽略这个信息,直接 accept(10, x=1):
- A1 的
promised已经是 10,10 >= 10,它会接受x=1; - A4、A5 也会接受;
- 多数派 {A1, A4, A5} 接受了
x=1,于是x=1也被选中。
结果是 x=0 和 x=1 都被选中,agreement 被破坏。P2 本来可以从 A1 的 promise 里知道 x=0 已经存在,但它选择忽略;安全性不是被 acceptor 的规则破坏的,而是被 proposer 的疏忽破坏的。
这个反例也解释了「为什么 promise 要返回已接受值」:phase 1 的 quorum 和 phase 2 的 quorum 必然相交,交集里的 acceptor 一定见过之前被选中的值。proposer 只要把「见过的最大 ballot 值」带进 phase 2,就能保证新提案不会覆盖旧决定。Paxos 的安全性来自 quorum 相交 + 提案号单调 + 携带已接受值,三者缺一不可。
一个常见的误解是「acceptor 只能接受一个值」。不是的:acceptor 可以接受多个不同 ballot 的值,但它不能接受比 promised 小的 ballot。它最终只会为「最高的那个被选中的 ballot」作证;更低 ballot 的值即使被接受过,也会被后续提案覆盖或被学习者忽略。
下面是单值 Paxos 的简化 Python 模拟器。它只模拟 acceptor 的承诺/接受规则和 proposer 的选择规则,不模拟消息丢失、崩溃和持久化;目的是让你看到「忽略已接受值」会怎样破坏安全性。
from dataclasses import dataclass
from typing import Optional
@dataclass
class Acceptor:
name: str
promised: int = -1
accepted_ballot: int = -1
accepted_value: Optional[str] = None
def prepare(self, ballot: int):
if ballot <= self.promised:
return ("reject", self.promised, None)
self.promised = ballot
prior = None
if self.accepted_ballot >= 0:
prior = (self.accepted_ballot, self.accepted_value)
return ("promise", self.promised, prior)
def accept(self, ballot: int, value: str):
if ballot < self.promised:
return ("reject", self.promised)
self.promised = ballot
self.accepted_ballot = ballot
self.accepted_value = value
return ("accepted", ballot)
def run_paxos(acceptors, ballot: int, value: str):
replies = [a.prepare(ballot) for a in acceptors]
promises = [r for r in replies if r[0] == "promise"]
if len(promises) <= len(acceptors) // 2:
return None
prior = [r[2] for r in promises if r[2] is not None]
if prior:
value = max(prior, key=lambda item: item[0])[1]
accepts = [a.accept(ballot, value) for a in acceptors]
if sum(1 for r in accepts if r[0] == "accepted") > len(acceptors) // 2:
return value
return None
nodes = [Acceptor(f"A{i}") for i in range(1, 6)]
nodes[0].accepted_ballot, nodes[0].accepted_value = 8, "x=0"
nodes[0].promised = 8
print("chosen:", run_paxos(nodes, 10, "x=1"))
输出:
chosen: x=0
注意输出不是 x=1。proposer 本来想提 x=1,但它在 A1 的 promise 里看到了 ballot 8 的 x=0,于是改为 x=0。如果把 if prior: 那两行删掉,输出会变成 x=1,同时 A1 的 accepted_value 会被覆盖——这就是例 5.3.1 的 bug。
这个模拟器还有三个简化,生产实现不能照抄:
- 没有持久化:真实 acceptor 必须在回复 promise/accepted 之前把
promised、accepted_ballot、accepted_value落盘(fsync)。崩溃后丢失 promise,可能接受一个更小的 ballot,直接破坏安全性。 - 没有消息丢失/重复:真实网络里 prepare 的响应可能丢,proposer 会重试;重试必须复用同一个 ballot,不能每次生成新 ballot,否则会制造活锁(5.4)。
- 没有 learner:真实系统里「被选中」不等于「所有节点都知道」;learner 需要从 acceptor 或 leader 学习,并处理日志空洞。
Paxos 只保证一个值的安全。要复制一串命令,需要为每个日志槽位跑一次 Paxos,这就是 Multi-Paxos 的起点。
Paxos 常被拿来和两阶段提交(2PC)比较,但两者解决的不是同一个问题:
| 维度 | Paxos | 2PC |
|---|---|---|
| 目标 | 让一组进程对值达成一致 | 让多个资源要么全提交、要么全回滚 |
| 容错 | 少数派故障仍可继续(有 quorum) | 协调者故障可能阻塞,参与者要等决议 |
| 提交条件 | 多数派接受 | 所有参与者 vote yes |
| 失败后的状态 | 可以重新选 leader、继续提案 | 可能处于 in-doubt,需要恢复/人工介入 |
| 典型位置 | 复制日志、选主、元数据 | 跨库事务、XA、分布式事务协调 |
2PC 不是「崩溃容错的共识协议」:它要求所有参与者都同意,协调者一挂就可能阻塞;Paxos/Raft 只要求多数派,少数派可以暂时失联。反过来说,Paxos 也不能直接替代 2PC 的事务语义:共识能决定「提交这条事务日志」,但事务的回滚、隔离、约束检查仍要数据库自己实现。第 6 章会把两者组合起来讲。
5.4 Paxos 的活锁与 Multi-Paxos
Paxos 保证安全性,但不保证活性。最典型的活性故障是提案者互锁(dueling proposers):
P1: prepare(10) ──────────────▶ 多数派 promise(10)
P2: prepare(11) ────────▶ 多数派 promise(11)
P1: accept(10, v1) ──▶ 被拒绝(promised=11)
P2: accept(11, v2) ──▶ 被拒绝(P1 又 prepare(12))
P1: prepare(12) ──▶ ...
P2: prepare(13) ──▶ ...
两个 proposer 都没有违反规则:P1 的 accept 被拒是因为 acceptor 已经 promise 了更大的 11;P2 的 accept 被拒是因为 P1 又提高了 ballot。每一轮都「差一点」成功,但没有任何值被多数派接受。这不是安全性故障,而是活性故障;客户端看到的是无限超时。
活锁在工程上不罕见,常见的触发方式:
- 两个客户端同时检测到 leader 失效,各自发起提案, ballot 一路抬高;
- 两个机房都认为自己应该接管,各自有一个 proposer;
- 重试逻辑没有随机退避,失败后立刻用更大的 ballot 重试;
- leader 租约到期时间设置得太短,正常抖动也会触发切换。
解决办法不是「让 Paxos 更聪明」,而是减少 proposer 的数量和冲突概率:
- 稳定 leader(distinguished proposer):同一时刻只允许一个节点发起提案;其他节点把请求转发给它。这也是 Raft 直接内置 leader 的原因。
- 随机退避:冲突后等待一个随机区间再重试,打破同步脉冲。
- ballot 空间分区:每个 proposer 使用
(epoch, node_id)形式的 ballot,避免两个节点生成相同的 ballot;ballot 相同但值不同会直接破坏安全性。 - 抢占与租约:leader 用租约告诉其他人「这段时间别抢」,租约到期后再开放选举;代价是时钟假设(5.8)。
Multi-Paxos 是把「一个值」扩展成「一串值」的工程方案。核心思想:选出一个稳定的 leader,让它只做一次 phase 1,然后对每个日志槽位直接做 phase 2。 这样每个命令省掉一轮 prepare/promise,吞吐量大幅提高。
Multi-Paxos 的几个关键设计:
- 日志槽位(slot):每个槽位对应一个共识实例;leader 为槽位分配命令,acceptor 接受
(slot, ballot, command)。 - phase 1 摊销:leader 在任期开始时对「所有未来槽位」做一次 prepare,或者对一段槽位区间做 prepare;之后 phase 2 只需要多数派接受。
- 提交索引(commit index):槽位可以乱序被选中,但状态机只能按连续前缀应用;中间的洞用 no-op 填,或者等之前的槽位补齐。
- leader 完整性:新 leader 必须先学习所有已选中的槽位,不能从自己的日志末尾直接开始,否则会覆盖已提交命令。
- 成员变更与快照:日志不能无限增长;配置变更也要走共识,不能靠改配置文件。
一次 Multi-Paxos 的日志写入可以简化为:
slot: 1 2 3 4 5
命令: set x=1 set y=2 no-op set z=3 set w=4
状态: chosen chosen chosen pending chosen?
commitIndex = 3 ← 只能提交连续前缀;slot 4 未选中,slot 5 即使已选中也不能先 apply
slot 4 的洞可能来自 leader 崩溃、消息丢失或并发提案;新 leader 必须先补齐/确认 slot 4,才能让 slot 5 生效。「已经选中」是每个槽位各自的事,「已经提交」是连续前缀的事;混用这两个概念,就会在恢复时应用一个中间有洞的日志。
Paxos 和 Raft 的关系,常被简化成「Raft 是 Multi-Paxos 的一种」。这句话方向没错,但容易掩盖差异:
| 维度 | Paxos / Multi-Paxos | Raft |
|---|---|---|
| leader 选举 | 协议之外,靠外部机制或额外 Paxos | 协议内置,term + 随机超时 + 投票 |
| 日志 | 槽位,允许空洞 | 连续索引,强日志匹配 |
| 提交规则 | 每个槽位多数派接受 | 当前 term 规则 + 多数派 matchIndex |
| 成员变更 | 通常靠外部配置管理 | 联合共识/单步变更,写进日志 |
| 读路径 | 需要额外设计(ReadIndex/lease) | 同样需要额外设计,协议不免费提供 |
| 可理解性 | 论文较抽象,实现变体多 | 为可理解性设计,状态机明确 |
这张表不是说 Paxos 更差。Paxos 更通用,适合需要灵活槽位、批量、跨地域优化的系统;Raft 把 leader 选举、日志、成员变更打包成一套更约束的协议,牺牲一些灵活性换取可实现性和可测试性。选型时不要问「哪个更强」,要问「团队能不能把它实现对、测出来」。
5.5 Raft:term、角色、选举
Raft 把共识拆成三个相对独立的部分:leader 选举、日志复制、安全性。它用 term(任期)把所有决策串起来,用随机化选举超时打破平票,用日志匹配性质保证「相同索引 + 相同 term ⇒ 前缀相同」。
Raft 的每个节点在任意时刻处于三种角色之一:
| 角色 | 行为 | 关键状态 |
|---|---|---|
| Follower | 被动接收 AppendEntries/RequestVote,重置选举计时器 | currentTerm、votedFor、日志 |
| Candidate | 超时后自增 term,给自己投票,向其他节点拉票 | 获得票数、选举超时 |
| Leader | 向所有 follower 发心跳和日志,处理客户端请求 | nextIndex[]、matchIndex[] |
term 是一个单调递增的逻辑时钟。每个 term 最多有一个 leader;节点收到更大的 term 时,立即更新 currentTerm 并退回 follower;收到更小的 term 时拒绝。term 不是墙钟,不能用来判断「谁先发生」;它只用来给选举和日志条目划分纪元。
一次选举的步骤:
- follower 在选举超时(例如 150–300 ms 的随机值)内没有收到 leader 的心跳,进入 candidate。
currentTerm++,给自己投一票,重置选举计时器。- 向所有节点发
RequestVote(term, candidateId, lastLogIndex, lastLogTerm)。 - 接收方在以下条件都满足时投票:
term >= currentTerm;本 term 还没投给别人(或已经投给该候选人);候选人的日志不旧于自己。 - 候选人拿到多数票,成为 leader;否则等待新的随机超时,term++ 后重来。
图里 term 1 三个节点同时超时,各自拉票,谁都没拿到多数;term 2 节点 B 的随机超时先到,先发起选举,拿到 A 和 C 的票。随机化不是为了「公平」,而是为了打破对称性:如果所有节点的超时时间相同,它们会永远同时超时、同时拉票,形成活锁。
「日志不旧于自己」的判定规则是 Raft 安全性的关键:
候选人的 lastLogTerm 更大 → 候选人的日志更新
lastLogTerm 相同,lastLogIndex 更大 → 候选人的日志更新
lastLogTerm 和 lastLogIndex 都相同 → 一样新,可以投票
注意比较顺序:先比 term,再比 index。不能只看 index,也不能只看 term。一个候选人可能在 index 100 处有 term 5 的条目,另一个在 index 90 处有 term 6 的条目;虽然后者更短,但它的最后 term 更大,按 Raft 的规则它「更新」。这看起来反直觉,但正是它保证了「已提交的条目一定在候选人日志里」。
选举限制(election restriction)的直觉:一条日志被提交,意味着它已经复制到多数派;任何新 leader 要当选,必须拿到多数票;两个多数派相交,至少有一个投票者拥有这条已提交日志;这个投票者只会把票投给「日志不旧于自己」的候选人,所以新 leader 一定拥有这条日志。这条推理是 Raft 安全性证明的主干,也是你实现时不能偷懒的地方。
看三个具体的投票判断(候选人最后日志 vs 投票者最后日志):
| 候选人 | 投票者 | 投吗 | 原因 |
|---|---|---|---|
(term=4, index=10) | (term=3, index=12) | 是 | 候选人最后 term 更大,日志更短也算更新 |
(term=3, index=12) | (term=3, index=12) | 是 | 最后 term 和 index 都相同,一样新 |
(term=3, index=9) | (term=3, index=12) | 否 | 最后 term 相同,但候选人更短 |
注意第一行:如果只看 index,投票者会拒绝一个最后 term 更高的候选人,可能让一个缺少已提交条目的节点当选。先比 term 的规则,是为了让「更新的 term」压过「更长的旧日志」。
需要持久化的状态:
| 状态 | 持久化? | 为什么 |
|---|---|---|
currentTerm | 必须 | 重启后若 term 回退,可能在同一 term 投两次票 |
votedFor | 必须 | 重启后忘记投过谁,会重复投票,可能选出两个 leader |
| 日志条目 | 必须 | 已提交的条目不能丢;未提交的条目也要参与日志匹配 |
commitIndex | 可不持久化 | 可从 leader/快照恢复;但不能应用未提交条目 |
lastApplied | 可不持久化 | 重启后从快照/日志重放 |
nextIndex/matchIndex | 不需要 | leader 上任后重新初始化 |
持久化必须在回复 RPC 之前完成,尤其是 votedFor。一个常见 bug 是「先回 granted,再异步落盘」:节点在落盘前崩溃,重启后忘记已经投过票,又投给另一个候选人,同一 term 出现两个 leader。投票是一种承诺,承诺必须先写进稳定存储。
Raft 还有两个工程上常用的扩展,虽然不属于原始论文的核心:
- PreVote:候选人在真正增加 term 之前,先问一圈「如果我竞选,你会投我吗」。如果收不到多数 positive,就不增加 term。它避免一个分区里的落后节点反复自增 term,恢复后把健康 leader 逼退。
- CheckQuorum:leader 定期检查自己是否还能联系到多数派;如果联系不到,主动退位。它避免旧 leader 在分区期间继续以 leader 自居(5.9 会展开)。
这两个扩展不改变安全性,只改善活性/稳定性;但它们的实现同样需要小心,尤其是 PreVote 的 term 处理和 CheckQuorum 的超时选择。
选举参数没有「标准值」,但有几条硬约束:
| 参数 | 常见起点 | 约束 |
|---|---|---|
| heartbeat interval | 50–100 ms | 必须小于 election timeout 的 1/3;建议留出 5–10 倍余量 |
| election timeout | 150–300 ms | 大于 heartbeat 的 3 倍;必须大于 p99 网络 RTT + 调度抖动 |
| 随机化区间 | [timeout, 2×timeout] 内随机 | 区间太窄,平票概率高;太宽,故障恢复慢 |
| CheckQuorum 窗口 | 接近 election timeout | 太短会把正常抖动当成分区,导致 leader 频繁退位 |
这些值要按部署环境调:跨可用区的 RTT 可能是同机房的 10 倍,容器 CPU 限流会让调度抖动变大。不要直接抄 etcd 的默认值;etcd 的默认值是为它的典型部署调的,不是为你的集群调的。调参之后要用分区/暂停测试验证,而不是只看「跑起来没报错」。
5.6 Raft:日志复制与提交规则
选举只决定「谁来写日志」;日志复制决定「写进去的条目什么时候算数」。Raft 的日志是一个从 1 开始连续编号的数组,每个条目包含 (index, term, command)。
Leader 通过 AppendEntries 复制日志:
| 字段 | 含义 | 作用 |
|---|---|---|
term | leader 的任期 | follower 用来更新自己的 term |
leaderId | leader 标识 | 方便 follower 重定向客户端 |
prevLogIndex | 新条目前一条的索引 | 一致性检查的锚点 |
prevLogTerm | 新条目前一条的 term | 一致性检查的锚点 |
entries[] | 要追加的条目 | 可以为空(心跳) |
leaderCommit | leader 已知的提交索引 | follower 据此推进自己的 commitIndex |
follower 的处理逻辑:
- 如果
term < currentTerm,拒绝。 - 如果
prevLogIndex超过自己的日志末尾,拒绝。 - 如果
prevLogIndex处的 term 不等于prevLogTerm,拒绝。 - 删除自己日志中与
entries冲突的后缀(相同 index 但不同 term 的条目及其之后的所有条目)。 - 追加新条目。
- 如果
leaderCommit > commitIndex,把commitIndex推进到min(leaderCommit, 新日志末尾)。 - 按顺序应用索引不大于
commitIndex的条目到状态机。
Leader 为每个 follower 维护 nextIndex 和 matchIndex。初始时 nextIndex = lastLogIndex + 1,matchIndex = 0。如果 follower 拒绝,leader 把 nextIndex 减一重试;生产实现通常用 follower 返回的冲突 term/索引做跳跃式回退,避免逐条回退。
回退示例:follower 的日志到 index 7,leader 的 nextIndex=8;follower 发现 prevLogIndex=7 的 term 不匹配,拒绝;leader 把 nextIndex 降到 7,再降到 6,直到找到共同前缀。逐条回退最坏要 O(日志长度) 次 RPC;优化是让 follower 返回「冲突 term 的第一个 index」,leader 一次跳过整个 term,把回退降到 O(term 数)。
日志匹配性质(Log Matching Property):如果两个日志在某个 index 和 term 上相同,那么它们从开头到该 index 的所有条目都相同。AppendEntries 的 prevLogIndex/prevLogTerm 检查就是归纳法的那一步:每次追加都要求前缀匹配,所以整个日志前缀匹配。
提交规则是 Raft 最容易写错的地方:
Leader 只能把
commitIndex推进到 N,当且仅当:log[N].term == currentTerm,且多数派的matchIndex >= N。
为什么必须加「当前 term」这个条件?因为一个旧 term 的条目即使已经出现在多数派上,也可能被后续 leader 覆盖。Raft 论文的 Figure 8 给出了反例,直觉如下:
- 条目 E 在 term 2 被复制到 S1、S2、S3(多数派),但 leader 在提交前崩溃,E 没有被提交。
- S4 在 term 3 当选。S4 的日志可能更短,但它的最后一条 term 是 3,比 S1/S2/S3 的最后 term 2 更大;根据选举限制,S1/S2/S3 会把票投给 S4。
- S4 成为 leader 后,可以在相同 index 写入自己的条目,覆盖 E。
如果旧 leader 在 term 2 时看到「E 在多数派上」就宣布提交,客户端会认为 E 已经成功;但 S4 上任后 E 被覆盖,已确认的写丢失。「多数派有」不是提交的充分条件,必须有一个当前 term 的条目作为「屏障」。
当前 term 的条目一旦被多数派复制并提交,它之前的所有条目也会被间接提交:新 leader 拥有当前 term 的条目,而日志匹配性质保证它拥有该条目之前的全部前缀;选举限制保证未来 leader 也拥有这个前缀。所以规则不是「只提交当前 term 的条目,旧条目永远不提交」,而是「旧条目要通过当前 term 的提交来间接提交」。
下面是一段可运行的 Raft 日志/选举检查伪代码,覆盖两个最关键的判断:
def up_to_date(cand_term, cand_index, voter_term, voter_index):
"""候选人日志是否不旧于投票者:先比最后 term,再比最后 index。"""
if cand_term != voter_term:
return cand_term > voter_term
return cand_index >= voter_index
def can_commit(log, match_index, n, current_term, cluster_size):
"""log 用 1-based 列表,log[i] = (term, command)。"""
if log[n][0] != current_term:
return False
quorum = cluster_size // 2 + 1
return sum(1 for m in match_index if m >= n) >= quorum
log = [None, (1, "set x=1"), (2, "set x=2"), (4, "set x=3")]
match = [3, 3, 3, 1, 1] # 5 个节点,前三个已复制到 index 3
assert can_commit(log, match, 3, 4, 5) is True
assert can_commit(log, match, 2, 4, 5) is False # 旧 term,不能凭多数派提交
assert up_to_date(3, 2, 2, 5) is True # term 更大,日志更短也算更新
assert up_to_date(2, 6, 3, 2) is False # term 更小,index 更长也不算更新
print("raft checks passed")
输出:
raft checks passed
第 2 条断言是重点:log[2] 的 term 是 2,current_term 是 4,即使多数派 match_index 已经覆盖 index 2,也不能把它标成已提交。实现里如果写成 if majority(match) >= n: commitIndex = n,就会在 Figure 8 的场景里丢数据。
日志复制还有一个容易被忽略的点:commit 不等于 apply。commitIndex 表示「这条日志已经被多数派确认,不会丢」;lastApplied 表示「状态机已经执行到哪」。读请求如果要读到自己刚写的值,必须等到 lastApplied >= commitIndex;否则会出现「写已提交、读却看不到」的假象。etcd 的 raftAppliedIndex 和 raftIndex 的差值,就是这类滞后的观测入口(5.10)。
除了「旧 term 提交」,还有两个常见的提交错误:
| 错误 | 后果 | 正确做法 |
|---|---|---|
| 跳过空洞提交:commitIndex 从 3 直接推到 5,但 index 4 还没选中 | 状态机 apply 时缺少中间命令,状态与日志不一致 | 只提交连续前缀;洞用 no-op 或等补齐 |
| follower 应用未提交条目:收到 AppendEntries 就 apply,不等 leaderCommit | 未提交命令可能被后续 leader 覆盖,状态机却已经执行 | 只 apply commitIndex 之前的条目,commitIndex 由 leaderCommit 推进 |
这两个错误在 happy path 上很难发现:日志看起来都在,重启后也能跑;只有在 leader 切换、分区恢复、快照恢复时,才会暴露成「状态和日志对不上」。
最后是日志截断。follower 在冲突时会删除未提交的后缀,这是安全的:未提交意味着它还没有被多数派确认,也没有向客户端承诺。但绝不能删除已提交的条目;如果实现把 commitIndex 之前的日志也截掉,重启后就无法恢复状态机。快照是唯一可以安全丢弃已提交前缀的机制(5.7)。
5.7 成员变更与快照
Raft 的 quorum 是相对于「当前配置」的。成员变更如果处理不好,会让两个不相交的多数派同时存在。
假设集群从 3 节点 {A, B, C} 直接切到 3 节点 {D, E, F}。旧配置的多数派可以是 {A, B},新配置的多数派可以是 {D, E};两个集合完全不相交。如果 A、B 还用旧配置选出了 leader,D、E 用新配置也选出了 leader,两个 leader 都可以拿到「多数票」,但它们互相不知道对方,日志会分叉。
即使新旧配置有重叠,直接切换也未必安全。考虑 {A, B, C} 切到 {B, C, D}:旧多数派 {A, B} 和新多数派 {C, D} 不相交。只要存在两个不相交的多数派,就可能出现两个 leader。成员变更的本质,是让「旧配置的多数派」和「新配置的多数派」在过渡期间发生重叠。
工程上有两种常见做法:
| 做法 | 规则 | 优点 | 缺点 |
|---|---|---|---|
| 单步变更(single-server change) | 一次只增加或删除一个节点 | 实现简单;旧/新多数派必然相交 | 扩容慢;连续变更要串行 |
| 联合共识(joint consensus) | 先进入 C_old,new,任何决定需要同时满足旧、新两个多数派;再切到 C_new | 可以一次改多个节点;理论清晰 | 实现复杂;过渡期可用性下降 |
单步变更为什么安全?当配置只差一个节点时,旧配置的任意多数派和新配置的任意多数派必然相交(对于 N≥3 的多数派系统)。例如 {A,B,C} → {A,B,C,D}:旧多数派至少 2 个,新多数派至少 3 个,在 4 个节点里不可能不相交。一次只改一个,就把「两个不相交多数派」的可能性排除了。
联合共识的流程:
- leader 收到配置变更请求,先把
C_old,new写入日志并提交;从这一刻起,任何决定都需要C_old的多数派 和C_new的多数派同时同意。 - 在
C_old,new提交后,再写入C_new并提交;之后只用C_new的多数派。 - 如果 leader 不在
C_new中,它在提交C_new后主动退位;新 leader 从C_new中选出。
联合共识的关键是两个多数派同时满足,而不是「先旧后新」的两次独立决定。过渡期间,任何一方单独都无法做出决定,从而避免两个不相交的多数派各自选出 leader。
生产实现还要处理这些细节:
- Learner:新节点先以非投票成员加入,追日志,追到接近 leader 后再提升为投票成员;否则新节点一加入就拖慢 quorum。
- Leader 移除:如果 leader 把自己从配置里删掉,它要在
C_new提交后停止发送心跳并退位,不能继续以 leader 自居。 - 一次一个变更:即使支持联合共识,也不要在过渡未完成时再发起下一次变更;配置变更本身要串行。
- 配置也在日志里:成员变更是一条日志条目,必须走共识、持久化、快照;不能只改本地配置文件。
etcd 把成员变更暴露成命令,理解这些命令的语义比背参数有用:
# 以 learner 身份加入,先追日志,不参与投票
etcdctl member add etcd-4 --peer-urls=http://10.0.0.14:2380 --learner
# MEMBER_ID 从 member list 取;追到接近 leader 后再提升为投票成员
etcdctl member promote "$MEMBER_ID"
# 删除成员;被删除的成员最终会收到自己的移除通知
etcdctl member remove "$MEMBER_ID"
这些命令只是入口;真正的安全性来自「配置变更是一条经过共识的日志」和「过渡期间 quorum 同时覆盖旧、新配置」。
快照解决另一个问题:日志不能无限增长。每条命令都保留,磁盘会满,重启重放会越来越慢。快照把「某一时刻的状态机状态」写成一个文件,同时记录 lastIncludedIndex 和 lastIncludedTerm,然后丢弃这个索引之前的日志。
| 快照字段 | 含义 | 注意 |
|---|---|---|
lastIncludedIndex | 快照覆盖的最后一条日志索引 | 必须是已提交、已应用的索引 |
lastIncludedTerm | 该索引对应条目的 term | 用于 InstallSnapshot 的一致性检查 |
| 状态机数据 | 应用后的完整状态 | 必须与索引一致;不能包含未提交命令 |
| 配置 | 快照时的成员配置 | 恢复后不能丢配置 |
| 校验和 | 数据完整性 | 传输/落盘损坏时能发现 |
Follower 落后太多、leader 已经丢弃了它需要的日志时,leader 发送 InstallSnapshot。Follower 的处理:
- 如果快照的
lastIncludedIndex不大于自己的commitIndex,忽略(自己已经更新)。 - 如果自己的日志在
lastIncludedIndex处有匹配的 term,保留该索引之后的日志后缀。 - 否则清空整个日志,把快照作为新的起点。
- 把状态机重置为快照状态,更新
commitIndex、lastApplied、lastIncludedIndex/Term。 - 继续接收后续日志。
快照最容易出的三个错:
- 快照了未提交的状态:状态机里可能包含还没被多数派确认的命令;恢复后这些命令可能被覆盖,状态就错了。快照必须基于
lastApplied,而lastApplied只能追到commitIndex。 - 截断太多:leader 把 follower 还需要的前缀日志删了,导致 follower 只能全量传快照;网络和磁盘压力骤增。通常要保留一段日志尾部,或者根据最慢 follower 的进度决定。
- 把快照当备份:快照是日志压缩,不是灾难恢复。它和日志在同一块磁盘上,磁盘损坏时一起丢;真正的备份要独立存储、定期校验、可恢复演练(第 3 章)。
崩溃恢复的顺序同样重要:
- 读取最新快照,校验校验和与
lastIncludedIndex/Term; - 把状态机恢复到快照状态,设置
commitIndex = lastApplied = lastIncludedIndex; - 从
lastIncludedIndex + 1开始重放日志,直到日志末尾; - 只有
lastApplied追上commitIndex后,才对外服务读; - 向 leader 确认自己的日志/任期,接受后续 AppendEntries。
如果第 2 步把 commitIndex 设得比 lastApplied 大,又在第 4 步之前服务读,就会读到「提交了但没 apply」的旧状态;如果第 3 步重放时又 apply 了快照里已有的命令,就会重复执行副作用。快照恢复和日志重放必须严格按索引拼接,不能靠「大概对齐」。
5.8 线性一致读:ReadIndex、lease read、follower read
Raft 只保证「日志的顺序」,不自动保证「读是线性一致的」。一个 leader 可能在分区后仍然自认为 leader;一个 follower 可能落后很多;一个本地读可能绕过共识,直接返回旧值。
先把三种读路径摆出来:
| 路径 | 做法 | 线性一致? | 额外 RTT | 关键假设 |
|---|---|---|---|---|
| 本地读 | 直接读本地状态机 | 否 | 0 | 无;可能读到任意旧值 |
| ReadIndex | leader 确认多数派仍承认自己,再读已应用状态 | 是 | 1 次心跳往返 | 多数派可达;不需要时钟 |
| Lease read | leader 在租约内直接读 | 是(在租约假设下) | 0 | 时钟漂移有界、进程不会被长暂停 |
| Follower read | follower 向 leader 要 readIndex,等 apply 后读 | 是(正确实现) | 1–2 次 RTT | follower 能联系 leader;不能本地猜 |
5.8.1 ReadIndex:用一次心跳确认「我还是 leader」
ReadIndex 的步骤:
- leader 必须已经提交过一条当前 term 的日志(没有的话,先提交一条 no-op)。这是为了确保
commitIndex包含当前 term 的屏障,避免读到旧 term 的提交边界。 - 向多数派发送心跳(或带空 entries 的 AppendEntries),确认自己仍然是 leader。
- 记录
readIndex = commitIndex。 - 等待
lastApplied >= readIndex。 - 从状态机读取,返回结果和对应的 revision/index。
客户端 Leader Follower 多数派
│ read │ │
├────────────────▶│ │
│ │ heartbeat / AppendEntries │
│ ├─────────────────────────────▶│
│ │◀───────── ack ───────────────┤
│ │ 多数派确认:仍是 leader │
│ │ readIndex = commitIndex │
│ │ 等待 lastApplied >= readIndex│
│◀── value@rev ───┤ │
为什么不能省略第 2 步?因为 leader 可能在分区后仍然保留 leader 状态。如果它直接用本地 commitIndex 返回,客户端可能读到一个旧值,而新 leader 已经提交了更新的值。心跳确认的是「多数派还认我」,不是「我还活着」。
为什么不能省略第 4 步?因为 commitIndex 可能大于 lastApplied:日志已经多数派确认,但状态机还没执行到那里。直接读状态机会漏掉刚提交的写,违反「读己之写」。
把数字代进去:commitIndex=100、lastApplied=98,读请求到达时 readIndex=100。如果直接读状态机,读到的是 index 98 的状态,漏掉了 index 99、100 两条已经提交的写;客户端可能刚写完其中一条,于是「读己之写」失败。等待 lastApplied >= 100 之后,读到的才是包含这两条写的状态。
ReadIndex 不依赖时钟。它只依赖多数派可达和日志/状态机的顺序,因此在时钟不可信的环境里更安全。代价是每次线性一致读都要一次心跳往返;高并发读会成为 leader 的 CPU 和网络瓶颈,工程上会用批量 ReadIndex、读请求合并、lease read 来优化。
5.8.2 Lease read:用时间换 RTT
Lease read 的思路:leader 在选举时获得一个租约,租约期内它相信自己还是 leader,不需要每次读都问多数派。只要租约没有过期,且没有发生长时间 GC/进程暂停/时钟跳变,读就是线性一致的。
安全性条件可以写成:
lease_duration + max_clock_drift < election_timeout
且(检查租约 → 返回结果)之间的进程暂停 < 剩余租约
第一个条件保证:旧 leader 的租约过期之前,新 leader 不可能被选出(因为新 leader 需要等待 election_timeout)。第二个条件保证:旧 leader 不会在「已经检查完租约、但还没返回结果」的窗口里被暂停,醒来后继续用已经过期的身份服务读。
Lease read 的风险全在时钟和暂停上:
- GC 停顿:leader 停顿 2 秒,租约 1 秒;醒来后它可能还不知道自己已经过期,继续读旧值。
- 时钟跳变:NTP 把时钟往回拨,租约看起来还没到期;或者往前跳,租约提前失效。
- 虚拟机/容器冻结:进程没有执行,但墙钟在走;恢复后租约状态不可信。
- 网络分区 + 租约配置错误:如果租约长度接近甚至超过 election timeout,新 leader 可能在旧租约到期前被选出;这段时间内旧 leader 仍可能服务读。
因此,lease read 必须配合 CheckQuorum(联系不到多数派就退位)、单调时钟(用 CLOCK_MONOTONIC 而不是墙钟)、保守的租约长度(远小于 election timeout),并且要接受「极端暂停下可能违反线性一致」的边界。ReadIndex 是安全优先,lease read 是延迟优先;不要把 lease read 当成「免费的线性一致」。
5.8.3 Follower read:把读压力分散,但不能本地猜
Follower read 的目标是让 follower 分担读流量。正确做法不是「follower 直接读本地」,而是:
- follower 向 leader 请求一个
readIndex(leader 走 ReadIndex 流程,或返回当前 commitIndex 并附上租约/任期证明)。 - follower 等待自己的
lastApplied >= readIndex。 - follower 从本地状态机读取。
这样 follower 不需要成为 leader,也不需要参与投票,但它读到的状态至少覆盖了 leader 确认时的提交边界。代价是 follower 要能联系到 leader;如果 follower 被分区,它不能提供线性一致读,只能提供串行化/最终一致的本地读。
etcd 把这两种语义暴露得很直接:
# 默认:线性一致读,走 ReadIndex/leader 确认
etcdctl get /config/feature --consistency=l
# 显式降级:串行化读,可以读本地副本,可能陈旧
etcdctl get /config/feature --consistency=s
--consistency=s 不是「更快的一致性读」,而是「放弃线性一致,换取本地读」。它适合读一些对陈旧不敏感的配置;如果用它读「刚写完的开关」,就可能读到旧值。把 s 当成默认,是把线性一致悄悄降级成最终一致。
三种路径的选择建议:
| 场景 | 推荐路径 | 理由 |
|---|---|---|
| 选主、锁、配置开关、余额 | ReadIndex | 不能容忍旧值;时钟不可信 |
| 高频只读、可容忍秒级陈旧 | follower read + 串行化 | 分摊 leader 压力;接受陈旧 |
| 低延迟、时钟可控、有 CheckQuorum | lease read | 省 RTT;必须做时钟/暂停防护 |
| 调试、离线分析 | 本地读/串行化 | 明确知道自己在读旧数据 |
最后记住一条:共识协议不自动提供线性一致读。Raft 论文专门用一节讲客户端交互与读路径,etcd 也把 --consistency 做成显式参数。把「写走 Raft」当成「读也自动线性一致」,是本章最常见的误解之一。
5.9 失败模式:分区后的旧主
Raft 能防止「两个 leader 同时提交不同的日志」,但这不等于「旧 leader 一被分区就立刻安静」。旧 leader 可能仍然监听端口、仍然返回读结果、仍然接受客户端的写请求——只是它无法让写提交。「接受」和「提交」是两件事;很多事故就发生在这两个词的缝隙里。
假设 5 节点集群 A、B、C、D、E,A 是 term 7 的 leader,已经提交到 index 100。某一刻网络分成 {A} 和 {B, C, D, E} 两边。B、C、D、E 在 election timeout 后选举 C 为 term 8 的 leader,继续提交新日志。A 这边没有多数派,既不能提交新日志,也不会立刻知道自己已经过时。
时间 A(旧 leader,term 7) B/C/D/E(term 8) 客户端
──────────────────────────────────────────────────────────────────────────────────────────────
t0 leader,commitIndex=100 follower 写 W → A
t1 ── 网络分区:{A} | {B,C,D,E} ── election timeout 倒计时 等待 W
t2 append W@101,拿不到多数派,不能提交 C 拿到 3 票,term=8,成为 leader W 超时 → unknown
t3 lease 到期,ReadIndex 失败; C 提交自己的 X@101 重试 W → 路由到 C
若实现直接本地读,可能返回旧值
t4 收到 term=8 的 AppendEntries 复制 X 给 B/D/E 重试命中 C,提交 W'
t5 退位为 follower,截断未提交的 101 commitIndex 前进到 102 成功;但旧 W 可能已执行
这张图里有几条不同的时间线,必须分开看:
| 时间线 | 旧 leader A 的状态 | 客户端看到什么 | 风险 |
|---|---|---|---|
| 写路径 | 接受请求、追加日志,但拿不到多数派 | 超时/unknown,不是成功 | 客户端重试到新 leader,可能重复提案 |
| ReadIndex 读 | 心跳拿不到多数派,ReadIndex 失败 | 读失败或超时 | 不会返回陈旧值(正确实现) |
| Lease read(正确实现) | 租约到期后拒绝读,等退位/重新确认 | 读失败或超时 | 不会返回陈旧值;代价是可用性 |
| Lease read(假设被破坏) | 时钟跳变/暂停导致租约判断错误 | 旧值 | 违反线性一致;需要 CheckQuorum/单调时钟 |
| 串行化本地读 | 直接读本地状态机 | 旧值(设计如此) | 被误当成线性一致读 |
| 外部副作用 | 旧 leader 可能仍在执行定时任务/写下游 | 下游收到旧 epoch 的写 | 需要 fencing,不能只靠「我是 leader」 |
同一条写,在分区前后有三个不同的结局:
- 分区前已经提交:它已经复制到多数派,新 leader 一定包含它(选举限制);客户端收到成功,数据不会丢。
- 分区后打到旧 leader:旧 leader 只能追加、不能提交,客户端收到超时/unknown;这条写可能被新 leader 覆盖,也可能在新 leader 上以新的提案重新提交。
- 分区后打到新 leader:新 leader 正常提交;如果客户端同时重试到旧 leader,旧 leader 的那条未提交提案最终会被截断。
危险的不是第 1 种,也不是第 3 种,而是第 2 种里的「unknown」被客户端当成失败,换一个新请求 ID 重发。共识层把「未提交」处理得很干净,客户端层却可能把它变成两次副作用。
注意第一行:正确的 Raft 旧 leader 不会确认一条没有多数派的写。它会把请求挂起、超时,或者返回「不是 leader」;客户端得到的是 unknown,而不是成功。真正危险的是读路径和外部副作用:
- 旧 leader 的 lease read 在租约到期后必须拒绝;如果实现用墙钟、不检查暂停,或把租约设得比 election timeout 还长,就可能在租约实际失效后仍返回旧值。
- 旧 leader 可能还在跑「只有 leader 才做」的后台任务:清理、对账、向下游发指令。这些动作不经过 Raft 日志,新 leader 也在做同样的事,于是同一件事被执行两次。
- 客户端/SDK 可能缓存了旧 leader 地址,重试时仍然打到 A;A 接受连接、挂起请求,放大超时。
- 监控上 A 可能仍然
has_leader=1(它自己认为有 leader),直到它收到更高 term 或 CheckQuorum 触发退位。
所以「分区后的旧主」要分三层处理:
- 提交层:写必须拿到多数派确认才回复成功;旧 leader 不能凭本地日志回复。Raft 的提交规则已经覆盖这一层,前提是实现正确(5.6)。
- 读层:线性一致读必须走 ReadIndex,或者使用有严格时钟/暂停假设的 lease read;串行化本地读要显式标注「可能陈旧」。
- 副作用层:旧 leader 对下游资源的操作必须带 fencing token(term/epoch/lease ID),资源端拒绝更小的 token;否则「旧主还在写」无法靠共识协议本身阻止。
- 真 leader,暂时失联:它还能联系多数派,只是某个 follower 不可达;它不是旧主,读路径仍然有效。
- 少数派 leader,尚未退位:它拿不到多数派,写不能提交;ReadIndex 会失败,但 lease read 可能还在窗口内。
- 已退位但客户端还在打它:它自己知道不是 leader,但客户端路由没更新;它会返回重定向/错误,客户端必须重新发现 leader。
工程上的止损手段:
- CheckQuorum:leader 在 election timeout 内联系不到多数派就主动退位,缩短「自以为主」的窗口。
- PreVote:分区中的节点不会因为反复超时把 term 抬高,恢复后不会逼退健康 leader。
- ReadIndex:读之前确认多数派仍承认自己;不依赖时钟。
- Lease 保守化:租约长度远小于 election timeout,使用单调时钟,限制 GC 暂停;接受「极端暂停可能不安全」的边界。
- Fencing:把 term/epoch 传给下游,资源端记录最大 token;旧 token 的写一律拒绝(第 9 章)。
- 客户端幂等:重试复用 request id,状态机去重(5.12);否则旧主/新主各执行一次。
客户端路由也值得单独说一句。很多 SDK 会缓存 leader 地址,或者在配置里写死一个 endpoint;分区后它可能一直打到旧 leader,看到超时,然后重试同一个地址。正确的客户端行为是:
- 收到「不是 leader」/重定向时,刷新 leader 地址,而不是盲目重试;
- 收到超时/unknown 时,复用请求 ID,换一个 endpoint 重试;
- 不要在客户端用「上次成功的那台机器」做粘性路由,除非它能接受陈旧读;
- 把 leader 发现和业务重试分开:发现 leader 可以频繁,业务重试要有预算和退避。
服务端把旧主处理得再好,客户端一直打旧主也会把可用性问题放大。 共识协议不负责客户端路由,这部分要 SDK 和运维一起设计。
一个常见的错误结论是「Raft 已经避免了双主,所以分区后旧主无所谓」。Raft 避免的是两个 leader 同时提交不同日志;它没有避免旧 leader 返回陈旧读、没有避免旧 leader 的外部副作用、也没有避免客户端重试造成的重复提案。共识协议的安全性边界止于状态机;状态机之外的副作用要另外设计。
5.10 观测:etcd/raft 指标
排查共识问题,第一步不是看日志,而是确认「现在有没有 leader、term 是否一致、日志有没有追上」。etcd 把这些信息暴露在 endpoint status 和 /metrics 里。
先看每个 endpoint 的 raft 状态:
export ETCDCTL_API=3
etcdctl --endpoints=https://10.0.0.11:2379,https://10.0.0.12:2379,https://10.0.0.13:2379 \
--cacert=/etc/etcd/ca.crt \
--cert=/etc/etcd/client.crt \
--key=/etc/etcd/client.key \
endpoint status --cluster -w table
示意输出(版本、数值不是生产数据):
+----------------+----------+---------+---------+-----------+------------+-----------+------------+--------------------+--------+
| ENDPOINT | ID | VERSION | DB SIZE | IS LEADER | IS LEARNER | RAFT TERM | RAFT INDEX | RAFT APPLIED INDEX | ERRORS |
+----------------+----------+---------+---------+-----------+------------+-----------+------------+--------------------+--------+
| 10.0.0.11:2379 | 8e9e05c5 | 3.5.15 | 25 MB | true | false | 17 | 98231 | 98231 | |
| 10.0.0.12:2379 | 9f8c1a2b | 3.5.15 | 24 MB | false | false | 17 | 98231 | 98229 | |
| 10.0.0.13:2379 | a1b2c3d4 | 3.5.15 | 24 MB | false | false | 17 | 98231 | 98190 | |
+----------------+----------+---------+---------+-----------+------------+-----------+------------+--------------------+--------+
怎么读这张表:
| 列 | 正常 | 异常信号 | 先查什么 |
|---|---|---|---|
IS LEADER | 恰好一个 true | 全 false:没有 leader;同一 term 多个 true:实现/观测异常 | 网络分区、election timeout、进程停顿 |
RAFT TERM | 所有成员相同或短暂差 1 | 某个成员 term 明显更高/更低 | 该成员是否分区、是否刚重启、是否被隔离后 term 膨胀 |
RAFT INDEX | leader 最高,follower 逐步追上 | follower 长期落后 | 网络、磁盘、apply 速度、快照传输 |
RAFT APPLIED INDEX | 与 RAFT INDEX 差距小且不增长 | 差距持续扩大 | 状态机 apply 慢、磁盘慢、CPU 饱和 |
ERRORS | 空 | 有错误文本 | 该 endpoint 的日志/磁盘/网络 |
DB SIZE | 低于配额,增长平稳 | 接近配额或突增 | compaction、写入量、告警阈值 |
再看指标。etcd_server_* 把「有没有 leader、leader 换了多少次、提案卡在哪」变成时间序列:
curl -s http://10.0.0.11:2379/metrics \
| grep -E '^etcd_server_(has_leader|leader_changes_seen_total|proposals_(committed|applied|pending|failed)_total) '
示意输出:
etcd_server_has_leader 1
etcd_server_leader_changes_seen_total 2
etcd_server_proposals_committed_total 19342
etcd_server_proposals_applied_total 19340
etcd_server_proposals_pending 0
etcd_server_proposals_failed_total 0
| 指标 | 含义 | 正常/异常边界 | 读法 |
|---|---|---|---|
etcd_server_has_leader | 本节点是否认为集群有 leader | 正常为 1;持续 0 超过一个 election timeout 为异常 | 0 时写一定失败,先看网络和选举日志 |
etcd_server_leader_changes_seen_total | 见过的 leader 变更次数 | 启动/维护时增长正常;10 分钟内多次增长异常 | 突增说明选举抖动、租约过短、网络不稳 |
etcd_server_proposals_pending | 已提交但未完成的提案数 | 接近 0;持续 > 0 异常 | 卡在 quorum 或磁盘 fsync |
committed - applied | 已提交但未应用的提案差 | 小且不增长;持续扩大异常 | apply 慢、状态机阻塞、磁盘瓶颈 |
etcd_server_proposals_failed_total | 失败提案累计 | 平稳;阶跃增长异常 | leader 切换、quorum 丢失、请求被取消 |
etcd_disk_wal_fsync_duration_seconds | WAL fsync 延迟直方图 | 与基线比;p99 持续升高异常 | 磁盘 IO、fsync 争用、云盘抖动 |
etcd_network_peer_round_trip_time_seconds | peer RTT | 与基线比;持续升高异常 | 跨机房网络、拥塞、丢包 |
如果集群接了 Prometheus,下面几条查询可以直接做成告警:
# 1. 当前节点认为没有 leader
etcd_server_has_leader == 0
# 2. leader 变化过快(10 分钟内至少一次)
rate(etcd_server_leader_changes_seen_total[10m]) > 0
# 3. 有提案卡住
etcd_server_proposals_pending > 0
# 4. 提案失败在增长
rate(etcd_server_proposals_failed_total[5m]) > 0
告警要带上前提:单节点 has_leader=0 可能只是它自己与 leader 失联,其他节点仍然健康;leader_changes 的阈值要结合发布/维护窗口;proposals_pending 的绝对值受写入速率影响,最好看「持续时间 + 是否增长」。不要用一个固定阈值覆盖所有集群。
单看一个瞬时值不够,至少要看「趋势 + 对比」:
has_leader=1但leader_changes在 10 分钟内涨了 5 次,说明 leader 不稳定,不是健康。RAFT INDEX一致但APPLIED INDEX差 5000,说明共识没问题,状态机 apply 有问题;此时读可能超时,写可能堆积。proposals_pending持续大于 0,先看 WAL fsync 和 peer RTT:如果是磁盘,pending 会随时间累积;如果是网络,通常伴随 term/leader 变化。proposals_failed_total阶跃增长,通常对应一次 leader 切换;把它和leader_changes对齐看,能区分「客户端取消」和「quorum 丢失」。
把几个指标组合起来,能快速缩小范围:
| 组合 | 更可能的原因 | 下一步 |
|---|---|---|
has_leader=0 + 所有节点 term 相同 | 网络分区/选举超时,没人拿到多数派 | 查 peer 连通性、election timeout、CPU 停顿 |
has_leader=0 + 某节点 term 明显更高 | 该节点反复竞选,term 膨胀 | 查 PreVote、网络抖动、日志落后 |
committed - applied 增长 + pending 正常 | 状态机 apply 慢(磁盘/CPU/锁) | 看 apply 日志、slow_apply、磁盘 util |
pending 增长 + WAL fsync p99 高 | 磁盘 fsync 成瓶颈,提案卡在落盘 | 看云盘 IOPS/吞吐、fsync 争用 |
leader_changes 增长 + peer RTT 高 | 网络不稳导致心跳超时、频繁选举 | 查跨机房链路、丢包、MTU |
proposals_failed 阶跃 + leader 变化 | leader 切换导致在途提案失败 | 对齐发布时间、节点重启、分区事件 |
这张表不是诊断的终点,而是「先看哪一列」的起点。真正的根因往往要回到日志和 trace:谁在什么时候失去了 quorum、谁先自增了 term、哪条提案被重复提交。
需要更细的 term/index 时,用 JSON 输出配合 jq:
etcdctl endpoint status -w json \
| jq '{term: .raftTerm, index: .raftIndex, applied: .raftAppliedIndex}'
{"term": 17, "index": 98231, "applied": 98231}
上面的「10 分钟」「5000」只是示意边界。生产阈值要来自自己的基线:集群规模、磁盘类型、写入速率、election timeout 都不同。能解释趋势,比记住一个数字更重要。
观测的最终目的是回答三个问题:现在有没有唯一的 leader?日志有没有追上?读路径走的是哪一条? 如果这三个问题答不上来,再看更多指标也只是堆信息。
5.11 真实案例:Jepsen 对 etcd 3.4.3 的分析
共识系统的结论对版本、配置、故障注入方式极其敏感。本节引用可核验的 Jepsen etcd 3.4.3 报告(2020-01-30),并把「KV 正确」与「上层锁不安全」分开说。
可核验的公开事实(Jepsen: etcd 3.4.3,2020-01-30):
| 项目 | 公开内容 |
|---|---|
| 系统 | etcd 3.4.3,基于 Raft 的 KV 存储 |
| 集群 | 5 节点 Debian Stretch |
| 故障注入 | 网络分区(隔离单节点、多数/少数分区、非传递分区)、崩溃与进程暂停、定向杀 leader、数百秒级时钟偏移、动态成员变更 |
| 工作负载 | register(读/写/CAS)、set、append 事务、lock、watch |
| KV 结论 | 读、写、多键事务表现为严格可串行化(strict serializable);serializable 读允许陈旧,符合文档 |
| watch 结论 | 单 key 的 watch 按顺序交付每一次变更(在 compaction 不破坏历史的前提下) |
| lock 结论 | etcd 锁不保证互斥;等待锁的路径存在 lease 有效性未复查的问题,已在后续修复 |
这张表里最值得共识章关心的是「故障注入」和「KV 结论」两行:在分区、进程暂停、时钟偏移、成员变更下,KV 操作仍然严格可串行化。这说明 Raft 的提交规则、ReadIndex 读路径和成员变更在 etcd 的实现里经受住了故障注入;它不是「论文里的协议」,而是可被工程验证的。
但同一份报告也给出了一个反例:共识核心正确,不代表上层 API 正确。锁的语义建立在 lease 上:客户端持有 lease,lease 过期后锁应释放;如果客户端在持有锁时被暂停,lease 过期,另一个客户端拿到锁,原客户端恢复后仍以为自己持有锁,就可能出现两个持有者。报告指出等待锁的路径没有在唤醒后复查 lease 有效性,放大了这个问题;即使修掉这个 bug,分布式锁在异步系统里仍无法保证互斥,需要 fencing token(第 9 章)。
把公开结论映射成一条工程失败链(示意,不是报告原文的逐字复述):
1. 客户端 A 获取锁,lease TTL=2s,开始处理
2. A 进程被 GC/虚拟机暂停 3s;lease 过期,锁被释放
3. 客户端 B 获取同一把锁,开始处理同一资源
4. A 恢复,仍以为自己持有锁,继续写
5. 资源端没有 fencing 校验,两次写都成功 → 丢失更新/交错
这条链和 5.9 的旧主问题是同构的:「我以为我还有权」和「我实际还有权」之间,隔着租约、时钟和一次 quorum 确认。 Raft 能保证日志的顺序,但不能保证一个基于 lease 的锁在进程暂停后仍然有效;后者需要资源端的 fencing,或者干脆把操作放进同一个共识状态机里做条件写。
四条教训:
- 线性一致读要作为故障注入的必测项,不能只测「集群健康时 get/put 对不对」。分区、暂停、时钟偏移下读到的值,才是用户会遇到的。
- 每个 API 单独证明。KV 的线性一致不等于 lock 的互斥,不等于 watch 的 exactly-once,也不等于 lease 的活性;共识是底座,不是万能证明。
- 租约/锁必须有 fencing 或资源端校验。不要把「持有锁」当成「下游一定会接受我的写」;下游要能拒绝旧 token。
- 用可复现的历史检查器。Jepsen 的价值不只是「找到了 bug」,而是把并发历史记录下来,用模型检查器判定它是否满足一致性;这套方法可以搬到自己系统的测试里。
为什么把这个案例放在共识章,而不是锁章?因为它同时展示了「共识做对了什么」和「共识不能保证什么」:
- 做对了:在分区、暂停、时钟偏移、成员变更下,KV 的读写仍然是严格可串行化;这说明 Raft 的提交规则和 ReadIndex 读路径在真实实现里是有效的。
- 不能保证:锁是建立在 lease 之上的另一个协议;lease 过期、进程暂停、缺少 fencing,都会让「持有锁」变成一句空话。共识日志只能保证锁的状态变更按顺序发生,不能保证锁的持有者在暂停后仍然有资格操作外部资源。
这也是本章反复强调的边界:共识是底座,不是全部。 读路径、租约、去重、fencing,每一层都要单独设计和测试;把它们都寄希望于「Raft 会处理」,就是把这些 bug 留到生产。
链接:
本节只引用公开页面上的结论、测试维度和版本信息;不把 3.4.3 的逐项结果倒推成 3.3 的结论,也不复述报告中的具体失败率数字。若需要版本级结论,以原文和对应版本的 release notes 为准。
5.12 重试导致重复副作用:提案重试与状态机去重
共识解决的是「顺序」,不是「身份」。同一个客户端意图,如果被提交两次,Raft 会老老实实地把它排进日志两次,并按顺序执行两次。日志没有重复的概念,只有命令;去重必须在命令里带上身份,并在状态机里完成。
场景:库存服务把 reserve(order-42, sku-7, 1) 包装成一条 Raft 提案。leader 提交了 index 101,但在返回响应前网络断开。客户端超时,重试到新 leader;新 leader 不认识这个请求,把它作为一条新提案写到 index 145。两条日志都提交、都 apply:
index 101: reserve(order-42, sku-7, 1) → stock 10 → 9
index 145: reserve(order-42, sku-7, 1) → stock 9 → 8
共识完全正确:所有副本都按 101、145 的顺序执行,状态一致。但业务错了:一次预留扣了两次库存。这正是「共识不保证 exactly-once」的含义。
为什么会这样?
- 客户端重试是一个新的提案;Raft 只看到「两条命令」,不知道它们来自同一个意图。
- 如果重试打到旧 leader,旧 leader 可能把同一条命令追加到本地日志(未提交),恢复后被覆盖或再次提交;行为取决于实现。
- 如果命令本身是
set stock=9(幂等赋值),重复执行无害;如果是decrement、append、transfer,重复执行就有副作用。 - 在网关层做去重不够:网关重启、leader 切换、请求绕过网关,都会丢失去重状态。
正确的做法是把请求身份写进命令,并在状态机 apply 时原子地去重:
class Inventory:
def __init__(self):
self.stock = {"sku-7": 10}
self.seen = {} # (client_id, seq) -> (fingerprint, result, applied_index)
def apply(self, index, cmd):
key = (cmd["client_id"], cmd["seq"])
if key in self.seen:
fp, result, _ = self.seen[key]
if fp != cmd["fingerprint"]:
return "conflict" # 同键不同参:拒绝,不返回旧结果
return result # 同一个意图的重复提案:返回原结果
if cmd["op"] == "reserve":
if self.stock[cmd["sku"]] < cmd["qty"]:
result = "rejected"
else:
self.stock[cmd["sku"]] -= cmd["qty"]
result = "reserved"
else:
raise ValueError(f"unknown op: {cmd['op']}")
self.seen[key] = (cmd["fingerprint"], result, index) # 与副作用同一次 apply
return result
inv = Inventory()
cmd = {"client_id": "c1", "seq": 7, "fingerprint": "fp-1",
"op": "reserve", "sku": "sku-7", "qty": 1}
print(inv.apply(101, cmd), inv.stock["sku-7"])
print(inv.apply(145, cmd), inv.stock["sku-7"])
print(inv.apply(146, {**cmd, "qty": 2, "fingerprint": "fp-2"}), inv.stock["sku-7"])
输出:
reserved 9
reserved 9
conflict 9
第二次 apply 命中 seen,直接返回第一次的结果,库存仍然是 9。第三次 apply 用了同一个 (client_id, seq) 但 qty 不同,返回 conflict,库存保持 9;如果直接返回旧结果,客户端会以为 qty=2 也生效了。注意三个细节:
- 去重表和副作用必须在同一次 apply 中更新。如果先扣库存、再异步写
seen,中间崩溃会留下「扣了但没记住」的状态;恢复后重放又会扣一次。状态机 apply 应该是单线程、确定性的,去重表和业务状态一起进入快照。 client_id/seq必须由客户端生成,并在所有重试中复用。服务端生成的自增 ID 每次都会变,无法去重(第 1 章的逻辑请求/物理尝试)。seq要持久化在客户端。客户端进程重启后如果从 0 重新计数,可能和旧请求撞车;用持久化的会话 epoch + 单调序号,或者在重启时换一个新的client_id。
去重状态放在哪一层,决定了它能活多久:
| 位置 | leader 切换后还在? | 进程重启后还在? | 与副作用原子? | 结论 |
|---|---|---|---|---|
| 网关内存 | 否 | 否 | 否 | 只适合挡瞬时重试,不能作为正确性保证 |
| leader 内存 | 否 | 否 | 否 | leader 一换就丢,最危险的「假去重」 |
| 状态机内存 + 快照 | 是 | 是(从快照恢复) | 是(同一次 apply) | 正确方向 |
| 外部去重表(Redis/DB) | 视实现 | 视实现 | 否(跨系统双写) | 需要额外事务/对账,容易不一致 |
去重表会无限增长,必须回收:
- 按
client_id保留最近的seq窗口(例如每个客户端保留最后 N 个),旧请求直接拒绝; - 或者按 TTL 回收,但 TTL 必须大于客户端最大重试窗口 + 时钟偏移;否则客户端在 TTL 之后重试,去重表已经忘了,副作用会再来一次;
- 快照必须包含去重表,否则恢复后去重能力退化;
- 对「同
client_id+ 同seq但参数不同」的请求,返回冲突,而不是返回旧结果(和第 1 章的指纹规则一致)。
去重 TTL 可以这样估:
客户端最大重试窗口 = 首次发送到最后一次重试的墙钟时间(例如 10 分钟)
安全余量 = 最大时钟偏移 + 网络延迟上界(例如 30 秒)
去重 TTL >= 10 分钟 + 30 秒
如果客户端会在离线后恢复并重放(移动端、边缘设备),重试窗口可能是几小时甚至几天;这时 TTL 要按「最长离线时间」算,或者改用「每个客户端保留最后 N 个 seq」的窗口,而不是纯 TTL。TTL 太短,去重表先忘,副作用后到;TTL 太长,内存/快照膨胀。 这是一个明确的权衡,不要拍一个「1 小时」了事。
有些 Raft 实现会给提案分配一个 leader 本地的 proposal ID,用于匹配响应;这个 ID 在 leader 切换后通常就失效了。它不能替代客户端请求 ID。请求 ID 必须由客户端生成、跨 leader 稳定、写进日志命令,才能被状态机识别为同一个意图。
最后记住:exactly-once 的副作用 = at-least-once 的投递 + 状态机去重 + 幂等的应用语义。共识提供的是「所有副本看到同一个顺序」,不是「同一个意图只出现一次」。把这两件事混为一谈,就会写出「日志很一致,库存却多扣了」的系统。
5.13 自己实现 Raft 的五个坑
实现 Raft 的常见体验是:happy path 很快就能跑通,选主、复制、重启恢复看起来都正常;然后在分区、崩溃、快照、成员变更的组合下,出现一个难以复现的丢日志或双 leader。下面五个坑覆盖了大部分「最后 10%」的安全问题。
| # | 坑 | 典型症状 | 最小测试 |
|---|---|---|---|
| 1 | 提交旧 term 的日志 | 已确认的写在新 leader 上消失 | 构造 Figure 8 的 term/日志组合 |
| 2 | 投票/term 未持久化 | 同一 term 两个 leader | 在回复 RPC 前 kill -9 |
| 3 | 快照与日志截断 | 重启后状态回退/日志空洞 | 慢 follower + 大快照 + 崩溃 |
| 4 | 成员变更 | 变更期间双 leader/不可用 | 变更中分区、连续变更 |
| 5 | 读路径 | 分区后读到旧值/读己之写失败 | 旧 leader + lease/ReadIndex 读 |
5.13.1 提交旧 term 的日志
错误写法:leader 看到多数派的 matchIndex >= N,就把 commitIndex 推到 N,不检查 log[N].term。这在正常路径下没问题,但在 Figure 8 的场景里会把一个「多数派有、但可能被覆盖」的旧 term 条目宣布为已提交。
正确写法:只有 log[N].term == currentTerm 时才推进 commitIndex;旧 term 条目通过当前 term 的提交间接提交(5.6)。测试时不要只测「leader 正常复制」,要构造「旧 term 条目 + 新 leader + 覆盖」的日志序列,断言已提交的条目不会被截断。
5.13.2 投票和 term 没落盘
错误写法:收到 RequestVote 后先更新内存中的 currentTerm/votedFor,回复 granted,然后异步刷盘。节点在刷盘前崩溃,重启后忘记已经投过票,又投给另一个候选人;同一 term 出现两个 leader。
正确写法:在回复任何 RPC 之前,把 currentTerm、votedFor、新增日志条目 fsync 到稳定存储。批量提交可以优化吞吐,但不能改变「先持久化、后回复」的顺序。测试时在持久化前后注入 kill -9,检查重启后的投票行为。
5.13.3 快照与日志截断
常见错误有三个:快照了未提交的状态;截断了 follower 还需要的前缀;InstallSnapshot 之后没有正确重置 lastApplied/commitIndex,导致重复 apply 或状态机回退。
正确做法:快照只基于 lastApplied;保留一段日志尾部(或按最慢 follower 的进度决定);InstallSnapshot 时用 lastIncludedIndex/Term 做一致性检查,保留匹配的后缀,否则清空日志;快照必须包含成员配置和去重表。测试时用一个慢 follower 触发快照传输,再在传输过程中崩溃 leader/follower。
5.13.4 成员变更
错误写法:直接把配置从 {A,B,C} 改成 {A,B,C,D,E},不做联合共识/单步变更;或者在变更未完成时再发起下一次变更;或者新节点还没追平日志就参与投票。
正确做法:单步变更一次一个,或联合共识同时满足旧/新多数派;新节点先做 learner 追日志;leader 被移出配置时在 C_new 提交后退位;配置变更写进日志并进入快照。测试时在变更过程中分区,检查是否可能出现两个不相交的多数派。
5.13.5 读路径
错误写法:leader 直接读本地状态机;lease read 不检查时钟/暂停;ReadIndex 在确认多数派之前就取 commitIndex;或者不等待 lastApplied >= readIndex 就返回。
正确做法:线性一致读走 ReadIndex(确认多数派 + 等待 apply),或使用有严格时钟/暂停假设的 lease read;串行化读显式标注可能陈旧;follower read 必须向 leader 要 readIndex,不能本地猜。测试时把旧 leader 隔离,分别用三种读路径读同一个 key,断言只有 ReadIndex 路径不会返回旧值。
把五个坑落成一张最小测试矩阵:
| 故障 | 注入方式 | 断言 |
|---|---|---|
| 崩溃时机 | 在持久化前/后 kill -9 | 重启后不重复投票、不丢已提交日志 |
| 网络分区 | 隔离 leader / 隔离少数派 | 少数派不能提交;多数派选出新 leader;旧 leader 退位 |
| 慢 follower | 暂停一个 follower 数分钟 | leader 不被拖慢;follower 通过日志/快照追上 |
| 快照传输 | 让 follower 落后于 leader 的最旧日志 | InstallSnapshot 成功后状态一致,不重复 apply |
| 成员变更 | 变更中分区、连续变更 | 不出现两个不相交多数派;leader 移除后正常退位 |
| 读路径 | 隔离旧 leader 后读 | ReadIndex 失败;lease read 不返回旧值;串行化读标注陈旧 |
测试的目标不是「跑通 happy path」,而是在每一个可能崩溃/分区/暂停的点,检查安全性质是否仍然成立。这也是 Jepsen 那类工具的价值:它不证明你对,但它能在你出错时把历史留下来。
还有几个常见问题:nextIndex 回退逐条减一导致恢复慢;日志匹配检查漏掉 prevLogTerm;把 commitIndex 和 lastApplied 混用;PreVote 的 term 处理错误;CheckQuorum 超时太短导致正常抖动也退位。它们大多会表现为「性能差」或「选举抖动」,而不是直接丢数据,但同样值得测试。
5.14 本章陷阱清单
- 把共识当成「所有节点都同意」:共识只要求多数派参与决策;要求全体一致会让单机故障阻塞整个系统。
- 把「多数派有日志」当成「已提交」:提交是协议动作;旧 term 条目即使多数派有,也可能被后续 leader 覆盖(5.6)。
- 用 term 大小判断日志新旧:先比最后 term,再比最后 index;只看 term 或只看 index 都会选错 leader。
- 投票后不落盘就回复:重启后重复投票,同一 term 可能出现两个 leader(5.13)。
- 选举超时太小或没有随机化:频繁选举、平票、活锁;超时必须远大于 RTT,并加随机抖动。
- 以为写走 Raft 读就自动线性一致:本地读/串行化读可能陈旧;线性一致读要走 ReadIndex 或受约束的 lease read(5.8)。
- lease read 不看时钟和暂停:GC 停顿、时钟跳变会让旧 leader 在租约过期后继续服务读。
- 成员变更直接切换:旧/新多数派可能不相交,出现双 leader;要用单步变更或联合共识(5.7)。
- 快照未提交状态/截断太多:恢复后状态回退,或慢 follower 只能全量传快照;快照必须基于
lastApplied,并保留必要日志尾部。 - 重试时换 request id:每次重试生成新 ID,状态机无法去重;请求 ID 必须由客户端生成并在所有重试中复用(5.12)。
- 去重只放在网关/leader 内存:leader 切换或进程重启后去重状态丢失,重复提案照样执行。
- 只看
has_leader不看 term/apply lag:有 leader 不等于健康;leader 抖动、apply 滞后、磁盘 fsync 慢都会表现为「有 leader 但不可用」。
这 12 条里,第 2、4、10 条是「会直接丢数据/重复副作用」的硬错误;第 6、7、9 条是「边界条件」错误,平时不出现,故障时一起出现;第 12 条是观测错误,它不会直接造成事故,但会让你在事故里找错方向。
5.15 本章出口检查
| 检查项 | 达标线 | 没达标去哪 |
|---|---|---|
| 复述共识三性质 | 能说出 agreement/validity/termination,并区分安全/活性 | 5.1 |
| 解释 FLP | 能说出「完全异步 + 一个崩溃 + 确定性 + 必须终止」四个限定词 | 5.2 |
| 计算 quorum | 给定 N 能算多数派和容忍数;能解释相交性 | 5.2 |
| 画出 Paxos 两阶段 | 四个消息、acceptor 两条规则、proposer 的「选最大已接受 ballot」 | 5.3 |
| 解释 Paxos 活锁 | 能画出双 proposer 互锁,并说出稳定 leader/随机退避 | 5.4 |
| 解释 Raft 选举 | term、角色、随机超时、投票条件、日志新旧比较 | 5.5 |
| 判断提交规则 | 能说出「当前 term + 多数派 matchIndex」,并解释 Figure 8 | 5.6 |
| 设计成员变更 | 能说出单步变更/联合共识的适用场景和风险 | 5.7 |
| 设计读路径 | 能对比 ReadIndex/lease read/follower read 的假设和代价 | 5.8 |
| 处理旧主分区 | 能区分「接受/提交/读/外部副作用」四层,并给出止损 | 5.9 |
| 读 etcd 指标 | 能解释 has_leader、leader_changes、RAFT TERM、apply gap | 5.10 |
| 做状态机去重 | 能写出 (client_id, seq) 去重表,并说明快照/TTL | 5.12 |
如果你要动手做第 13 章的项目二(小型复制 KV),本章的检查顺序是:先实现选举和日志复制,再用「当前 term 提交」规则做提交,然后加 ReadIndex 读,最后加状态机去重和快照。不要先做 lease read:它看起来省了一次 RTT,实际上把时钟/暂停的假设引入了系统,调试成本远高于 ReadIndex。
Q1 5 节点集群,quorum 是多少?如果分区成 {A,B} 和 {C,D,E},哪边能选出 leader?{A,B} 这边的旧 leader 还能提交写吗?能服务 lease read 吗?
Q2 Paxos acceptor 已经 promise 了 ballot 10,此时收到 prepare(9) 和 accept(10, v),分别怎么处理?如果它在回复 promise 后崩溃且丢失了 promised,会有什么后果?
Q3 为什么 Raft leader 不能凭「多数派 matchIndex 已经覆盖 index N」就提交旧 term 的条目?请用 Figure 8 的直觉解释。
Q4 ReadIndex 读的五个步骤是什么?为什么第 2 步(确认多数派)和第 4 步(等待 apply)都不能省?
Q5 集群从 {A,B,C} 直接切到 {D,E,F},可能发生什么?单步变更和联合共识分别怎么避免?
Q6 客户端提交 decrement 提案,leader 提交后响应丢失,客户端重试到新 leader。共识层和状态机层分别应该做什么,才能保证只扣一次?
答案
A1 quorum 是 3。{C,D,E} 有 3 个节点,能选出 leader;{A,B} 只有 2 个,不能形成多数派,也不能提交新写。旧 leader 如果还在 {A,B} 这边,它的写只能追加、不能提交;lease read 在租约未到期时可能返回旧值,租约到期后 ReadIndex 会失败。正确做法是让旧 leader 退位,客户端重试到新 leader,并用请求 ID 去重。
A2 prepare(9) 被拒绝,因为 9 不大于 promised=10;accept(10, v) 可以接受(10 >= 10),acceptor 会更新 accepted_ballot=10、accepted_value=v。如果它在回复 promise 后崩溃且丢失 promised,重启后可能接受一个更小的 ballot,覆盖已经 promise 的决定,破坏安全性;所以 promise 必须先持久化再回复。
A3 旧 term 条目即使出现在多数派上,也可能被后续 leader 覆盖:新 leader 的最后 term 更高,即使日志更短,也能凭选举限制赢得选举,然后在相同 index 写入自己的条目。若旧 leader 凭多数派计数就提交,客户端会认为已成功,但新 leader 会覆盖它,造成已确认写丢失。当前 term 的条目提交后,它之前的条目才被间接提交。
A4 步骤:提交一条当前 term 的日志(或 no-op)→ 向多数派发心跳确认自己仍是 leader → 记录 readIndex=commitIndex → 等待 lastApplied >= readIndex → 从状态机读。第 2 步防止旧 leader 在分区后返回旧值;第 4 步防止「已提交但未 apply」的写读不到,保证读己之写。
A5 直接切换会让旧配置的多数派(如 {A,B})和新配置的多数派(如 {D,E})不相交,可能各自选出 leader,日志分叉。单步变更一次只加/删一个节点,使旧/新多数派必然相交;联合共识在过渡期要求同时满足旧、新两个多数派,完成后再切到新配置。
A6 共识层:保证两条提案按同一顺序提交,不丢已提交的日志;如果旧提案未提交,新 leader 可以覆盖它。状态机层:命令里带 (client_id, seq),apply 时先查去重表;命中就返回原结果,不重复扣减;去重表和库存变更在同一次 apply 中写入,并进入快照。两层合起来才是「有效一次」的副作用。
母本对应
| 母本 | 章节 | 本书落点 | 程度 | 说明 |
|---|---|---|---|---|
| 谢希仁《计算机网络》第 8 版 | 第 5 章 运输层、第 6 章 应用层 | 5.1、5.2、5.10 | 背景 | 只借用分层与端到端视角;不重写 TCP、不重复运输层细节 |
| 张彦飞《深入理解 Linux 网络》 | 第 4–7 章(TCP、socket、连接) | 5.10 | 背景 | 网络 RTT/丢包只作为观测背景,不展开内核路径 |
| 《Linux 系统与内核》第 4 篇 | 4.1–4.11(网络与协议栈) | 5.10、5.13 | 交叉引用 | 磁盘 fsync、网络栈只引用,不重复实现细节 |
| 本书自编 + Paxos/Raft 论文 | Paxos Made Simple、Raft 论文 | 5.3–5.9 | 完整 | 只引用定义、结论和反例;代码、图、时序均为本书重画,不复制论文或 etcd 源码 |
| 本书自编 + Jepsen/etcd 文档 | etcd 3.4.3 分析、etcd 文档 | 5.10–5.12 | 完整 | 只引用公开结论和链接;版本固定为 3.4.3 |
共识是第 2 篇的收束:第 3 章把日志复制到多个副本,第 4 章定义读到的值算不算数,本章让多个副本对「日志的下一位是什么」达成不会反悔的决定。但共识只解决「顺序」,不解决「业务事务」——一条日志里能不能原子地改多行、失败后怎么回滚、读写怎么隔离,是下一篇的主题。
下一站:日志已经能按同一个顺序落地,下一步要问「一条日志里的多个操作能不能一起成功或一起失败」。进入 6 事务:ACID、隔离级别、MVCC,看单机事务、分布式事务和共识日志如何组合,以及为什么「重试一次事务」会把一次转账做成两次。