Storforge
编码预计 60 分钟

Raft:给元数据上共识

放置表、成员列表这些「集群真相」必须所有节点认同。理解 Raft 的选举与日志复制,用 openraft 存管 forge 的集群元数据。

学完这节你能做到

  • 讲清 Raft 选举、日志复制、提交点推进的流程
  • 基于 openraft 实现集群配置状态机
  • 解释为什么数据面不走 Raft、只有元数据走

两份放置表不一致会发生什么

先推演一次事故,角色都是你的老熟人。forge 四节点,slot 42 原本由 n2 负责, 某时刻 n2 宕机,集群决定把 slot 42 迁给 n3。如果这个决定靠 gossip 慢慢扩散:

  1. n1 先收到新表(epoch 8),把 slot 42 的新写入发给 n3
  2. n4 还拿着旧表(epoch 7),读 slot 42 时去找 n2 —— 超时,重试,客户端报错
  3. 更糟的一步:n2 十秒后活过来了(其实只是网络抖了),n4 从 n2 读到了旧版本数据, 不报错、内容还"合理"—— 静默读旧,比读失败恶劣得多

问题不在于「表更新慢」,在于没有一个所有节点公认的更新顺序。gossip 是最终一致的, 对「n2 疑似挂了」这类情报够用;但「slot 42 归 n3」是集群的真相, 真相必须全体节点按同一顺序看到同一份 —— 这就是共识问题。 你运维 Ceph 时其实天天在消费共识:osdmap 带 epoch、由 mon 集群用 Paxos 维护、 OSD 和客户端拿 epoch 对表,过期就拒绝服务。forge 用 Raft 做同一件事。

Raft 核心:任期、选举、日志匹配

Raft 把共识拆成三个能独立理解的机制。用「值班表」类比:集群任何时刻至多一个 leader 说了算,leader 挂了就投票选新的,所有决定记在一本全体一致的流水账上。

任期(term):单调递增的逻辑时钟,每次选举开启新任期。任何消息都带 term, 收到更高 term 就立刻承认自己过时 —— 这是防脑裂的第一道闸:被网络隔离的旧 leader term 落后,它发出的任何指令都会被拒绝。

选举:follower 在选举超时(150~300ms 之间随机)内没收到 leader 心跳,就自增 term 发起投票,拿到多数派(3 节点要 2 票,5 节点要 3 票)即成为 leader。 两个细节各挡一类事故:超时随机化避免多个候选人永远同时发起、互相分票; 投票时比较日志新旧、只投给日志不落后于自己的候选人,保证已提交的记录不会被新 leader 丢掉。

日志复制与提交:客户端的写请求(对 forge 来说是「变更集群配置」)由 leader 追加到自己的日志,并行发给所有 follower;多数派落盘确认后,这条记录才算提交, 才能应用到状态机。日志匹配性质保证:两个节点的日志在同一位置若 term 相同, 则之前的全部记录都相同 —— 冲突时 follower 无条件服从 leader 截断重写。

数字账要会算:3 节点容忍 1 个失效,5 节点容忍 2 个;每次提交至少一个 RTT 加一次多数派 fsync,同机房大约 1~5ms —— 记住这个数,最后一节要用它算账。

Checkpoint单选

3 节点的 Raft 集群发生网络分区,leader 单独在一侧,另外两台在另一侧。此时写入会怎样?

openraft 集成:状态机、存储、网络三个 trait

自己实现 Raft 是个好练习,但选举安全性的坑(投票持久化、日志截断边界)足够写一篇 事故报告合集。forge 用 openraft,它把「你的系统」和「共识算法」的边界切成三个 trait, 每个都落在你已有的积木上:

trait职责forge 的实现
RaftNetwork节点间发选票和日志tonic:forge.proto 加一个 RaftService
RaftLogStorage日志与投票的持久化L1 的 WAL 经验直接复用:追加、crc、崩溃后截断
RaftStateMachine已提交日志应用到业务状态ClusterState:成员 + slot 表

状态机就是上一课埋的伏笔 —— 集群真相的唯一权威版本:

use serde::{Deserialize, Serialize};

/// Raft 日志里的一条命令:对集群配置的一次变更
#[derive(Serialize, Deserialize, Clone, Debug)]
pub enum MetaCommand {
    AddNode { node_id: String, addr: String, weight: u32 },
    RemoveNode { node_id: String },
    /// 把某个 slot 的节点列表整体替换 —— 搬迁、故障接管都是它
    UpdateSlot { slot: u16, nodes: Vec<String> },
}

/// 状态机:每 apply 一条已提交命令,epoch 加一
#[derive(Default, Serialize, Deserialize)]
pub struct ClusterState {
    pub epoch: u64,
    pub members: std::collections::BTreeMap<String, NodeInfo>,
    pub slots: SlotTable, // 上一课的 256 slot 表
}

