找回密码
立即注册
搜索
发回帖 发新帖

5257

积分

0

好友

673

主题
发表于 昨天 22:28 | 查看: 8| 回复: 0

Raft 的卖点是“可理解”。但真正手写一遍会发现:选主确实好懂,安全性约束才是坑。

Raft 的论文只有十几页,里面有若干条看起来“多余”的约束——比如“只能提交当前任期的日志”、“投票时必须比较日志新旧”。这些约束不看反例,很难理解它们为什么必要。

这篇从零实现一个能跑的 Raft,重点是给每条约束配上反例。

三个角色和两个概念

Raft 里每个节点处于三种状态之一:

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Role {
    Follower,
    Candidate,
    Leader,
}

两个核心概念:

  • 任期(term):单调递增的逻辑时钟。每次选举开始 +1。任期是 Raft 里判断“谁的信息更新”的第一依据。
  • 日志索引(log index):日志条目的位置,从 1 开始。
#[derive(Debug, Clone, PartialEq)]
pub struct LogEntry {
    pub term: u64,
    pub index: u64,
    pub command: Vec<u8>,
}

pub struct RaftNode {
    // 持久化状态(必须落盘,见上一篇 WAL)
    current_term: u64,
    voted_for: Option<u64>,
    log: Vec<LogEntry>,

    // 易失状态
    role: Role,
    commit_index: u64,
    last_applied: u64,

    // leader 特有的
    next_index: std::collections::HashMap<u64, u64>,
    match_index: std::collections::HashMap<u64, u64>,
}

注释里那个“持久化状态”很关键。 Raft 有三个字段必须在响应任何 RPC 之前落盘:current_term、voted_for、log。重启后如果这三个丢了,就会投票给两个不同的候选人,破坏安全性。

第一件事:任期只能单调递增

先写一个最基础的辅助函数,它体现了 Raft 的一条铁律:

impl RaftNode {
    /// 看到更高的任期,无条件投降并转为 Follower
    fn maybe_step_down(&mut self, term: u64) {
        if term > self.current_term {
            self.current_term = term;
            self.voted_for = None;   // 新任期,还没投票
            self.role = Role::Follower;
        }
    }

    fn log_term(&self, index: u64) -> Option<u64> {
        if index == 0 {
            Some(0) // 虚拟的“日志起点”,任期算 0
        } else {
            self.log.get((index - 1) as usize).map(|e| e.term)
        }
    }
}

index == 0 返回任期 0 是 Raft 实现里的标准技巧:它让“从头开始复制”这件事也符合统一的下标规则,避免到处写 if index == 0 的特判。

选主:投票限制是最容易写错的地方

选举流程:follower 超时 → 变 candidate → 任期 +1 → 给自己投票 → 向所有节点拉票。

#[derive(Debug)]
pub struct RequestVote {
    pub term: u64,
    pub candidate_id: u64,
    pub last_log_index: u64,
    pub last_log_term: u64,
}

#[derive(Debug)]
pub struct RequestVoteReply {
    pub term: u64,
    pub vote_granted: bool,
}

impl RaftNode {
    pub fn handle_request_vote(&mut self, req: RequestVote, my_id: u64) -> RequestVoteReply {
        // 1. 任期更小的候选人直接拒绝
        if req.term < self.current_term {
            return RequestVoteReply { term: self.current_term, vote_granted: false };
        }

        // 2. 任期更大或相等,先步进
        self.maybe_step_down(req.term);

        // 3. 本轮还没投过票(或投给同一个人)
        let not_voted = self.voted_for.is_none() || self.voted_for == Some(req.candidate_id);
        if !not_voted {
            return RequestVoteReply { term: self.current_term, vote_granted: false };
        }

        // 4. 关键:候选人日志必须“至少和我一样新”
        if !self.is_log_up_to_date(req.last_log_term, req.last_log_index) {
            return RequestVoteReply { term: self.current_term, vote_granted: false };
        }

        self.voted_for = Some(req.candidate_id);
        self.reset_election_timer();
        RequestVoteReply { term: self.current_term, vote_granted: true }
    }

    /// 日志新旧比较:先比最后一条的 term,再比长度
    fn is_log_up_to_date(&self, cand_term: u64, cand_index: u64) -> bool {
        let (my_term, my_index) = self.last_log_pos();

        if cand_term != my_term {
            return cand_term > my_term; // term 大的更新
        }
        cand_index >= my_index // term 相同时,更长的更新
    }
}

