Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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 和所有超时机制的关键。

定义 5.1.1 · 共识与状态机复制

共识:一组进程对某个值达成一致,满足 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 章)共识(本章)
解决的问题数据放在几个副本上哪个值/哪条命令排在哪个位置
典型机制主从、多主、无主、quorumPaxos、Raft、Zab
安全边界复制 lag、冲突、双主两个 leader、两个值、丢已提交日志
失败后果读到旧值、写冲突已确认的写丢失、状态机分叉
与读的关系决定副本是否新决定「已提交」的边界

最后强调本章的故障模型:崩溃-恢复(crash-recovery)+ 网络分区 + 部分同步(partial synchrony)。我们不考虑拜占庭节点(会撒谎、会伪造消息),也不考虑磁盘静默损坏。拜占庭共识需要 3f+1 个节点和签名等额外机制,是另一个话题;把崩溃模型下的结论套到拜占庭环境,会低估所需副本数。

5.2 FLP、故障模型与 quorum

初学者常问:「既然异步网络里消息可能丢、可能慢,那共识是不是不可能?」答案是:在完全异步、确定性算法、允许一个进程崩溃的模型下,要求所有正确进程都终止,确实不可能。 这就是 FLP 不可能性定理(Fischer、Lynch、Paterson,1985)。

定理 5.2.1 · FLP 不可能性

在一个完全异步的分布式系统中,即使消息可靠、网络最终会送达,只要允许一个进程崩溃,且算法是确定性的,就不存在一个共识协议能同时保证 agreement、validity 和 termination。

注意限定词:完全异步、确定性、一个崩溃、必须终止。少一个限定词,结论就可能不同;多一个「部分同步」假设,工程上就有活路。

FLP 的直觉不复杂:在异步模型里,你无法区分「进程已经崩溃」和「进程只是极慢」。假设协议在某一步必须等某个进程的消息才能决定;调度器可以一直延迟这个消息,让协议停在「还差一步」的状态。如果协议为了终止而选择不等,它又可能在一个「其实还活着、只是消息晚到」的场景里做出错误决定。调度器可以把系统在两种几乎相同的全局状态之间来回摆,让任何确定性协议都无法安全地打破平局。

这不是说「共识不能做」。它说的是:安全性和活性不能同时无条件保证。工程系统的选择是:安全性质永远不妥协(绝不出现两个值、绝不丢已提交日志),活性性质只在「网络最终稳定、时钟漂移有界」的假设下保证。超时、随机化、leader 选举,都是在这个边界上做工程。

故障模型进程会怎样网络会怎样本章是否覆盖
崩溃-停止(crash-stop)崩溃后不再恢复可靠或可能丢是(简化)
崩溃-恢复(crash-recovery)崩溃后重启,内存丢失、磁盘保留可能丢、可能分区是(主模型)
遗漏(omission)可能漏发/漏收消息可能丢是(分区的一种)
网络分区进程都活着,但互相不可达双向/单向/非传递是
时钟故障时钟跳变、漂移正常是(lease read 的重点)
拜占庭任意撒谎、伪造任意否

超时在共识里扮演什么角色?它不是「证明对方死了」,而是一个不完美的故障检测器:在部分同步假设下,超时最终能发现真正崩溃的进程,但也会误判「只是慢」的进程。误判的代价是多余的选举,不是安全性的丧失——只要投票规则和 quorum 规则不被绕过。把超时当证据,是很多实现错误的起点;把超时当怀疑,才是正确用法。

quorum 是共识的另一个支柱。大多数协议用多数派(majority):N 个节点中需要 floor(N/2)+1 个。为什么是多数派?因为任意两个多数派必然相交。

N多数派大小能容忍的崩溃数 f备注
110没有容错,退化成本地日志
220任何一台挂掉都无法形成多数派
321最常见的入门配置
431成本比 3 节点高,容错没变
532生产常见;可跨三个可用区
642仍然只容忍 2,成本更高
743跨五个可用区时才会考虑

把相交性写成计数: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)            │    │    │    │

