Skip to content

共识算法:主节点换了,已经答应的事不能反悔 ​

把同一份数据复制到三台机器,能防止一台机器宕机后数据完全不可用。但如果主节点在“已经写给部分副本、还没向所有人确认”时退出,新主该保留哪些记录?客户端收到成功的写入,会不会在换主后消失?这才是共识算法要解决的核心问题。

本文用 Raft 讲清选主、日志复制与安全性,再用 Paxos 和 ZAB 对照它们各自组织问题的方式。要记住的是协议如何把旧决定带进新一轮,而不只是“超过半数就成功”。示例只验证几个有界条件,不是 Raft、Paxos 或 ZAB 的实现。

先认清三层:决定、日志、业务状态 ​

单次共识让参与者对一个值达成一致。复制状态机则需要不断确定命令顺序:先决定日志位置 1 的命令,再决定位置 2,依次应用到同样的初始状态。Raft 直接围绕这条日志组织;Basic Paxos 先描述一个值如何被选定,Multi-Paxos 再把它用于多个日志位置。[1][2]

正在绘制图示…

可切换适合窗口或原始尺寸
查看图源代码
flowchart LR
    C[客户端命令] --> L[领导节点接收]
    L --> R[复制日志与取得协议所需确认]
    R --> K[确认某段日志可提交]
    K --> A[按索引应用到状态机]
    A --> O[返回业务结果]
    R -. 未提交尾部可能被替换 .-> U[换主后的日志修复]
放大图示

原始尺寸下可横向或纵向滚动;按 Escape 关闭。

正在绘制图示…

查看图源代码
flowchart LR
    C[客户端命令] --> L[领导节点接收]
    L --> R[复制日志与取得协议所需确认]
    R --> K[确认某段日志可提交]
    K --> A[按索引应用到状态机]
    A --> O[返回业务结果]
    R -. 未提交尾部可能被替换 .-> U[换主后的日志修复]
名称回答的问题常见混淆
已写入日志某个副本是否保存了这条记录?一台机器有记录,不等于集群已决定
已提交,committed协议是否保证以后不能改掉这个决定?不能把任意多数派持有任意旧记录都叫已提交
已应用,applied状态机是否已经执行到该位置?已提交但还未应用的节点,直接读状态仍可能落后
客户端收到成功响应是否到达调用方?没收到成功不代表没提交;重试可能造成重复业务动作

复制状态机要求执行同一命令得到一致效果。当前时间、随机数和外部服务响应若影响状态,通常要把选定结果放进日志,或由应用设计确定性的处理方式。协议负责命令顺序,不能替应用修复不确定的业务代码。[1]

多数派交集有用,但它只是第一块积木 ​

固定 5 个投票节点,任意两个 3 节点集合至少相交 1 个。这个交集让新一轮有机会接触旧一轮的信息。我们的模型枚举全部大小为 3 的集合,检查每一对:

go
p, d := majorityIntersections(5)
fmt.Println("5 voters: quorum pairs =", p, "disjoint =", d)

实际输出:

text
5 voters: quorum pairs = 100 disjoint = 0

共有 10 种三节点集合,所以有 100 个有序集合对。这只证明这个固定集合的组合性质。要让它支持协议安全性,交集中的节点还得记得自己承诺过什么,并按规则拒绝冲突请求。

典型固定配置的 Raft 用 2f+1 个投票节点,在最多 f 个节点不可用且剩余节点能正常通信等条件下仍可推进;多数派不是容忍任意拜占庭行为的阈值。不要据此推导所有 Paxos 变体都要求每个阶段相同大小的简单多数派,或 ZooKeeper 只能采用一种 quorum 配置。[1][2][3]

Raft 选主:选票带着任期,也带着历史约束 ​

Raft 节点有 follower、candidate、leader 三种角色。任期 term 是逻辑编号,用来区分一轮轮领导权,不是日期或租约到期时间。节点看见更高任期,就更新任期并退回 follower。超时只是怀疑当前领导失联,并不证明旧 leader 已死。[1,§5.1–5.2]

正在绘制图示…

可切换适合窗口或原始尺寸
查看图源代码
stateDiagram-v2
    [*] --> Follower
    Follower --> Candidate: 选举超时,任期加一并自投
    Candidate --> Leader: 获得该配置多数票
    Candidate --> Candidate: 再次超时,发起新任期
    Candidate --> Follower: 收到有效领导消息或更高任期
    Leader --> Follower: 发现更高任期