为什么必须有第 4 条约束

去掉它会发生什么?考虑这个场景:

节点 A 收到命令 x,写进本地日志,然后崩溃
节点 B、C 没有 x,它们选举,B 当选为 Leader
B 不知道 x 的存在,在索引 1 写入了自己的命令 y
A 恢复,被追平,x 永久丢失

但客户端已经被告知“x 已提交”——这就是数据丢失。

第 4 条约束保证:只有“包含了所有已提交日志”的节点才能当选。 因为已提交意味着多数派持有,而多数派里日志最新的那个节点,日志一定包含全部已提交内容。

选举限制是 Raft 数据安全的第一道闸门:不让“日志落后”的节点当 leader。

注意比较顺序:先比 term,再比 index。 反过来的话,一个“任期低但日志长”的节点会取胜,而它可能缺少高任期里已提交的日志。

日志复制:AppendEntries

Leader 收到写请求后,追加到本地日志,然后向所有 follower 发 AppendEntries:

#[derive(Debug)]
pub struct AppendEntries {
    pub term: u64,
    pub leader_id: u64,
    /// 新日志的前一条的 index
    pub prev_log_index: u64,
    /// 新日志的前一条的 term —— 一致性检查用
    pub prev_log_term: u64,
    pub entries: Vec<LogEntry>,
    pub leader_commit: u64,
}

prev_log_term 是一致性检查(consistency check)的核心:

pub fn handle_append_entries(&mut self, req: AppendEntries) -> AppendEntriesReply {
    if req.term < self.current_term {
        return AppendEntriesReply { term: self.current_term, success: false };
    }

    self.maybe_step_down(req.term);
    self.reset_election_timer();

    // 一致性检查:我有没有 prev_log_index 这条,且任期一致?
    if req.prev_log_index > 0 {
        match self.log_term(req.prev_log_index) {
            Some(t) if t == req.prev_log_term => {}
            _ => {
                // 不匹配,拒绝,让 leader 回退 next_index
                return AppendEntriesReply {
                    term: self.current_term,
                    success: false,
                };
            }
        }
    }

    // 追加/覆盖日志
    for entry in req.entries {
        let idx = entry.index as usize;
        if idx <= self.log.len() {
            // 已存在:如果 term 不同,说明有冲突,从此处截断
            if self.log[idx - 1].term != entry.term {
                self.log.truncate(idx - 1);
                self.log.push(entry);
            }
            // term 相同:这条和之后都一致,不用动
        } else {
            self.log.push(entry);
        }
    }

    // 更新 commit_index(取 leader_commit 和本地 last_index 的较小值)
    if req.leader_commit > self.commit_index {
        self.commit_index = req.leader_commit.min(self.log.len() as u64);
        self.apply_committed();
    }

    AppendEntriesReply { term: self.current_term, success: true }
}

“term 相同则后面的都相同”是 Raft 的一个关键性质(日志匹配性质)。它由“leader 只追加、follower 冲突时截断”共同保证。有了它,冲突处理才能“找到第一个不同的位置,截断后面全部”。

冲突回退的优化

朴素的实现是“一致性检查失败就让 next_index 减一,重试”。如果 follower 落后一万条,就要重试一万次。

LevelDB 论文里提到的优化:follower 返回自己的“冲突任期和该任期第一条的索引”,leader 一次跳过整段。

pub struct AppendEntriesReply {
    pub term: u64,
    pub success: bool,
    /// 优化:冲突时的提示
    pub conflict_term: Option<u64>,
    pub conflict_index: u64,
}
fn next_index_after_conflict(&self, reply: &AppendEntriesReply, peer: u64) -> u64 {
    match reply.conflict_term {
        None => self.next_index[&peer].saturating_sub(1),
        Some(cterm) => {
            // 在自己日志里找 cterm 的最后一条
            match self.log.iter().rposition(|e| e.term == cterm) {
                Some(pos) => (pos + 2) as u64, // 跳到该任期之后
                None => reply.conflict_index,
            }
        }
    }
}

Raft 的性能优化,一半都在“让冲突回退尽可能快”。没有这个优化,follower 落后越多,恢复越慢,形成正反馈。

提交规则:最容易被忽略的安全约束

