从零实现Raft协议

0 阅读11分钟

Raft 协议把分布式一致性拆成三个相对独立的子问题:

  1. Leader 选举 —— 集群选出一个 Leader,所有写操作由 Leader 处理
  2. 日志复制 —— Leader 把操作复制到所有节点,达到一致后提交
  3. 安全性 —— 保证已提交的日志不会被覆盖(选举限制 + 提交规则)

节点的三种状态和定时器

image.png

// 三种状态
private volatile NodeState state = NodeState.FOLLOWER;

// volatile 状态(所有节点都有)
private volatile long commitIndex = 0;    // 已提交到哪了
private volatile long lastApplied = 0;    // 已应用到状态机到哪了
private volatile String currentLeader;    // 当前 Leader 是谁

// Leader 专属(跟踪每个 Follower 的复制进度)
private final Map<String, Long> nextIndex;   // 下一条该发给它的日志索引
private final Map<String, Long> matchIndex;  // 已确认复制成功的最高索引

关键点:nextIndexmatchIndex 只在 Leader 上维护。Leader 根据 matchIndex 判断哪些日志已经复制到多数节点上,从而推进 commitIndex

Leader 选举的完整流程

选举定时器 —— 随机化的精髓

Raft 用随机选举超时来避免多个 Candidate 同时发起选举(脑裂):

// RaftNode.java — resetElectionTimer()
long timeout = electionTimeoutMinMs
    + random.nextLong(electionTimeoutMaxMs - electionTimeoutMinMs);
electionTask = scheduler.schedule(this::startElection, timeout, TimeUnit.MILLISECONDS);

假设 3 个节点同时启动,如果没有随机化,它们可能在同一时刻超时、同时成为 Candidate、同时发出投票请求,谁也得不了多数票,永远选不出 Leader。随机化让其中一个节点先超时,先发起选举,先拿到多数票。

发起选举 —— 自增 Term + 投自己一票

private synchronized void startElection() {
    currentTerm++;           // 任期 +1
    votedFor = nodeId;       // 投自己
    persistState();          // ⚠️ 持久化!保证重启后不违背投票承诺

    state = NodeState.CANDIDATE;
    long lastLogIdx = getLastLogIndex();
    long lastLogTerm = getLastLogTerm();

    int votesGranted = 1;  // 自己的票
    int needed = (clusterMembers.size() / 2) + 1;  // 需要多数

    // 并发向所有其它节点发 RequestVote RPC
    for (String peer : clusterMembers) {
        if (peer.equals(nodeId)) continue;
        transport.requestVote(peer,
            new RequestVoteRequest(currentTerm, nodeId, lastLogIdx, lastLogTerm))
            .thenAccept(resp -> {
                // 回调里检查...
            });
    }
}

这里有两个关键细节:

  1. 每次选举必须自增 Term

    Term 是 Raft 里的"逻辑时钟",全局单调递增。任何一个节点发现自己的 Term 比对方小,就立即转为 Follower。这保证了集群在任意时刻只有一个最高 Term。

  2. 带上 lastLogIndex 和 lastLogTerm

    假设节点 A 的日志是 [1,1,2,2,3](已提交到索引 5),节点 B 的日志是 [1,1,2,2](只到索引 4,且最后一条还没提交)。如果 B 当选 Leader,可能会覆盖 A 上已经提交的日志 —— 违反安全性。所以 Candidate 必须拥有比投票者更新的日志,比较规则是先比 Term 再比 Index。

投票方怎么决定是否投票

public synchronized RequestVoteResponse handleRequestVote(RequestVoteRequest req) {
    // 规则 1: 对方的 Term 大于我 → 我认输,转为 Follower
    if (req.getTerm() > currentTerm) {
        stepDown(req.getTerm());
    }

    // 规则 2: 对方 Term 比我小 → 拒绝
    if (req.getTerm() < currentTerm) {
        grant = false;
    }
    // 规则 3: 对方 Term 等于我,且我还没投过票(或就是投给它的)
    else if (votedFor == null || votedFor.equals(req.getCandidateId())) {
        // 规则 4: Candidate 日志至少和我一样新
        //         先比较 lastLogTerm,相等时再比较 lastLogIndex
        if (req.getLastLogTerm() > myLastTerm
                || (req.getLastLogTerm() == myLastTerm
                    && req.getLastLogIndex() >= myLastIdx)) {
            votedFor = req.getCandidateId();
            persistState();  // ⚠️ 投票要持久化
            grant = true;
            resetElectionTimer();  // 重置自己的选举定时器
        }
    }
}