放大图示

原始尺寸下可横向或纵向滚动;按 Escape 关闭。

正在绘制图示…

查看图源代码
stateDiagram-v2
    [*] --> Follower
    Follower --> Candidate: 选举超时,任期加一并自投
    Candidate --> Leader: 获得该配置多数票
    Candidate --> Candidate: 再次超时,发起新任期
    Candidate --> Follower: 收到有效领导消息或更高任期
    Leader --> Follower: 发现更高任期

每个节点在一个任期内最多把票给一个候选人。任期和投票记录必须在相应响应之前持久化,否则重启后“忘了投过票”,同一任期可能再次帮助另一个候选人取得多数派。

模型只保留这条投票规则:

go
v := Vote{}
fmt.Println("same term A then B:", v.grant(7, "A"), v.grant(7, "B"))
v = Vote{} // 错误重启:把已持久化状态也丢了
fmt.Println("lost vote after restart, B:", v.grant(7, "B"))

实际输出:

text
same term A then B: true false
lost vote after restart, B: true

这段故意错误的“重启”没有演出整个双主集群;它展示的是安全论证所需的前提已经被破坏。磁盘持久化不是一个可晚点补上的性能选项。[1,图 2][4]

候选人还必须通过日志新旧检查。Raft 比较最后一条日志的 (term, index):先比任期,任期相同再比索引。于是 (term=2,index=100) 不比 (term=3,index=10) 新。只数日志条数会选错人。

go
fmt.Println("longer but older tail:", upToDate(Tail{2, 100}, Tail{3, 10}))
text
longer but older tail: false

这是为什么选主不能简化成“先超时的人获胜”:先发起只是获得机会,最终还要得到满足投票规则的多数支持。随机化选举超时主要减少撞在一起的选举,提高活性;日志限制和投票规则才承担安全性职责。论文里的毫秒示例不是适用于所有机房的默认调参答案。[1,§5.2、§5.4.1]

Raft 复制:先确认共同前缀,再补尾部 ​

leader 发 AppendEntries 时,不只发送新记录,还携带新记录之前的 prevLogIndex 和 prevLogTerm。follower 先检查那里是否一致;不一致就拒绝。leader 据反馈找到共同前缀,再修复冲突的未提交尾部。[1,§5.3]

假设 leader 的日志任期序列是 [1,1,3,3],follower 是 [1,1,2]。位置 2 一致,位置 3 冲突。修复从冲突位置开始;不是把两份日志按长度拼起来,也不是删除每个暂时多出来的条目就算完成。协议保证同一索引、同一任期的记录所对应的前缀相同,这叫日志匹配性质。

正在绘制图示…

可切换适合窗口或原始尺寸
查看图源代码
sequenceDiagram
    participant L as Leader
    participant F as Follower
    L->>F: prev=(3,3),追加位置 4
    F-->>L: 拒绝:本地位置 3 的任期为 2
    L->>F: prev=(2,1),发送位置 3、4
    Note over F: 前缀匹配后替换冲突尾部并持久化
    F-->>L: 确认复制到位置 4
    Note over L: 按提交规则更新 commitIndex
    L->>F: 后续消息携带提交位置
    Note over F: 按顺序应用已提交日志
放大图示

原始尺寸下可横向或纵向滚动;按 Escape 关闭。

正在绘制图示…

查看图源代码
sequenceDiagram
    participant L as Leader
    participant F as Follower
    L->>F: prev=(3,3),追加位置 4
    F-->>L: 拒绝:本地位置 3 的任期为 2
    L->>F: prev=(2,1),发送位置 3、4
    Note over F: 前缀匹配后替换冲突尾部并持久化
    F-->>L: 确认复制到位置 4
    Note over L: 按提交规则更新 commitIndex
    L->>F: 后续消息携带提交位置
    Note over F: 按顺序应用已提交日志

图只画一个 follower,实际提交要看该配置需要的 quorum。延迟消息和重复消息必须通过任期、索引及已有状态判断;“收到一次 ACK 就把计数加一”会把重复响应当成多个节点。

为什么旧任期日志在多数节点上,也不能直接按票数提交? ​

Raft 的 leader 用复制数量直接推进提交位置时,要求该位置上的日志属于当前任期。旧任期的记录可能经历过不同领导的复制过程,单看它当前分布在哪些机器上,不足以排除后续合法选举覆盖它。原论文图 8 给出了这种反例。[1,§5.4.2]

