元数据卡
- 前置知识:第1章(分布式系统基本问题)、第2章(RPC 通信)
- 预计时间:50 分钟
- 核心难度:深入
- 阅读模式:高度专注(建议完整阅读,不要中断)
- 完成标志:能够画出 Raft 的状态转换图,说清 Leader 选举和日志复制的完整流程,理解 Paxos 的核心直觉,知道 ZooKeeper 怎么用 ZK 实现选主和配置管理
你的进度
你已经可以指挥两个堡垒之间通信了——消息格式规范,传输通道可靠。但你的战报地图上摆了 5 座前哨站,每座站都有自己的哨兵日志。现在的问题不是"怎么传数据",而是"5 个站在同一件事上能否达成一致"。
林将军的地图上,目标位置标了一个红叉。他说:"五队人马必须到达这个位置。不是四队,不是三队——五队。如果有一队没到,这次突击就无效。"
这个命令翻译成分布式系统的语言就是:共识。多个节点就一个值达成一致,而且这个一致是不可撤销的。
你的任务
掌握分布式共识的三个层次:理解 Raft 协议的全部核心机制(Leader 选举、日志复制、安全性),建立 Paxos 的直觉,了解 ZAB 协议的基本思想。之后你会看到这些协议在 etcd 和 ZooKeeper 中的具体应用。
破局 · 溯源
先遇问题:为什么要共识?
你在单机上实现一个计数器:
// 单机计数器
public class Counter {
private int value = 0;
// 完全正确
public synchronized int increment() {
return ++value;
}
}现在你把计数器部署到 3 台机器上。客户端随机访问任一节点:
节点 A: 收到 increment() → value = 1
节点 B: 收到 increment() → value = 1 // 不对,应该是 2!
节点 C: value 还是 0没有共识,计数器是错的。更严肃的场景:选主。5 台机器要选出一台当 Leader,如果两台机器都认为自己是 Leader,系统就会出现"脑裂"——两个指挥中心各自发令,系统陷入混乱。
你需要一个协议:所有正确的节点,对某一个值(谁当 Leader、计数器是多少、配置项是什么),最终做出相同的决定。
Raft:可理解的共识算法
Raft 是 Diego Ongaro 在 2013 年提出的共识协议。它的核心目标不是比 Paxos 快(实际上它们等价),而是可理解。Raft 把共识问题分解为三个相对独立的子问题:
- Leader 选举
- 日志复制
- 安全性
Raft 中的节点角色
每个节点在任意时刻处于三种状态之一:
Follower ──超时未收到 Leader 心跳──→ Candidate
Candidate ──获得多数票───────────────→ Leader
Leader ──发现更高 Term 的节点────────→ Follower
Candidate ──选举超时或收到更高 Term───→ Follower节点状态图(纯文本表示):
+------------------+
| Follower |<----------+
+---+----------+---+ |
| | |
超时无心跳 发现Leader |
| | |
v | |
+---+---+ | |
|Candidate|----+ |
+---+---+ |
| |
获得多数票 |
| |
v |
+---+---+ |
| Leader |---------------------+
+--------+ 发现更高TermTerm(任期)
Raft 把时间划分为一个个 Term,Term 编号单调递增。每个 Term 最多有一个 Leader。没有 Leader 的 Term(选举失败)称为"无领导任期"。
Term 1 Term 2 Term 3 Term 4
[Leader A] [选举失败] [Leader B] [Leader B]
| | | |
+-----------+-------------+-------------+------> 时间Term 是 Raft 安全性的核心——任何节点在切换角色时都必须检查 Term 编号:低 Term 的节点不能影响高 Term 的决策。
Leader 选举:详细流程
当 Follower 在一段时间(150-300ms 随机)内没有收到 Leader 的心跳,它会:
- 增加自己的 Term
- 切换为 Candidate
- 给自己投票
- 向所有节点发送 RequestVote RPC
其他节点收到 RequestVote 后,检查三个条件:
- 对方的 Term 是否 >= 自己的 Term?
- 自己在这一 Term 中还没有投过票?
- 对方的日志至少和自己一样新?
如果全部满足,则投票给对方。如果 Candidate 获得超过半数节点的投票,它就当选为 Leader。
如果多个 Candidate 发起选举,选票分散,没有人获得多数——这时选举超时,Term 递增,开始新一轮选举。选举超时是随机的(150-300ms),这大大减少了多个 Candidate 再竞选的概率。
// Raft Election Timer - 伪代码
// 不是完整的 Raft 实现,只展示选举超时逻辑
public class RaftNode {
private int currentTerm = 0;
private String votedFor = null;
private Role role = Role.FOLLOWER;
private final Random random = new Random();
private final long electionTimeoutMin = 150; // ms
private final long electionTimeoutMax = 300; // ms
private long electionDeadline;
public void startElectionTimer() {
long timeout = electionTimeoutMin +
random.nextLong(electionTimeoutMax - electionTimeoutMin);
electionDeadline = System.currentTimeMillis() + timeout;
}
public void checkElectionTimeout() {
if (role == Role.LEADER) return;
if (System.currentTimeMillis() < electionDeadline) return;
// 超时了,开始选举
currentTerm++;
role = Role.CANDIDATE;
votedFor = "self";
startElectionTimer(); // 重置,防止重复触发
// 向所有节点发送 RequestVote RPC(略)
sendRequestVote();
}
}日志复制:Leader 主导一切
当选的 Leader 是所有写入的入口。客户端写入流程:
- 客户端只向 Leader 发送写请求
- Leader 把操作追加到自己的日志
- Leader 向所有 Follower 发送 AppendEntries RPC
- Follower 把日志追加到本地(发送响应)
- Leader 收到多数节点的成功响应后,提交这条日志(应用到状态机)
- Leader 在下一个心跳中通知 Follower 这条日志已提交
日志结构示意:
机器 | 日志条目
-----+---------------------------------------------------------
A | Term 1: x=3 | Term 1: y=5 | Term 2: x=7 | Term 2: z=9 |
B | Term 1: x=3 | Term 1: y=5 | Term 2: x=7 | Term 2: z=9 |
C | Term 1: x=3 | Term 1: y=5 | Term 2: x=7 | |
D | Term 1: x=3 | Term 1: y=5 | | |
| ^commitIndex=2
| (已提交到索引 2)注意节点 C 和 D 还未收到最新的日志。Leader 会持续重试 AppendEntries 直到所有节点追上。
为什么写入一定要经过 Leader?
Raft 不同于 Paxos——它极度简化了写入路径。所有写入通过 Leader 走,消除了 Paxos 中多阶段并发的复杂性。
代价是什么?如果 Leader 挂了,写操作被阻塞,直到新 Leader 选出。这是 CP(一致性优先)的取舍。AP 系统(如 Cassandra)优先可用,但需要处理冲突。
安全性:Raft 的核心保证
Raft 需要保证选出的 Leader 拥有所有已提交的日志。如果选出一个日志落后的人当 Leader,它可能会覆盖已提交的日志——这是灾难。
Raft 的解决方案体现在 RequestVote RPC 的投票条件中:
Candidate 的日志至少和大多数节点一样新,才能获得投票。
"更新"的定义:
- 如果 Term 不同,Term 更大的日志更新
- 如果 Term 相同,日志更长的更新
这个条件保证了:如果一条日志已经被提交(复制到多数节点),那么获得多数票的 Candidate 一定包含了这条日志。
证明了这一点,Raft 的安全性就成立了——任何已提交的条目都不会在后续 Term 中被覆盖。
日志一致性检查
当新 Leader 开始向 Follower 复制日志时,它先找到每个 Follower 与自己的日志"分叉"的位置,然后强制 Follower 复制自己的日志。这种"Leader 覆盖"的机制简化了实现,但代价是 Follower 上尚未提交的本地日志会被丢弃。
对比窗口:Raft 与 Paxos 的直觉
Paxos 是 Lamport 在 1998 年提出的共识协议。它是理论的里程碑,但实现起来极其困难。
Paxos 的核心直觉比它的实现要简单得多。看一个故事化的版本:
你所在的小组需要决定明天去哪儿团建。大家都能提出建议。一个"Paxos 提议"的流程:
- Prepare:你告诉所有人:"我想提一个建议,在我的年代号是 N。如果有人已经接受过年代号大于 N 的建议,请告诉我,否则请保证不再接受年代号小于 N 的建议。"
- Promise:大家回复"年代号 N 我记住了,我会忽略后面年代号更小的建议。我没有接受过任何年代号大于 N 的建议(或者说我有接受的 V 值)。"
- Accept:如果没有收到更大的值,你提出"大家去爬山";如果你已经知道别人提议过"去烤肉",你改提"去烤肉"。
- Accepted:大家回复确认。
Paxos 用两轮 RPC(Prepare-Promise 和 Accept-Accepted)确保:一旦一个值被多数派接受,所有后续的提议都必须接受同一个值。
Paxos 只解决"对一个值达成共识"——这就是 Single-Paxos。Multi-Paxos 把多个 Paxos 实例串起来,但 Lamport 的论文没有给出工程细节,所以每个实现都是自己的理解。
Raft 相对于 Paxos 的核心改进:
| 问题 | Paxos | Raft |
|---|---|---|
| 理解难度 | 理论优雅,实现艰难 | 为可理解性设计 |
| Leader 角色 | 可选优化,非核心 | 核心,写入必经之路 |
| 日志连续性 | 不保证 | 严格连续(Log Append Only) |
| 成员变更 | 复杂(论文没详讲) | 两阶段联合共识 |
| 工程参考实现 | 无标准参考 | Diego 的论文附完整伪代码 |
ZAB:ZooKeeper 的原子广播
ZAB(ZooKeeper Atomic Broadcast)是 ZooKeeper 内部使用的共识协议。它和 Raft 很像,但有一个关键区别:
- Raft 的 Leader 选举与日志复制是分离的两个阶段
- ZAB 的 Leader 选举是"发现阶段"——新 Leader 必须让所有 Follower 的日志与自己的同步,然后才能对外服务
这种设计让 ZAB 更专注于一个目标:原子广播——保证所有节点以相同顺序看到相同的变更。
ZooKeeper 怎么用 ZAB?
ZooKeeper 对外暴露的文件系统 API(znode)。但 znode 的操作背后是 ZAB 在驱动:
客户端写 /config/db_url = "jdbc:postgresql://..."
|
v
ZK Leader 接收请求
|
v
通过 ZAB 广播到集群(Follower 日志复制)
|
v
多数节点确认后,提交并响应客户端ZooKeeper 的典型使用场景:
- 配置管理:把配置写入 znode,所有监听的客户端立即收到变更通知
- 选主:多个候选者尝试创建同一个临时 znode,谁创建成功谁就是 Leader
- 分布式锁:通过临时顺序 znode + Watch 机制实现
// ZooKeeper 选主示例(简化)
// 多个节点竞争成为 Leader
// 注意:实际生产推荐使用 Curator 库
import org.apache.zookeeper.*;
public class LeaderElection implements Watcher {
private static final String ELECTION_NODE = "/election";
private ZooKeeper zk;
private String currentZnode;
public void runForElection() throws Exception {
zk = new ZooKeeper("localhost:2181", 3000, this);
// 创建临时顺序节点
currentZnode = zk.create(
ELECTION_NODE + "/candidate-",
new byte[0],
ZooDefs.Ids.OPEN_ACL_UNSAFE,
CreateMode.EPHEMERAL_SEQUENTIAL
);
// 检查自己是不是序号最小的节点
checkLeader();
}
private void checkLeader() throws Exception {
// 获取所有候选者
// 序号最小的成为 Leader
// 其余监听前一个节点的删除事件
}
}常见陷阱
Raft 选举超时的设计。 大部分人设置心跳间隔为 100ms,选举超时为 300ms,假设网络延迟稳定。但在高延迟网络上(跨区域部署),这个配置导致频繁选举。经验值:选举超时至少是网络 RTT 的 10 倍。
只部署 2 个节点。 你需要多数派才能提交——2 个节点中多数派是 2,任何一台挂了整个集群就停了。最少 3 个节点(多数派 = 2),奇数个节点推荐(3,5,7),偶数个节点只会浪费资源,不会提高容错。
把"已回复客户端"当成"已持久化"。 Raft 日志在内存中时,如果节点挂了数据就丢了。即使 Raft 说"已提交",也要等服务端把日志写入磁盘才算持久化。etcd 的
--sync选项就是这个作用。测试时只用"happy path"。 网络分区、Leader 和 Follower 日志不一致、旧 Leader 被隔离后恢复——这些 edge case 必须写测试。推荐使用 Jepsen 或 Porcupine 进行一致性验证。
通关挑战
热身:用纸笔模拟一次 Raft Leader 选举。3 个节点,Term 从 1 开始,设置不同的选举超时(模拟随机性)。画选举过程的状态转换图。
挑战:在本地启动一个 3 节点的 etcd 集群。写一个 Java 客户端,用 etcd 实现分布式锁和配置变更监听。
# 启动 3 节点 etcd(本地测试)
etcd --name node1 --initial-advertise-peer-urls http://localhost:2380 \
--listen-peer-urls http://localhost:2380 \
--initial-cluster node1=http://localhost:2380,node2=http://localhost:2381,node3=http://localhost:2382 \
--advertise-client-urls http://localhost:2379 \
--listen-client-urls http://localhost:2379
etcd --name node2 ... --initial-cluster-token etcd-cluster-1
etcd --name node3 ... --initial-cluster-token etcd-cluster-1
# 停掉 Leader,观察自动选举
etcdctl endpoint status --cluster -w table- 观察:用
etcdctl查看集群状态。停掉 Leader 节点,Watch Leader 切换过程。记录从 Leader 下线到新 Leader 上线的时间。
旅人笔记
Raft 用三个独立的机制——Leader 选举、日志复制、安全性——解决了分布式共识这个核心问题。它的可理解性是工程成功的关键:复杂的理论被分解为可测试、可验证的组件。但在生产中使用 Raft(通过 etcd、Consul 或 ZooKeeper)时,你必须像调试单机代码一样理解每个超时、每个状态转换——因为共识协议一旦在运行时出错,对一致性来说是致命的。
下一站预告
共识协议解决的是"多个节点对一个值做出相同的决定"。但当你有一大堆数据——10TB、100TB——你不能把所有数据都放在一个共识组里。下一章你会看到怎么存储海量数据:一致性哈希把数据分布到不同机器上,HDFS 管理大文件的块存储,Cassandra 用 Gossip 协议自组织集群。