Skip to content

元数据卡

  • 前置知识:第1章(分布式系统基本问题)、第2章(RPC 通信)
  • 预计时间:50 分钟
  • 核心难度:深入
  • 阅读模式:高度专注(建议完整阅读,不要中断)
  • 完成标志:能够画出 Raft 的状态转换图,说清 Leader 选举和日志复制的完整流程,理解 Paxos 的核心直觉,知道 ZooKeeper 怎么用 ZK 实现选主和配置管理

你的进度

你已经可以指挥两个堡垒之间通信了——消息格式规范,传输通道可靠。但你的战报地图上摆了 5 座前哨站,每座站都有自己的哨兵日志。现在的问题不是"怎么传数据",而是"5 个站在同一件事上能否达成一致"。

林将军的地图上,目标位置标了一个红叉。他说:"五队人马必须到达这个位置。不是四队,不是三队——五队。如果有一队没到,这次突击就无效。"

这个命令翻译成分布式系统的语言就是:共识。多个节点就一个值达成一致,而且这个一致是不可撤销的。


你的任务

掌握分布式共识的三个层次:理解 Raft 协议的全部核心机制(Leader 选举、日志复制、安全性),建立 Paxos 的直觉,了解 ZAB 协议的基本思想。之后你会看到这些协议在 etcd 和 ZooKeeper 中的具体应用。


破局 · 溯源


先遇问题:为什么要共识?

你在单机上实现一个计数器:

java
// 单机计数器
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 把共识问题分解为三个相对独立的子问题:

  1. Leader 选举
  2. 日志复制
  3. 安全性

Raft 中的节点角色

每个节点在任意时刻处于三种状态之一:

Follower ──超时未收到 Leader 心跳──→ Candidate
Candidate ──获得多数票───────────────→ Leader
Leader ──发现更高 Term 的节点────────→ Follower
Candidate ──选举超时或收到更高 Term───→ Follower
节点状态图(纯文本表示):

    +------------------+
    |    Follower      |<----------+
    +---+----------+---+           |
        |          |               |
  超时无心跳    发现Leader         |
        |          |               |
        v          |               |
    +---+---+      |               |
    |Candidate|----+               |
    +---+---+                      |
        |                          |
    获得多数票                     |
        |                          |
        v                          |
    +---+---+                      |
    | Leader |---------------------+
    +--------+   发现更高Term

Term(任期)

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 的心跳,它会:

  1. 增加自己的 Term
  2. 切换为 Candidate
  3. 给自己投票
  4. 向所有节点发送 RequestVote RPC

其他节点收到 RequestVote 后,检查三个条件:

  • 对方的 Term 是否 >= 自己的 Term?
  • 自己在这一 Term 中还没有投过票?
  • 对方的日志至少和自己一样新?

如果全部满足,则投票给对方。如果 Candidate 获得超过半数节点的投票,它就当选为 Leader。

如果多个 Candidate 发起选举,选票分散,没有人获得多数——这时选举超时,Term 递增,开始新一轮选举。选举超时是随机的(150-300ms),这大大减少了多个 Candidate 再竞选的概率。

java
// 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 是所有写入的入口。客户端写入流程:

  1. 客户端只向 Leader 发送写请求
  2. Leader 把操作追加到自己的日志
  3. Leader 向所有 Follower 发送 AppendEntries RPC
  4. Follower 把日志追加到本地(发送响应)
  5. Leader 收到多数节点的成功响应后,提交这条日志(应用到状态机)
  6. 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 的日志至少和大多数节点一样新,才能获得投票。

"更新"的定义:

  1. 如果 Term 不同,Term 更大的日志更新
  2. 如果 Term 相同,日志更长的更新

这个条件保证了:如果一条日志已经被提交(复制到多数节点),那么获得多数票的 Candidate 一定包含了这条日志。

证明了这一点,Raft 的安全性就成立了——任何已提交的条目都不会在后续 Term 中被覆盖。

日志一致性检查

当新 Leader 开始向 Follower 复制日志时,它先找到每个 Follower 与自己的日志"分叉"的位置,然后强制 Follower 复制自己的日志。这种"Leader 覆盖"的机制简化了实现,但代价是 Follower 上尚未提交的本地日志会被丢弃。


对比窗口:Raft 与 Paxos 的直觉

Paxos 是 Lamport 在 1998 年提出的共识协议。它是理论的里程碑,但实现起来极其困难。

Paxos 的核心直觉比它的实现要简单得多。看一个故事化的版本:

你所在的小组需要决定明天去哪儿团建。大家都能提出建议。一个"Paxos 提议"的流程:

  1. Prepare:你告诉所有人:"我想提一个建议,在我的年代号是 N。如果有人已经接受过年代号大于 N 的建议,请告诉我,否则请保证不再接受年代号小于 N 的建议。"
  2. Promise:大家回复"年代号 N 我记住了,我会忽略后面年代号更小的建议。我没有接受过任何年代号大于 N 的建议(或者说我有接受的 V 值)。"
  3. Accept:如果没有收到更大的值,你提出"大家去爬山";如果你已经知道别人提议过"去烤肉",你改提"去烤肉"。
  4. Accepted:大家回复确认。

Paxos 用两轮 RPC(Prepare-Promise 和 Accept-Accepted)确保:一旦一个值被多数派接受,所有后续的提议都必须接受同一个值。

Paxos 只解决"对一个值达成共识"——这就是 Single-Paxos。Multi-Paxos 把多个 Paxos 实例串起来,但 Lamport 的论文没有给出工程细节,所以每个实现都是自己的理解。

Raft 相对于 Paxos 的核心改进:

问题PaxosRaft
理解难度理论优雅,实现艰难为可理解性设计
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 机制实现
java
// 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
        // 其余监听前一个节点的删除事件
    }
}

常见陷阱

  1. Raft 选举超时的设计。 大部分人设置心跳间隔为 100ms,选举超时为 300ms,假设网络延迟稳定。但在高延迟网络上(跨区域部署),这个配置导致频繁选举。经验值:选举超时至少是网络 RTT 的 10 倍。

  2. 只部署 2 个节点。 你需要多数派才能提交——2 个节点中多数派是 2,任何一台挂了整个集群就停了。最少 3 个节点(多数派 = 2),奇数个节点推荐(3,5,7),偶数个节点只会浪费资源,不会提高容错。

  3. 把"已回复客户端"当成"已持久化"。 Raft 日志在内存中时,如果节点挂了数据就丢了。即使 Raft 说"已提交",也要等服务端把日志写入磁盘才算持久化。etcd 的 --sync 选项就是这个作用。

  4. 测试时只用"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 协议自组织集群。

Built with VitePress | Software Systems Atlas