go
fmt.Println("term 2 entry in term 4, 3/5:", directCommit(2, 4, 3, 5))
fmt.Println("term 4 entry in term 4, 3/5:", directCommit(4, 4, 3, 5))

实际输出:

text
term 2 entry in term 4, 3/5: false
term 4 entry in term 4, 3/5: true

第二行确认当前任期的某条记录可提交后,它之前的日志也随前缀一起提交。第一行并不是说旧记录永远不能提交;限制针对的是“仅凭副本计数直接提交该旧记录”这一步。这个条件属于 Raft,不能把它写成 Paxos、ZAB 的统一规则。

安全性不是一条多数公式 ​

Raft 的证明把几条性质连在一起:同一任期至多选出一个 leader;leader 自己的日志只追加;日志匹配;已提交条目出现在后来任期的 leader 中;各状态机不会在同一位置应用不同命令。选举限制与当前任期提交规则共同支撑后两条。[1,图 3、§5.4]

工程问题为什么仅看“写到多数”不够还需要什么
leader 与多数派隔离但仍在运行它可能继续相信自己是 leader新写无法获得合法确认;读也必须确认领导权或满足安全租约条件
follower 已保存日志但未应用内存状态落后于已提交位置读屏障与 applied index 检查
重启后投票丢失同一任期可能重新投票正确持久化任期、票和日志,遵守发送时序
客户端超时重试同一业务命令可能被提交两次客户端 ID/序列号、结果缓存或业务幂等
集群成员变化旧配置多数与新配置多数可能不相交按协议做配置转换,不能同时随意替换一半节点

Raft 论文的 joint consensus 通过重叠配置,要求转换期同时满足旧、新配置的多数条件;具体库可能提供特定的配置变更流程,应遵守该实现的约束。[1,§6][4] “三节点改成另三个节点”不是修改一个配置文件就完成的事。

快照解决日志无限增长的问题,但也必须记住它覆盖到哪个索引和任期、相应的状态及配置。把某个节点的任意数据库文件拷给别人,不等于完成 Raft 的 InstallSnapshot。[1,§7]

线性一致读取也有额外条件。读者至少需要知道自己读的节点没有落在一个失去领导权的旧世界,并且状态机已应用到足够新的位置。新当选的 leader 还必须先确定提交前缀,论文的办法是先提交一条当前任期的记录,通常是初始 no-op;然后通过与多数派交换心跳确认领导权,并等状态机应用到读取屏障要求的位置。Raft 论文讨论这一过程,也讨论依赖时间假设的租约优化;etcd/raft 的 README 则区分 ReadIndex 和 lease-based read,后者明确依赖时钟条件。[1,§8][4] 不能把“发给 leader”当作无条件的线性一致性证明。

Paxos:新提案必须继承可能已经选定的值 ​

Basic Paxos 常用三类角色:proposer 提议值,acceptor 记承诺并接受提案,learner 获知最终选定的值。一台进程可以同时承担多个角色。“提案编号”区分竞争轮次;它不是日志索引,也不等于 Raft 的任期。[2]

阶段发起者做什么acceptor 必须记住什么为什么需要
Phase 1:Prepare / Promise选择新的、足够大的唯一编号,发 Prepare 并收齐多数 acceptor 的 Promise承诺不再接受更小编号;返回自己已经接受的最高编号和值让新提案看到旧决定可能留下的证据
Phase 2:Accept / Accepted在收齐多数 Promise 后,若其中有已接受值则选最高编号的值;否则可选新值在没有更高承诺阻止时接受 (编号, 值)新轮不能任意改掉已选定的内容
学习结果获知某个值已被多数 acceptor 接受而选定决定与业务应用状态“某节点接受过”与“值已被选定”不同

假设 A、B 已接受编号 5 的值 X。假设共有 A、B、C 三个 acceptor,新的 proposer 用编号 6 向 B、C 发 Prepare 并收到这两个节点的 Promise,B 会把 X 带回来。proposer 不能说“我是新领导,所以改成 Y”;它必须按 Phase 1 收到的最高编号已接受提案来选择值。这一继承规则加上 quorum 交集,才阻止两个不同值都被选定。[2,§2.2]

