Raft:给元数据上共识
放置表、成员列表这些「集群真相」必须所有节点认同。理解 Raft 的选举与日志复制,用 openraft 存管 forge 的集群元数据。
学完这节你能做到
- 讲清 Raft 选举、日志复制、提交点推进的流程
- 基于 openraft 实现集群配置状态机
- 解释为什么数据面不走 Raft、只有元数据走
两份放置表不一致会发生什么
先推演一次事故,角色都是你的老熟人。forge 四节点,slot 42 原本由 n2 负责, 某时刻 n2 宕机,集群决定把 slot 42 迁给 n3。如果这个决定靠 gossip 慢慢扩散:
- n1 先收到新表(epoch 8),把 slot 42 的新写入发给 n3
- n4 还拿着旧表(epoch 7),读 slot 42 时去找 n2 —— 超时,重试,客户端报错
- 更糟的一步: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 —— 记住这个数,最后一节要用它算账。
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(())
}
}
两条红线,都是共识系统的经典翻车点。代码看不懂不要紧, 但这两条的后果你必须能用自己的话说清楚:
- apply 必须是确定性的纯函数:不许读时钟、不许随机数、不许查外部状态 —— 三个节点各自 apply 同一串日志,状态必须逐字节一致,否则「共识」名存实亡
- 投票和日志的持久化必须真 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 会生成大量对接共识库的胶水,那部分不用管。真正要盯的是应用变更的那段逻辑: 让 AI 逐条回答「这里有没有读时钟、有没有随机数、有没有查任何外部状态」, 只要有一处,直接打回 —— 那是"三节点状态漂移"这种最难排查事故的种子。 另外确认测试里「停掉 leader」停的是真 leader(每次现查再停,而不是写死某个节点), 然后用上面的隔离演练自己再演一次:少数派那侧提交必须超时,这是你亲眼能看到的证据。
目标:让 forge 的集群配置(成员表、slot 放置表)成为全体节点公认的唯一真相 —— 所有节点按同一顺序看到同一份,任何时刻至多一个节点有权修改。用 Raft 共识实现。 行为规格: 1. 共识层管三类变更:加节点、删节点、把某个 slot 的节点列表整体替换(数据搬迁和故障接管都走最后这一条)。每应用一条已提交的变更,集群配置版本号加一; 2. 应用变更的逻辑必须是确定性的:不许读时钟、不许用随机数、不许查任何外部状态。三个节点各自重放同一串日志,得到的状态必须逐字节一致 —— 做不到这一条,「共识」就只是装饰品,而且事故表现为三节点状态慢慢漂移,极难排查; 3. 同一条变更被重复应用,结果必须不变。Raft 会重放日志,这是前提不是优化; 4. 投票记录和日志必须真正落盘之后再回复。节点投过票、崩溃重启却忘了,就可能在同一任期投出第二票,选出两个 leader —— L1 学的 fsync 语义在这里直接决定算法的安全性。第一版允许先用内存实现起步,但必须在代码里留下明确的待办,写清「生产必须换成持久化,否则重启后可能重复投票」; 5. 节点间的选票和日志传输走已有的 gRPC 通道,新增一组共识专用接口; 6. 运维接口:初始化集群、逐个加入成员、查看当前 leader / 任期 / 配置版本号 / 成员表; 7. 数据面绝不许经过共识层:客户端拿着放置表直连数据节点,请求里带上配置版本号,节点发现版本过期就拒绝并逼客户端刷新表; 8. 任何预期内的失败都要变成明确的错误返回给调用方,而不是让进程直接崩溃。 交付:实现 + 测试(同一串变更在两个独立实例上重放、最终状态序列化后逐字节相等;单进程内起 3 个节点提交 5 条变更后三方状态一致;停掉当前 leader 后 1 秒内选出新 leader 且还能继续提交)+ 说明你选了哪个 Raft 库、为什么。
- 起 3 节点,先查出当前 leader 是谁,再 kill -9 掉它,掐表看多久选出新 leader、多久恢复能提交变更
- 提交若干次 slot 变更,在三个节点上分别打印集群状态,确认配置版本号和成员表完全一致
- 用 iptables 把 leader 单独隔离 30 秒,在少数派那一侧尝试提交变更,确认它只会超时、绝不会返回「提交成功」;多数派那侧应该能选出新 leader 正常服务。放开网络后确认老 leader 自动退位,没有产生两份互相矛盾的配置
- kill -9 一个 follower 后立刻重启,确认它能追上日志、状态与另外两个一致;反复重启几次,看有没有出现异常 —— 这也是在检验第 4 条那个待办有多真实
- 往集群里灌一批大对象,同时用 iftop 或 sar 看 leader 的网卡流量。数据面如果真的不经过 leader,它的流量应该远低于数据节点
- 网络分区把 leader 单独关在一侧,它继续接受写入会发生什么?为什么它永远无法提交?
- 三个节点重放同一串变更,如果其中一条变更的处理逻辑里读了一次系统时间,会出现什么现象?这种事故为什么特别难排查?
- 为什么不干脆让所有数据都走 Raft?把 1MiB 的数据塞进共识日志,代价具体是多少?
- 客户端拿着旧版本的放置表去写数据,数据会写错地方吗?拦住它的是哪一道机制?
小结
- gossip 传情报,Raft 定真相:放置表这类「全体必须看到同一份」的状态,只有共识能管
- Raft 三件套:term 防旧 leader 作乱,随机超时加多数派投票选主,日志多数派落盘才算提交
- openraft 三个 trait 各接一块积木:网络接 tonic、日志存储接 WAL 经验、状态机就是 slot 表
- apply 必须确定且幂等,投票必须真 fsync —— 两条红线,违反任何一条共识就是装饰品
- 元数据走 Raft、数据面直连加 epoch 校验:两三个数量级的吞吐差距决定了这个分工,Ceph/GPFS/Weka 概莫能外
- 全部零件到齐:引擎、RPC、成员、放置、EC、共识 —— 下一课组装起来,拔节点验收