投票的四条规则:

  1. 对方的 Term 大于我 → 我认输,转为 Follower
  2. 对方 Term 比我小 → 拒绝
  3. 对方 Term 等于我,且我还没投过票(或就是投给它的)
  4. 如果要投票给对方,必须确保对方日志至少和我一样新 —— 对方的 lastLogTerm 大于我,或 lastLogTerm 相同但 lastLogIndex ≥ 我

votedFor 被持久化到磁盘上。如果节点在投完票后宕机重启,它还能记住自己已经投过票了,不会在同一个 Term 里投两次 —— 这防止了同一个 Term 有两个 Leader。

计票 & 成为 Leader

private void becomeLeader() {
    state = NodeState.LEADER;
    currentLeader = nodeId;

    // 初始化每个 Follower 的 nextIndex = 自己最后一条日志 +1
    long lastIdx = getLastLogIndex();
    for (String peer : clusterMembers) {
        nextIndex.put(peer, lastIdx + 1);
        matchIndex.put(peer, 0L);
    }

    // 立刻发送心跳(空 AppendEntries)
    heartbeatTask = scheduler.scheduleAtFixedRate(
        this::sendHeartbeat, 0, heartbeatIntervalMs, TimeUnit.MILLISECONDS);

    // 取消选举定时器 —— Leader 不需要
    if (electionTask != null) electionTask.cancel(false);
}

Leader 当选后马上广播心跳,目的是尽快让所有 Follower 知道新 Leader 的存在,也阻止其他节点发起新的选举(因为收到心跳就重置选举定时器)。

日志复制 —— 一致性的核心

AppendEntries RPC 的字段含义

AppendEntriesRequest:
  term          → Leader 的任期
  leaderId      → 告诉 Follower 谁是 Leader
  prevLogIndex  → "我要追加的日志的前一条"的索引
  prevLogTerm   → "我要追加的日志的前一条"的任期
  entries       → 要追加的日志条目列表
  leaderCommit  → Leader 的 commitIndex(告诉 Follower 你也能提交了)

prevLogIndexprevLogTerm 是这个 RPC 最核心的设计 —— 它们实现了日志一致性检查。

Leader 端:发送日志

private void sendAppendEntries(String peer) {
    // 从 nextIndex 反算该从哪里开始发
    long prevIdx = nextIndex.getOrDefault(peer, getLastLogIndex() + 1L) - 1;

    // 收集从 prevIdx+1 开始的所有日志
    List<LogEntry> entries = new ArrayList<>();
    for (LogEntry e : logEntries) {
        if (e.getIndex() > adjustedLastIncluded && e.getIndex() >= startBatch) {
            entries.add(e);
        }
    }

    long prevTerm = getLogTermAtIndex(prevIdx);
    AppendEntriesRequest req = new AppendEntriesRequest(
        currentTerm, nodeId, prevIdx, prevTerm, entries, commitIndex);

    transport.appendEntries(peer, req).thenAccept(resp -> {
        if (resp.isSuccess()) {
            // 成功 —— 推进 nextIndex 和 matchIndex
            matchIndex.put(peer, prevIdx + entries.size());
            nextIndex.put(peer, newMatch + 1);
            advanceCommitIndex(); // ⚠️ 检查是否能提交了
        } else {
            // 失败 —— nextIndex 倒退一位,重试
            nextIndex.put(peer, Math.max(1, nextIndex.get(peer) - 1));
        }
    });
}

回溯机制是最精彩的设计之一:当 Follower 拒绝 AppendEntries(因为 prevLogIndex 位置上的日志不一致),Leader 就把 nextIndex 减 1 后重试。这是一个二分查找的退化版(线性回溯),简单但有效。最坏情况下要回溯整条日志,但实际中几乎不会发生,因为正常情况下 Leader 和 Follower 的日志高度一致。

Follower 端:接收 & 一致性检查