impl ClusterState {
    pub fn apply(&mut self, cmd: &MetaCommand) -> Result<(), MetaError> {
        match cmd {
            MetaCommand::AddNode { node_id, addr, weight } => {
                // 幂等:重复 apply 同一条命令结果不变(Raft 重放的前提)
                self.members.insert(node_id.clone(), NodeInfo::new(addr, *weight));
            }
            MetaCommand::RemoveNode { node_id } => {
                self.members.remove(node_id);
            }
            MetaCommand::UpdateSlot { slot, nodes } => {
                self.slots.set(*slot, nodes.clone())?;
            }
        }
        self.epoch += 1;
        Ok(())
    }
}

两条 review 红线,都是共识系统的经典翻车点:

  1. apply 必须是确定性的纯函数:不许读时钟、不许随机数、不许查外部状态 —— 三个节点各自 apply 同一串日志,状态必须逐字节一致,否则「共识」名存实亡
  2. 投票和日志的持久化必须真 fsync:节点投过票、崩溃重启后忘了,可能在同一任期 投出第二票,选出两个 leader —— L1 学的 fsync 语义在这里直接关系到算法的安全性

元数据面与数据面分离:所有高性能系统的共同选择

现在回答本阶段最重要的架构问题:既然 Raft 能保证一致,为什么不让所有数据都走 Raft?

算账。Raft 每次提交要一次多数派网络往返加 fsync,单个共识组的吞吐上限大约每秒 几千到几万条小记录,延迟毫秒级;而数据面的目标是什么?L2 里你把单机引擎压到了 NVMe 标称 IOPS 的 70%,几十万 IOPS、GB/s 级带宽 —— 差着两到三个数量级。 把 1MiB 的条带塞进 Raft 日志,等于让三台节点各自再写一遍全量数据, EC 省下的 1.5 倍开销全数吐回去,leader 的网卡成为全集群唯一的瓶颈。

所以 forge 的分工是(这也是本阶段的核心论点):

  • 元数据面走 Raft:成员变更、slot 表、epoch —— 小(一条几十字节)、低频 (故障和扩容才变)、但必须绝对一致
  • 数据面不走 Raft:客户端拿着 slot 表直连数据节点,EC 分片并行落盘。 一致性由三件套保证:请求携带 epoch(节点发现过期立刻拒绝,逼客户端刷新表)、 分片 crc32c 端到端校验、收齐 6 个确认才算提交

对照你运维过的系统,无一例外:Ceph 的 mon 用 Paxos 管 osdmap,数据从不经过 mon; GPFS 的配置服务器管集群配置,NSD 数据走直连;Weka 的共识层管 bucket 归属, IO 路径零共识参与。共识是奢侈品,只买给买得起的数据 —— 这句话就是本课的中心思想。

AI 结对:openraft 管起集群元数据pair with ai

openraft 的 API 面积不小,AI 会生成大量胶水代码 —— 胶水可以略读, 但 apply 必须逐行审:里面出现 SystemTime::nowrand 或任何读外部状态的调用,直接打回,那是"三节点状态漂移"这种 最难排查事故的种子。另外确认测试里"停掉 leader"停的是真 leader (先查再停,而不是写死 node 1)。

在 forge-cluster crate 集成 openraft,管理集群配置:

1. 定义 MetaCommand(AddNode/RemoveNode/UpdateSlot)与 ClusterState(epoch、members、slots 复用已有 SlotTable),实现 apply,保证确定性与幂等,并为 apply 写单元测试:同一命令序列在两个独立实例上重放,最终状态用 serde 序列化后逐字节相等;
2. 实现 openraft 需要的三个组件:状态机 trait 用 ClusterState;日志存储先用 openraft 提供的内存实现起步,但把"生产要换成 WAL 持久化,否则重启后可能重复投票"写成显式的 TODO 注释;网络层用 tonic,在 forge.proto 里新增 RaftService(vote/append_entries/snapshot 三个 RPC,消息体用 bytes 承载 openraft 序列化的负载);
3. 在 forge-node 里挂上:启动参数加 --raft-listen 与 --peers;提供 forge-cli cluster init(初始化单节点再逐个 add-learner 提升)、cluster status(打印 leader、term、epoch、成员表);
4. 集成测试:单进程内起 3 个 raft 节点,提交 5 条 UpdateSlot,断言三个状态机 epoch 与 slot 表一致;然后停掉 leader,断言 1 秒内选出新 leader 且还能继续提交。

生产路径不许 unwrap。跑 cargo test -p forge-cluster 与 forge-node 的构建,贴结果。

小结

  • gossip 传情报,Raft 定真相:放置表这类「全体必须看到同一份」的状态,只有共识能管
  • Raft 三件套:term 防旧 leader 作乱,随机超时加多数派投票选主,日志多数派落盘才算提交
  • openraft 三个 trait 各接一块积木:网络接 tonic、日志存储接 WAL 经验、状态机就是 slot 表
  • apply 必须确定且幂等,投票必须真 fsync —— 两条红线,违反任何一条共识就是装饰品
  • 元数据走 Raft、数据面直连加 epoch 校验:两三个数量级的吞吐差距决定了这个分工,Ceph/GPFS/Weka 概莫能外
  • 全部零件到齐:引擎、RPC、成员、放置、EC、共识 —— 下一课组装起来,拔节点验收