这是本文最想讲清的一条。

Leader 怎么判断一条日志“已提交”?

直觉答案:多数派复制成功就提交。

但 Raft 有一条附加限制:

Leader 只能提交“当前任期”的日志。之前任期的日志,要通过“当前任期的日志被提交”来间接提交。

fn advance_commit_index(&mut self) {
    let mut match_indexes: Vec<u64> = self.match_index.values().copied().collect();
    match_indexes.push(self.log.len() as u64); // 算上自己

    match_indexes.sort_unstable_by(|a, b| b.cmp(a));
    let majority = match_indexes[match_indexes.len() / 2]; // 中位数

    // 关键约束:只提交当前任期的日志
    if majority > self.commit_index && self.log_term(majority) == Some(self.current_term) {
        self.commit_index = majority;
        self.apply_committed();
    }
}

为什么必须加这个约束

看这个著名的反例(Raft 论文图 8):

时刻 1: Leader S1 收到命令,写入索引 2,复制到 S2,未提交
S1 日志: [, 1@T1, 2@T1]
S2 日志: [
, 1@T1, 2@T1]  

时刻 2: S1 崩溃。S5 当选(term 3),写入索引 2 在 term 3
S5 日志: [_, 1@T1, 3@T3]  

时刻 3: S5 崩溃。S1 恢复,重新当选(term 4),复制索引 2 的 2@T1 到多数派
S1 日志: [_, 1@T1, 2@T1]  

    ← 如果此时允许“S1 提交索引 2”,会发生什么?  

时刻 4: S1 崩溃。S5 重新当选(term 5),发现自己日志更“新”(term 3 > term 1)
于是用 3@T3 覆盖了索引 2  

结果:已经“提交”的 2@T1 被覆盖了 —— 安全性被破坏。

根因是:一个旧任期(T1)的条目,即使被多数派持有,也可能在之后被更高任期的 leader 覆盖。 因为“多数派”是动态的,重新选举后可能换了一批节点。

加了这个约束之后,2@T1 不会直接提交。只有当 S1 在 term 4 写入并提交了某个当前任期的条目时,之前的条目才随之提交。而“提交 term 4 的条目”意味着 S1 在 term 4 拥有多数派支持,S5 就不可能再当选了。

“只能提交当前任期的日志”这条约束,是 Raft 里最容易漏、也最容易导致数据丢失的一条。很多实现通过测试用例才最终发现它。

选举超时:随机化是必须的

选举超时如果所有节点相同,会陷入“同时竞选、同时超时、同时再竞选”的活锁。

use std::time::{Duration, Instant};

const ELECTION_TIMEOUT_MIN_MS: u64 = 150;
const ELECTION_TIMEOUT_MAX_MS: u64 = 300;

fn random_election_timeout() -> Duration {
    use std::cell::Cell;
    thread_local! {
        static SEED: Cell<u64> = Cell::new(0x9E3779B97F4A7C15);
    }

    SEED.with(|s| {
        let mut x = s.get();
        x ^= x << 13;
        x ^= x >> 7;
        x ^= x << 17;
        s.set(x);

        let range = ELECTION_TIMEOUT_MAX_MS - ELECTION_TIMEOUT_MIN_MS;
        Duration::from_millis(ELECTION_TIMEOUT_MIN_MS + x % range)
    })
}

同时,心跳间隔必须远小于选举超时:

心跳间隔:50ms
选举超时:150-300ms(3-6 倍心跳)

如果心跳间隔接近选举超时,网络抖动会让 follower 误判断线,发起不必要的选举。 每一次无效选举都会打断正在进行的复制。

Raft 的参数调优基本就一句:心跳要比选举超时小一个数量级,选举超时要有随机性。

线性一致性读:Leader 不能盲目读

一个容易被忽略的问题:Leader 能直接读本地数据吗?

答案是不能,除非加上额外条件。

原因:一个“过期的 leader”(其实已经被取代,但自己不知道)读到的可能是旧数据。

Raft 提供两个解法:

解法一:读之前先确认自己还是 leader。 发一轮心跳,收到多数派响应才能读:

pub async fn read_index(&mut self) -> Result<u64, RaftError> {
    self.heartbeat_round().await?; // 确认多数派还没抛弃我
    Ok(self.commit_index)
}

解法二:Lease Read(租约读)。 依赖时钟:如果心跳响应刚收到不到半个选举超时,就认为租约还有效,可以直接读。