public synchronized AppendEntriesResponse handleAppendEntries(AppendEntriesRequest req) {
    // 步骤 1: Term 检查(跟投票逻辑一样)
    if (req.getTerm() > currentTerm) stepDown(req.getTerm());
    if (req.getTerm() < currentTerm)
        return new AppendEntriesResponse(currentTerm, false, 0);

    // 步骤 2: 收到合法的 Leader 心跳 → 重置选举定时器
    currentLeader = req.getLeaderId();
    state = NodeState.FOLLOWER;
    resetElectionTimer();

    // 步骤 3: ⚠️ 一致性检查 —— 核心
    long localPrevTerm = getLogTermAtIndex(req.getPrevLogIndex());
    if (localPrevTerm != req.getPrevLogTerm()) {
        return new AppendEntriesResponse(currentTerm, false, 0);
        // ↑ 拒绝!告诉 Leader 我们这里不一致
    }

    // 步骤 4: 追加新条目,冲突的就截断
    for (LogEntry entry : req.getEntries()) {
        // 如果这条索引上已有不同 Term 的日志,删除它及之后所有
        if (existing != null && existing.getTerm() != entry.getTerm()) {
            while (logEntries.size() > pos) logEntries.remove(...);
        }
        logEntries.add(entry);
    }

    // 步骤 5: 推进 commitIndex
    if (req.getLeaderCommit() > commitIndex) {
        commitIndex = Math.min(req.getLeaderCommit(), getLastLogIndex());
        applyCommitted();
    }
}

一致性检查 prevLogTerm != localPrevTerm 是 Raft 保证安全性的核心。用一个例子来解释:

Leader 日志:  [1] [1] [2] [2] [3] [3]index=2, term=1

假设 prevLogIndex=2,prevLogTerm=1
Follower 在 index=2 处的日志 Term 必须是 1,否则说明双方日志在此分叉
比如 Follower 日志: [1] [1] [2] [4]index=2 处 term=1(匹配),分歧在后面
                    [1] [1] [3] [3]index=2 处 term=1(匹配),分歧在后面
                    [1] [2] [2] [2]index=2 处 term=21,直接拒绝!

如果 Follower 拒绝,Leader 会回溯 nextIndex,下次从更早的索引开始发,直到找到双方一致的 point,再从那里开始覆盖。

提交:只有 Leader 当前 Term 的日志才能推进 commitIndex

private void advanceCommitIndex() {
    // 收集所有节点的 matchIndex,取中位数(多数)
    List<Long> matches = new ArrayList<>();
    matches.add(getLastLogIndex());  // Leader 自身
    matches.addAll(matchIndex.values());
    matches.sort(Collections.reverseOrder());
    int majority = (clusterMembers.size() / 2) + 1;
    long newCommit = matches.get(Math.min(majority - 1, matches.size() - 1));

    if (newCommit > commitIndex) {
        long t = getLogTermAtIndex(newCommit);
        if (t == currentTerm) {  // ⚠️ 只能提交自己任期的日志
            commitIndex = newCommit;
            applyCommitted();
        }
    }
}

为什么只能提交自己任期的日志?

这是 Raft 安全性最关键的设计。考虑以下场景:

时间事件
Term 1S1 是 Leader,复制日志 [index=2, term=1] 到 S1 和 S2 后就宕机,这条日志没达到多数,没提交
Term 2S5 当选(日志更新),它复制了一条自己的日志 [index=2, term=2] 到 S3/S4/S5,成功提交
Term 3S1 重新当选(日志 [index=2, term=1] 还在它本地),如果 S1 把 term=1 的旧日志提交了 → 它会覆盖 S5 在 Term 2 已经提交了的 [index=2, term=2]

这意味着 一条已提交的日志被删除了,违反了 Raft 的安全性保证。

所以论文规定:Leader 只能通过提交自己任期的日志来"连带"提交之前任期的日志。这被称为"Commitment by current term"规则。

具体做法:S1 当选后,不在 index=2 处直接提交旧的 term=1 日志。它必须在 index=3 处创建一条 term=3 的新日志,将这条 term=3 的日志复制到大多数节点。当 term=3 的日志被提交时,index=2 的 term=1 日志自动连带被提交。

为什么这样安全?因为一旦 term=3 的日志存在于大多数节点上,根据 Leader 完备性:任何未来的 Leader 必须拥有这条 term=3 的日志(否则它无法获得大多数投票),而任何拥有 term=3 日志的节点,根据日志匹配特性,必然拥有前面 index=2, term=1 的日志。

快照 —— 压缩日志

Raft 日志会随着时间膨胀。快照是解决方案:

public synchronized void takeSnapshot() {
    Snapshot snap = stateMachine.takeSnapshot();  // 让状态机自己拍快照
    snap.setLastIncludedIndex(lastApplied);
    snap.setLastIncludedTerm(getLogTermAtIndex(lastApplied));

    // 删除已被快照覆盖的日志条目
    logEntries.removeIf(e -> e.getIndex() > adjustedLastIncluded
                              && e.getIndex() <= cutoff);

    lastIncludedIndex = lastApplied;
    lastIncludedTerm = getLogTermAtIndex(lastApplied);

    // 持久化到磁盘
    MAPPER.writeValue(snapPath.toFile(), snap);
}