Paxos 的安全性不要求每一轮都能迅速完成,但多个 proposer 不断用更高编号互相打断,可能没有进展。稳定领导者可以减少这种争抢。Multi-Paxos 在稳定领导等条件满足后,让同一领导处理多个日志位置,减少反复进行准备阶段的开销;换领导时仍须正确恢复每个位置的信息,不能把“只做一次 Prepare”理解为永久免检。[2,§3]

Paxos 与 Raft 都能支撑复制服务。Raft把领导选举与日志结构规定得更直接;Paxos 的基本表述更小,组成完整服务时还需决定日志、领导管理、学习与重配置方式。这里没有测它们的性能,不能根据协议图上的箭头数量推导某个实现一定更快。

ZAB:新主先接上旧历史,再广播新的状态变化 ​

ZAB 是 ZooKeeper 为主备复制设计的崩溃恢复原子广播协议。它关注 primary order:同一个 primary 的增量状态变化必须保持顺序;换 primary 后,新变化必须接在协议要求保留的旧历史之后。ZooKeeper 的 primary 可以流水线地产生多个尚未全部确认的更新,因此恢复不能只找到“一个看似最新的主”。[5]

论文把协议组织为 discovery、synchronization、broadcast 三个阶段。选出候选 primary 之后,发现阶段收集历史并建立新的 epoch;同步阶段让参与 quorum 的节点采用合适的历史前缀;达到协议规定的激活条件后,新 primary 才开始广播新变化。选举结束与可安全接收新写入之间,还隔着恢复和同步。

正在绘制图示…

可切换适合窗口或原始尺寸
查看图源代码
flowchart LR
    E[候选 primary] --> D[发现:收集 epoch 与历史]
    D --> S[同步:建立应保留的历史前缀]
    S --> Q[达到激活所需确认]
    Q --> B[广播:分配 zxid 并复制更新]
    B --> C[满足确认条件后提交]
    B -. 主故障或失去支持 .-> E
放大图示

原始尺寸下可横向或纵向滚动;按 Escape 关闭。

正在绘制图示…

查看图源代码
flowchart LR
    E[候选 primary] --> D[发现:收集 epoch 与历史]
    D --> S[同步:建立应保留的历史前缀]
    S --> Q[达到激活所需确认]
    Q --> B[广播:分配 zxid 并复制更新]
    B --> C[满足确认条件后提交]
    B -. 主故障或失去支持 .-> E

ZooKeeper 的 zxid 将 epoch 和递增计数组合起来标识事务顺序。这能帮助识别历史前缀及缺失更新,但只比较一个最大数字并不能替代完整恢复协议。固定版本的官方内部说明同时解释了 leader activation、原子广播与 quorum 配置。[3]

还要区分协议和对外读接口。ZooKeeper 的写入顺序依赖复制协议,而读取可以由本地服务器提供,可能看到落后的状态;同一客户端的顺序保证不能扩展成“所有客户端每次读都线性一致”。sync、会话顺序和写后读应按 API 文档及实际故障条件分析。[3][6] 不能因为底下用了 ZAB,就把所有上层操作一律标为强一致。

选实现时,审协议之外的那一半 ​

需要回答的问题可以要求的证据
哪些状态先落盘、哪些消息后发送?固定版本的持久化接口说明、崩溃恢复测试
有哪些故障不会丢已确认数据?明确的磁盘、节点、网络故障模型,而非一句“三副本”
读路径承诺什么?普通读、线性一致读、租约读的不同前提
客户端重试会怎样?相同请求 ID 重复、不同 payload 同 ID、响应丢失测试
如何备份、恢复、扩缩容?经演练的快照和成员变更流程
落后副本如何追赶?日志/快照切换、带宽限制、恢复时间和容量余量

自写几十行模型可以帮助理解一条不变量,但生产级共识还要面对磁盘写失败、消息重排、重启、快照、配置变更和应用执行。对于普通 Go 业务,优先使用成熟存储或经过充分验证的库,并把精力放在自己的状态机、幂等及恢复语义上。这个建议假设团队主要交付业务系统;研究协议本身则需要更完整的规范、模型检查和故障测试。