fn lease_valid(&self) -> bool {
    self.last_heartbeat_quorum_time
        .map(|t| t.elapsed() < Duration::from_millis(ELECTION_TIMEOUT_MIN_MS / 2))
        .unwrap_or(false)
}

Lease Read 快,但要求时钟漂移可控。 如果一台机器的时钟走得特别慢,它可能误判租约,读到过期数据。这也是为什么 Raft 实现通常要求节点间时钟同步(NTP)。

快照:日志不能无限增长

日志会一直涨。解决方案是快照——把“已应用的日志”压缩成一个状态快照:

pub struct Snapshot {
    pub last_included_index: u64,
    pub last_included_term: u64,
    pub data: Vec<u8>,
}

有了快照,日志可以丢弃 last_included_index 之前的部分:

fn install_snapshot(&mut self, snap: Snapshot) -> Result<(), RaftError> {
    if snap.last_included_index <= self.last_applied {
        return Ok(()); // 已经比我的状态旧,不用装
    }

    // 1. 保存快照
    self.save_snapshot(&snap)?;

    // 2. 丢弃日志前缀
    let keep_from = snap.last_included_index + 1;
    if keep_from <= self.log.len() as u64 {
        self.log.drain(..(keep_from - 1) as usize);
    } else {
        self.log.clear();
    }

    // 3. 更新索引
    self.last_applied = snap.last_included_index;
    self.commit_index = self.commit_index.max(snap.last_included_index);

    Ok(())
}

快照和日志的衔接点必须精确:last_included_index 对应的任期记在快照元数据里,否则 log_term(index) 就没法回答——而一致性检查需要它。

快照不只是“压缩日志”,它还必须保留 (index, term) 的边界信息,否则日志匹配性质就断了。

一个最小的 tick 循环

把所有部分串起来,每个节点的主循环很规整:

pub fn tick(&mut self) {
    match self.role {
        Role::Follower | Role::Candidate => {
            if self.election_deadline_reached() {
                self.start_election();
            }
        }
        Role::Leader => {
            if self.heartbeat_due() {
                self.broadcast_append_entries();
            }
        }
    }
}

fn start_election(&mut self) {
    self.role = Role::Candidate;
    self.current_term += 1;
    self.voted_for = Some(self.id);
    self.votes_received = std::collections::HashSet::from([self.id]);
    self.reset_election_timer();

    let req = RequestVote {
        term: self.current_term,
        candidate_id: self.id,
        last_log_index: self.log.len() as u64,
        last_log_term: self.last_log_term(),
    };
    self.broadcast_vote_requests(req);
}

注意:单节点集群会在第一次超时就当选 leader(自己就构成多数派)。这是正常的,很多实现的支持单节点模式靠的就是这条。

结论

Raft 的代码结构不复杂,但每条约束都有存在的理由:

任期只能单调递增;投票时必须比较日志新旧,防止落后节点当选;只能提交当前任期的日志,否则已提交数据可能被覆盖;选举超时必须随机化,心跳要比它小一个数量级;Leader 读之前必须确认自己还是 Leader;快照必须保留 (index, term) 边界。

这些约束的共同点是:去掉它们,大部分测试仍然会通过。 只有在特定时序下——节点崩溃、重新选举、网络分区恢复——才会暴露。

所以写 Raft 最好的方式是:先把论文里的每条约束列出来,逐条实现,再逐条构造“去掉它会怎样”的测试。 这也是这一整轮“造轮子”系列的收尾:轮子的价值不在于能用,而在于你知道它为什么必须这么写。 对这类系统实现感兴趣,也欢迎常来云栈社区交流。




上一篇:NV UEFI Unlocker解锁RTX 40/30/20及GTX 16隐藏AI算力,性能最高提升85%
下一篇:Skill路由优化实战:基于真实请求的 description 自进化与归因验证
您需要登录后才可以回帖 登录 | 立即注册

关闭

站长推荐上一条 /1 下一条

手机版|小黑屋|网站地图|云栈社区 ( 苏ICP备2022046150号-2 )

GMT+8, 2026-11-6 00:25 , Processed in 0.082606 second(s), 39 queries , Gzip On.

Powered by Discuz! X3.5

© 2025-2026 云栈社区.

快速回复 返回顶部 返回列表