如果 Leader 需要发给一个落后很多的 Follower,而 Leader 已经把日志压缩了,Leader 会发 InstallSnapshot RPC 把整个快照发给 Follower。Follower 收到后直接替换自己的状态机和日志。

成员变更 —— 联合共识

在成员变更过程中,如果直接切换到新配置,可能出现"两个 Leader"同时存在(脑裂)的情况。

问题

假设一个 5 节点集群(A,B,C,D,E)要缩容为 3 节点(A,B,C)。

如果直接让各节点从旧配置切到新配置,由于网络延迟,不同节点切换的时间点不同:

  • A、B 已经切换到新配置(3 节点,多数 = 2)
  • C、D、E 还在旧配置(5 节点,多数 = 3)

此时如果 A、B 与 C、D、E 网络分区:

  • A、B 认为自己有 2/3,可以选出 Leader
  • C、D、E 认为自己有 3/5,也可以选出 Leader

两个 Leader 同时存在 → 数据不一致。

联合共识的核心思想

联合共识的思路是:变更过程中,不是在不同时刻使用不同配置,而是让"旧配置"和"新配置"同时生效。

具体规则:

在联合共识阶段,一个日志条目要被提交,必须同时获得旧配置的多数同意和新配置的多数同意。

也就是说,这个阶段的"多数"是两个配置都要过半。

成员变更的两阶段流程

第一阶段:进入联合配置(Cold,new)

Leader 向集群发送一个特殊的日志条目,内容是 Cold,new(旧配置 + 新配置的组合)。

各节点收到后,立即生效这个联合配置:

  • 选举:必须同时获得旧配置多数和新配置多数的投票,才能成为 Leader
  • 提交:必须同时获得旧配置多数和新配置多数的确认,日志才能提交

第二阶段:切换到新配置(Cnew)

Cold,new 日志被成功提交后,Leader 再发送 Cnew 日志条目。

各节点收到 Cnew 后,切换到纯新配置,成员变更完成。


为什么联合共识能避免脑裂?

关键在于:Cold,new 阶段,任何决策都需要两个配置同时过半。

假设旧配置是 5 节点(A,B,C,D,E),新配置是 3 节点(A,B,C):

  • 旧配置多数 = 3 票
  • 新配置多数 = 2 票

Cold,new 阶段,要成为 Leader 必须同时拿到:

  • 旧配置中至少 3 票
  • 新配置中至少 2 票

这意味着任何两个候选者不可能同时成功,因为旧配置的 3 票已经占用了 5 个节点中的大多数,不可能同时分给两个候选者。

即使网络分区,最多只有一个候选者能同时满足两个配置的多数要求。


联合共识 vs. 单节点变更

在 Diego Ongaro 的博士论文中,联合共识是通用解法。但在后续的工程实践中(如 etcd),更常用的是单节点变更(One-node membership change):

  • 每次只增或减 1 个节点
  • 旧配置多数和新配置多数必然有交集(数学保证)
  • 不需要显式的联合共识阶段,实现更简单

但联合共识仍然是理论完备的方案,适用于任意规模的成员变更。

下面是一个单步变更的简单实现:

public void changeMembership(boolean add, String peerId, String peerAddress) {
    Set<String> newConfig = new HashSet<>(clusterMembers);
    if (add) newConfig.add(peerId);
    else newConfig.remove(peerId);

    clusterMembers = newConfig;
    persisted.setClusterMembers(newConfig);
    persistence.save(persisted);
}

只要新旧配置的大多数存在交集,就不会出现脑裂,因为新旧多数派都必然要争取至少一个共同的节点。

总结

Raft 的安全性由三条防线共同保证:

防线机制防止什么
投票承诺votedFor 持久化,每个 Term 只投一次一个 Term 两个 Leader
日志比较投票时比较 lastLogTerm + lastLogIndex落后节点当选 Leader
限时提交Leader 只提交自己任期的日志已提交的日志被错误覆盖

这三条一起保证了 Raft 的 Leader 完备性(Leader Completeness Property):一旦一条日志被提交,所有未来的 Leader 的日志里必定包含这条日志。这是分布式 KV 不丢数据的根本保证。