四个消息的语义:

  1. prepare(n):proposer 请求 acceptor 用一个比 n 小的提案号做承诺。
  2. promise(n, accepted):acceptor 承诺「不再接受小于 n 的提案」,并返回自己已经接受过的最大 ballot 及其值(如果有)。
  3. accept(n, v):proposer 请求 acceptor 接受「ballot 为 n、值为 v」的提案。
  4. 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 相交也救不了。

为什么必须这样做?看一个反例:

例 5.3.1 · 忽略已接受值会选出两个值

假设 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)比较,但两者解决的不是同一个问题:

维度Paxos2PC
目标让一组进程对值达成一致让多个资源要么全提交、要么全回滚
容错少数派故障仍可继续(有 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 的数量和冲突概率:

  1. 稳定 leader(distinguished proposer):同一时刻只允许一个节点发起提案;其他节点把请求转发给它。这也是 Raft 直接内置 leader 的原因。
  2. 随机退避:冲突后等待一个随机区间再重试,打破同步脉冲。
  3. ballot 空间分区:每个 proposer 使用 (epoch, node_id) 形式的 ballot,避免两个节点生成相同的 ballot;ballot 相同但值不同会直接破坏安全性。
  4. 抢占与租约: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-PaxosRaft
leader 选举协议之外,靠外部机制或额外 Paxos协议内置,term + 随机超时 + 投票
日志槽位,允许空洞连续索引,强日志匹配
提交规则每个槽位多数派接受当前 term 规则 + 多数派 matchIndex
成员变更通常靠外部配置管理联合共识/单步变更,写进日志
读路径需要额外设计(ReadIndex/lease)同样需要额外设计,协议不免费提供
可理解性论文较抽象,实现变体多为可理解性设计,状态机明确

这张表不是说 Paxos 更差。Paxos 更通用,适合需要灵活槽位、批量、跨地域优化的系统;Raft 把 leader 选举、日志、成员变更打包成一套更约束的协议,牺牲一些灵活性换取可实现性和可测试性。选型时不要问「哪个更强」,要问「团队能不能把它实现对、测出来」。

5.5 Raft:term、角色、选举

Raft 把共识拆成三个相对独立的部分:leader 选举、日志复制、安全性。它用 term(任期)把所有决策串起来,用随机化选举超时打破平票,用日志匹配性质保证「相同索引 + 相同 term ⇒ 前缀相同」。

Raft 的三个角色与迁移条件 Follower 在选举超时后成为 Candidate;获得多数票成为 Leader;发现更高 term 退回 Follower。 Raft 的三个角色与迁移条件 Follower 被动接收 AppendEntries Candidate term+1,拉票 Leader 心跳 + 复制 选举超时 获得多数票 发现更高 term 选举超时/平票 term 增大 收到更高 term 安全规则 · 每个 term 最多投一票 · 只给日志不旧于自己的候选人投票 · 只提交当前 term 的日志项(旧 term 间接提交)
读图:Raft 把「什么时候可以投票」「什么时候可以提交」绑定到 term 和日志新旧。图中每条边都对应一个必须检查的条件,少一个就会出现双主或丢日志。

Raft 的每个节点在任意时刻处于三种角色之一:

角色行为关键状态
Follower被动接收 AppendEntries/RequestVote,重置选举计时器currentTerm、votedFor、日志
Candidate超时后自增 term,给自己投票,向其他节点拉票获得票数、选举超时
Leader向所有 follower 发心跳和日志,处理客户端请求nextIndex[]、matchIndex[]

term 是一个单调递增的逻辑时钟。每个 term 最多有一个 leader;节点收到更大的 term 时,立即更新 currentTerm 并退回 follower;收到更小的 term 时拒绝。term 不是墙钟,不能用来判断「谁先发生」;它只用来给选举和日志条目划分纪元。

一次选举的步骤:

  1. follower 在选举超时(例如 150–300 ms 的随机值)内没有收到 leader 的心跳,进入 candidate。
  2. currentTerm++,给自己投一票,重置选举计时器。
  3. 向所有节点发 RequestVote(term, candidateId, lastLogIndex, lastLogTerm)。
  4. 接收方在以下条件都满足时投票:term >= currentTerm;本 term 还没投给别人(或已经投给该候选人);候选人的日志不旧于自己。
  5. 候选人拿到多数票,成为 leader;否则等待新的随机超时,term++ 后重来。
一次 Raft 选举:term 1 平票,term 2 选出 leader 三个节点在 term 1 各自拉票导致平票;随机超时后节点 B 在 term 2 先发起并赢得多数。 一次 Raft 选举:term 1 平票,term 2 选出 leader 节点 A 节点 B 节点 C term 1:三方同时超时 RequestVote RequestVote RequestVote 都没有拿到多数票 → 超时,term+1 term 2:B 先超时(随机 150–300 ms) RequestVote(term=2) RequestVote(term=2) 投票 投票 B 获得 2/3 票 → Leader AppendEntries(term=2) AppendEntries(term=2) 关键:每个节点在 term 2 只投一票;B 的日志必须不旧于投票者。
读图:随机化选举超时是打破平票的关键;没有它,三个节点可能在同一时刻反复拉票,形成活锁。图中 term 2 的 B 先超时,所以它拿到了 A 和 C 的票。

图里 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 interval50–100 ms必须小于 election timeout 的 1/3;建议留出 5–10 倍余量
election timeout150–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 复制日志:

字段含义作用
termleader 的任期follower 用来更新自己的 term
leaderIdleader 标识方便 follower 重定向客户端
prevLogIndex新条目前一条的索引一致性检查的锚点
prevLogTerm新条目前一条的 term一致性检查的锚点
entries[]要追加的条目可以为空(心跳)
leaderCommitleader 已知的提交索引follower 据此推进自己的 commitIndex

follower 的处理逻辑:

  1. 如果 term < currentTerm,拒绝。
  2. 如果 prevLogIndex 超过自己的日志末尾,拒绝。
  3. 如果 prevLogIndex 处的 term 不等于 prevLogTerm,拒绝。
  4. 删除自己日志中与 entries 冲突的后缀(相同 index 但不同 term 的条目及其之后的所有条目)。
  5. 追加新条目。
  6. 如果 leaderCommit > commitIndex,把 commitIndex 推进到 min(leaderCommit, 新日志末尾)。
  7. 按顺序应用索引不大于 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 个节点里不可能不相交。一次只改一个,就把「两个不相交多数派」的可能性排除了。

联合共识的流程:

  1. leader 收到配置变更请求,先把 C_old,new 写入日志并提交;从这一刻起,任何决定都需要 C_old 的多数派 和 C_new 的多数派同时同意。
  2. 在 C_old,new 提交后,再写入 C_new 并提交;之后只用 C_new 的多数派。
  3. 如果 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 的处理:

  1. 如果快照的 lastIncludedIndex 不大于自己的 commitIndex,忽略(自己已经更新)。
  2. 如果自己的日志在 lastIncludedIndex 处有匹配的 term,保留该索引之后的日志后缀。
  3. 否则清空整个日志,把快照作为新的起点。
  4. 把状态机重置为快照状态,更新 commitIndex、lastApplied、lastIncludedIndex/Term。
  5. 继续接收后续日志。

快照最容易出的三个错:

  • 快照了未提交的状态:状态机里可能包含还没被多数派确认的命令;恢复后这些命令可能被覆盖,状态就错了。快照必须基于 lastApplied,而 lastApplied 只能追到 commitIndex。
  • 截断太多:leader 把 follower 还需要的前缀日志删了,导致 follower 只能全量传快照;网络和磁盘压力骤增。通常要保留一段日志尾部,或者根据最慢 follower 的进度决定。
  • 把快照当备份:快照是日志压缩,不是灾难恢复。它和日志在同一块磁盘上,磁盘损坏时一起丢;真正的备份要独立存储、定期校验、可恢复演练(第 3 章)。

崩溃恢复的顺序同样重要:

  1. 读取最新快照,校验校验和与 lastIncludedIndex/Term;
  2. 把状态机恢复到快照状态,设置 commitIndex = lastApplied = lastIncludedIndex;
  3. 从 lastIncludedIndex + 1 开始重放日志,直到日志末尾;
  4. 只有 lastApplied 追上 commitIndex 后,才对外服务读;
  5. 向 leader 确认自己的日志/任期,接受后续 AppendEntries。

如果第 2 步把 commitIndex 设得比 lastApplied 大,又在第 4 步之前服务读,就会读到「提交了但没 apply」的旧状态;如果第 3 步重放时又 apply 了快照里已有的命令,就会重复执行副作用。快照恢复和日志重放必须严格按索引拼接,不能靠「大概对齐」。

5.8 线性一致读:ReadIndex、lease read、follower read

Raft 只保证「日志的顺序」,不自动保证「读是线性一致的」。一个 leader 可能在分区后仍然自认为 leader;一个 follower 可能落后很多;一个本地读可能绕过共识,直接返回旧值。

先把三种读路径摆出来:

路径做法线性一致?额外 RTT关键假设
本地读直接读本地状态机否0无;可能读到任意旧值
ReadIndexleader 确认多数派仍承认自己,再读已应用状态是1 次心跳往返多数派可达;不需要时钟
Lease readleader 在租约内直接读是(在租约假设下)0时钟漂移有界、进程不会被长暂停
Follower readfollower 向 leader 要 readIndex,等 apply 后读是(正确实现)1–2 次 RTTfollower 能联系 leader;不能本地猜

5.8.1 ReadIndex:用一次心跳确认「我还是 leader」

ReadIndex 的步骤:

  1. leader 必须已经提交过一条当前 term 的日志(没有的话,先提交一条 no-op)。这是为了确保 commitIndex 包含当前 term 的屏障,避免读到旧 term 的提交边界。
  2. 向多数派发送心跳(或带空 entries 的 AppendEntries),确认自己仍然是 leader。
  3. 记录 readIndex = commitIndex。
  4. 等待 lastApplied >= readIndex。
  5. 从状态机读取,返回结果和对应的 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 直接读本地」,而是:

  1. follower 向 leader 请求一个 readIndex(leader 走 ReadIndex 流程,或返回当前 commitIndex 并附上租约/任期证明)。
  2. follower 等待自己的 lastApplied >= readIndex。
  3. 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 压力;接受陈旧
低延迟、时钟可控、有 CheckQuorumlease 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」

同一条写,在分区前后有三个不同的结局:

  1. 分区前已经提交:它已经复制到多数派,新 leader 一定包含它(选举限制);客户端收到成功,数据不会丢。
  2. 分区后打到旧 leader:旧 leader 只能追加、不能提交,客户端收到超时/unknown;这条写可能被新 leader 覆盖,也可能在新 leader 上以新的提案重新提交。
  3. 分区后打到新 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 触发退位。

所以「分区后的旧主」要分三层处理:

  1. 提交层:写必须拿到多数派确认才回复成功;旧 leader 不能凭本地日志回复。Raft 的提交规则已经覆盖这一层,前提是实现正确(5.6)。
  2. 读层:线性一致读必须走 ReadIndex,或者使用有严格时钟/暂停假设的 lease read;串行化本地读要显式标注「可能陈旧」。
  3. 副作用层:旧 leader 对下游资源的操作必须带 fencing token(term/epoch/lease ID),资源端拒绝更小的 token;否则「旧主还在写」无法靠共识协议本身阻止。
旧主的三种「还以为自己是主」
  1. 真 leader,暂时失联:它还能联系多数派,只是某个 follower 不可达;它不是旧主,读路径仍然有效。
  2. 少数派 leader,尚未退位:它拿不到多数派,写不能提交;ReadIndex 会失败,但 lease read 可能还在窗口内。
  3. 已退位但客户端还在打它:它自己知道不是 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 INDEXleader 最高,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_secondsWAL fsync 延迟直方图与基线比;p99 持续升高异常磁盘 IO、fsync 争用、云盘抖动
etcd_network_peer_round_trip_time_secondspeer 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,或者干脆把操作放进同一个共识状态机里做条件写。

四条教训:

  1. 线性一致读要作为故障注入的必测项,不能只测「集群健康时 get/put 对不对」。分区、暂停、时钟偏移下读到的值,才是用户会遇到的。
  2. 每个 API 单独证明。KV 的线性一致不等于 lock 的互斥,不等于 watch 的 exactly-once,也不等于 lease 的活性;共识是底座,不是万能证明。
  3. 租约/锁必须有 fencing 或资源端校验。不要把「持有锁」当成「下游一定会接受我的写」;下游要能拒绝旧 token。
  4. 用可复现的历史检查器。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 也生效了。注意三个细节:

  1. 去重表和副作用必须在同一次 apply 中更新。如果先扣库存、再异步写 seen,中间崩溃会留下「扣了但没记住」的状态;恢复后重放又会扣一次。状态机 apply 应该是单线程、确定性的,去重表和业务状态一起进入快照。
  2. client_id/seq 必须由客户端生成,并在所有重试中复用。服务端生成的自增 ID 每次都会变,无法去重(第 1 章的逻辑请求/物理尝试)。
  3. 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 小时」了事。

提案 ID 不是请求 ID

有些 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 本章陷阱清单

12 条高频错误
  1. 把共识当成「所有节点都同意」:共识只要求多数派参与决策;要求全体一致会让单机故障阻塞整个系统。
  2. 把「多数派有日志」当成「已提交」:提交是协议动作;旧 term 条目即使多数派有,也可能被后续 leader 覆盖(5.6)。
  3. 用 term 大小判断日志新旧:先比最后 term,再比最后 index;只看 term 或只看 index 都会选错 leader。
  4. 投票后不落盘就回复:重启后重复投票,同一 term 可能出现两个 leader(5.13)。
  5. 选举超时太小或没有随机化:频繁选举、平票、活锁;超时必须远大于 RTT,并加随机抖动。
  6. 以为写走 Raft 读就自动线性一致:本地读/串行化读可能陈旧;线性一致读要走 ReadIndex 或受约束的 lease read(5.8)。
  7. lease read 不看时钟和暂停:GC 停顿、时钟跳变会让旧 leader 在租约过期后继续服务读。
  8. 成员变更直接切换:旧/新多数派可能不相交,出现双 leader;要用单步变更或联合共识(5.7)。
  9. 快照未提交状态/截断太多:恢复后状态回退,或慢 follower 只能全量传快照;快照必须基于 lastApplied,并保留必要日志尾部。
  10. 重试时换 request id:每次重试生成新 ID,状态机无法去重;请求 ID 必须由客户端生成并在所有重试中复用(5.12)。
  11. 去重只放在网关/leader 内存:leader 切换或进程重启后去重状态丢失,重复提案照样执行。
  12. 只看 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 85.6
设计成员变更能说出单步变更/联合共识的适用场景和风险5.7
设计读路径能对比 ReadIndex/lease read/follower read 的假设和代价5.8
处理旧主分区能区分「接受/提交/读/外部副作用」四层,并给出止损5.9
读 etcd 指标能解释 has_leader、leader_changes、RAFT TERM、apply gap5.10
做状态机去重能写出 (client_id, seq) 去重表,并说明快照/TTL5.12

如果你要动手做第 13 章的项目二(小型复制 KV),本章的检查顺序是:先实现选举和日志复制,再用「当前 term 提交」规则做提交,然后加 ReadIndex 读,最后加状态机去重和快照。不要先做 lease read:它看起来省了一次 RTT,实际上把时钟/暂停的假设引入了系统,调试成本远高于 ReadIndex。

即时自测 · 5

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,看单机事务、分布式事务和共识日志如何组合,以及为什么「重试一次事务」会把一次转账做成两次。