依据与验证范围 ​

  1. Ongaro、Ousterhout:In Search of an Understandable Consensus Algorithm,扩展版,图 2、图 3、图 8,§5–8:选举、复制、安全性、成员变更、快照、读和客户端重试。
  2. Lamport:Paxos Made Simple,§2–3:角色、两阶段、值继承与多次共识。
  3. ZooKeeper 3.9.3 Internals:leader activation、zxid、quorum 与一致性说明。
  4. etcd/raft v3.6.0 README:实际库的持久化顺序、ReadIndex、租约读和成员变更边界;这里引用固定 tag,不声称它是最新版本。
  5. Junqueira、Reed、Serafini:Zab: High-performance broadcast for primary-backup systems,§III–V:primary order、discovery/synchronization/broadcast。
  6. ZooKeeper 原论文,2010,§2:面向客户端的一致性及读取设计。

本次 Go 1.27.2、Linux/amd64 实测:运行、普通测试、race 测试及 vet 均通过。模型枚举 1–9 个投票节点的固定多数集合交集;检查 Raft 的局部投票、日志尾部比较和直接提交条件;错误重启反例直接清空内存投票。模型没有磁盘、网络、完整 Raft 状态机、Paxos/ZAB 执行、成员变更或协议级安全证明。未启动任何真实共识集群,未做吞吐/延迟比较。

完整可复现实验 ​

把以下三个文件放在同一空目录。只依赖标准库,不需要外部服务。

sh
go run .
go test -count=1 -v ./...
go test -race -count=1 ./...
go vet ./...

go.mod ​

mod
module example.com/distributed/consensus

go 1.27.2

main.go ​

代码块 · 71 行 · 点击收起 / 展开
go
package main

import (
	"fmt"
	"math/bits"
)

func majorityIntersections(n int) (pairs, disjoint int) {
	if n < 1 || n > 10 {
		panic("bounded model: 1..10 voters")
	}
	q := n/2 + 1
	for a := 1; a < 1<<n; a++ {
		if bits.OnesCount(uint(a)) != q {
			continue
		}
		for b := 1; b < 1<<n; b++ {
			if bits.OnesCount(uint(b)) != q {
				continue
			}
			pairs++
			if a&b == 0 {
				disjoint++
			}
		}
	}
	return
}

type Tail struct{ Term, Index int }

func upToDate(candidate, voter Tail) bool {
	return candidate.Term > voter.Term || candidate.Term == voter.Term && candidate.Index >= voter.Index
}

// Tests only the leader's direct commit predicate, not all Raft safety rules.
func directCommit(entryTerm, currentTerm, replicas, voters int) bool {
	return entryTerm == currentTerm && replicas > voters/2
}

type Vote struct {
	Term      int
	Candidate string
}

func (v *Vote) grant(term int, candidate string) bool {
	if term < v.Term {
		return false
	}
	if term > v.Term {
		v.Term = term
		v.Candidate = ""
	}
	if v.Candidate != "" && v.Candidate != candidate {
		return false
	}
	v.Candidate = candidate
	return true
}
func main() {
	p, d := majorityIntersections(5)
	fmt.Println("5 voters: quorum pairs =", p, "disjoint =", d)
	fmt.Println("term 2 entry in term 4, 3/5:", directCommit(2, 4, 3, 5))
	fmt.Println("term 4 entry in term 4, 3/5:", directCommit(4, 4, 3, 5))
	fmt.Println("longer but older tail:", upToDate(Tail{2, 100}, Tail{3, 10}))
	v := Vote{}
	fmt.Println("same term A then B:", v.grant(7, "A"), v.grant(7, "B"))
	// Incorrect restart: a new zero value models losing the persisted vote.
	v = Vote{}
	fmt.Println("lost vote after restart, B:", v.grant(7, "B"))
}

main_test.go ​

go
package main

import "testing"

func TestMajorityIntersection(t *testing.T) {
	for n := 1; n <= 9; n++ {
		_, d := majorityIntersections(n)
		if d != 0 {
			t.Fatal(n)
		}
	}
}
func TestRaftPredicates(t *testing.T) {
	if directCommit(2, 4, 5, 5) || directCommit(4, 4, 2, 5) || !directCommit(4, 4, 3, 5) {
		t.Fatal("commit predicate")
	}
	if upToDate(Tail{2, 100}, Tail{3, 10}) || !upToDate(Tail{3, 10}, Tail{3, 9}) {
		t.Fatal("tail ordering")
	}
	v := Vote{}
	if !v.grant(7, "A") || v.grant(7, "B") || v.grant(6, "A") || !v.grant(8, "B") {
		t.Fatal("vote")
	}
}

保留各书来源与许可 · 示例运行条件以正文为准