Storforge
编码预计 50 分钟

集群成员与故障检测

「节点挂了」在代码里是什么?心跳、超时、误判 —— 实现基于 gossip 的成员列表,理解为什么故障检测永远不可能完美。

学完这节你能做到

  • 实现心跳与 phi accrual 风格的故障判定
  • 解释脑裂场景与为什么需要仲裁
  • 设计节点状态机:alive / suspect / dead / rejoin

「节点挂了」在监控里和在代码里是两回事

运维时你对「节点挂了」的判断链路很长:Zabbix 三次 ping 不通、Ceph mon 收到多个 OSD 的举报、你上跳板机 ssh 一把确认。而在代码里,一个节点能观察到的事实只有一条: 我在某个时间点之后,没再收到它的消息。它可能真死了,可能只是慢 (在换页、在 GC、盘在抖),也可能是网络断了而它活得好好的。 这三种情况在观察侧不可区分 —— 故障检测永远是概率判断,不是事实陈述。 这节课就在这个约束下,给 forge 建成员管理:新增 forge-cluster crate。

故障模型:宕机、网络分区、慢节点各长什么样

先把敌人分类,每一类你都在生产上见过:

故障现场特征对检测器的挑战
宕机(crash-stop)进程没了,TCP 连接收到 RST 或超时最好对付,信号干脆
网络分区两边都活着,互相以为对方死了最危险:可能引出脑裂
慢节点(gray failure)心跳时通时断,RPC 延迟从 1ms 抖到几秒最恶心:二值判断反复横跳

重点说分区和脑裂。假设 forge 三节点 n1/n2/n3,交换机故障把 n1 和另外两台隔开: n1 认为 n2、n3 死了,自己继续接客户端写;n2、n3 认为 n1 死了,把 n1 的数据责任接管过来 也继续写 —— 同一个对象在两侧各写出一个版本,网络恢复后没有任何依据判断谁对。 这就是你处理过的脑裂,根因是「双方都认为自己是多数」。

解法只有一个:仲裁(quorum)。任何「接管责任」的决定必须获得多数节点同意, 三节点里被隔离的 n1 拿不到 2 票,只能停止服务。注意这一课的 gossip 成员列表不做仲裁, 它只负责传播「谁疑似挂了」的情报;真正有法律效力的成员变更走 Raft,两课之后兑现 —— 情报系统和法院分开,是本阶段的核心架构判断。

gossip 协议:流言怎么收敛

心跳的朴素做法是全连接:每个节点向其他所有节点发心跳,消息量是节点数的平方。 3 节点无所谓,100 节点每秒就是上万条消息。gossip 把它摊薄: 每个节点每轮只随机挑 k 个节点,交换彼此的完整成员视图,好消息坏消息都靠转述扩散。

收敛速度是对数级:每轮知情节点数近似翻 k 倍。100 节点、每轮 1s、fanout 取 3, 一条「n7 疑似死亡」的流言大约 4~5 轮传遍全网 —— 代价是每节点每秒固定 3 条消息, 和集群规模无关。上一课占住的 GossipService 现在填上:

message NodeState {
  string node_id = 1;
  string addr = 2;
  uint64 incarnation = 3;  // 自增版本号,节点为自己"辟谣"用,下文讲
  Status status = 4;       // ALIVE / SUSPECT / DEAD
  uint64 heard_at_ms = 5;  // 上次直接听到它声音的时间
}

message GossipRequest  { repeated NodeState view = 1; }
message GossipResponse { repeated NodeState view = 1; }

合并两份视图的规则必须确定,否则流言永不收敛:同一节点取 incarnation 大的; incarnation 相同时,状态按 ALIVE 小于 SUSPECT 小于 DEAD 排序,取更严重的那个。

故障检测的两难:误判率 vs 检测延迟

固定超时是你最熟的方案:5 秒没心跳就判死。问题在于这个数没法定: 定 2 秒,一次 GC 停顿就误判,触发无谓的数据迁移(重建风暴的常见导火索); 定 30 秒,真宕机后客户端要多忍 30 秒的失败请求。误判率和检测延迟是一对交易, 固定超时把交易价格写死了,而网络状况是波动的。

phi accrual 检测器把二值判断改成连续怀疑度:记录最近 N 次心跳的到达间隔, 拟合分布,然后回答「按历史规律,这么久没来消息的概率有多低」, 输出 φ 值(怀疑度,大致是误判概率的负对数):

/// phi accrual 的最简实现:间隔滑动窗口 + 正态近似
pub struct PhiDetector {
    intervals: std::collections::VecDeque<f64>, // 最近 100 次心跳间隔,毫秒
    last_heard_ms: u64,
}

impl PhiDetector {
    pub fn heartbeat(&mut self, now_ms: u64) {
        let gap = (now_ms - self.last_heard_ms) as f64;
        self.intervals.push_back(gap);
        if self.intervals.len() > 100 {
            self.intervals.pop_front();
        }
        self.last_heard_ms = now_ms;
    }

    /// φ = -log10(这么久没心跳仍属正常的概率)
    pub fn phi(&self, now_ms: u64) -> f64 {
        let elapsed = (now_ms - self.last_heard_ms) as f64;
        let (mean, stddev) = mean_stddev(&self.intervals);
        // 正态近似下的生存函数,细节交给实现
        -normal_sf(elapsed, mean, stddev.max(10.0)).log10()
    }
}

φ 超过 8(误判概率约亿分之一量级)转 SUSPECT,持续更久转 DEAD。 好处是自适应:心跳一向稳定的节点,晚 1 秒 φ 就飙高;本来就抖的网络, 阈值自动放宽 —— 你不用再半夜改超时参数了。

Checkpoint单选

节点状态机里为什么要有 SUSPECT 这个中间态,而不是直接从 ALIVE 跳到 DEAD?

节点生命周期与运维接口:你最擅长的部分

把状态机钉死,每条转移都要有明确的触发条件:

  • ALIVE → SUSPECT:φ 超过阈值 8
  • SUSPECT → ALIVE:再次收到该节点消息,且 incarnation 不低于流言里的值
  • SUSPECT → DEAD:怀疑持续超过 10s,或多数节点都报 SUSPECT
  • DEAD → ALIVE(rejoin):节点重启回归,incarnation 自增后重新宣告自己

incarnation 是防冤案的关键:节点 n2 被流言判了 SUSPECT,而它其实活着, 它会把自己的 incarnation 从 7 改成 8 并宣告 ALIVE —— 合并规则里大版本胜出, 冤案自动平反。没有这个机制,「n2 死了」的旧流言会和「n2 活着」的新消息打架,永不收敛。

最后是运维接口 —— 你以前抱怨过的系统缺什么,现在自己补上:

forge-cli node ls                 # 成员列表:状态、incarnation、最后心跳、φ 值
forge-cli node cordon n2          # 计划内维护:主动标记下线,别让故障检测来"发现"
forge-cli node uncordon n2

cordon 值得强调:计划内变更(换内存、升内核)绝不应该走故障检测路径 —— 主动宣告让集群提前把读写引开,故障检测只留给真正的意外。这条原则你在 Ceph 上用 noout 时早就实践过了。

AI 结对:forge-cluster 的成员管理模块pair with ai

merge 的交换律测试是本次 review 的重心:gossip 网络里消息到达顺序不可控, 合并函数只要有一点顺序依赖,集群视图就会永久分歧,而普通单测根本测不出来 —— 这正是 property test 的用武之地。另外逐行读 gossip 循环里的锁: 持锁期间发 RPC 是经典死锁源,确认它是先拷贝视图、放锁、再网络交换。

在 forge workspace 新建 lib crate forge-cluster,实现 gossip 成员管理:

1. membership 模块:NodeState 结构(node_id、addr、incarnation、status、heard_at_ms),状态机 ALIVE/SUSPECT/DEAD,转移规则:phi 大于 8 转 SUSPECT,SUSPECT 持续 10s 转 DEAD,收到更高 incarnation 的 ALIVE 宣告则平反;
2. phi 模块:PhiDetector,滑动窗口 100 个心跳间隔,正态近似算 phi,窗口不足 3 个样本时退化为固定超时 5s;stddev 设下限 10ms 防止除零;
3. 视图合并函数 merge(local, remote):incarnation 大者胜,相同时取更严重状态;必须是纯函数并写 property test:合并满足交换律和幂等性(merge(a,b) 与 merge(b,a) 结果一致,merge(a,a) 等于 a);
4. gossip 循环:tokio 任务每 1s 随机挑最多 3 个 ALIVE 节点,通过 forge-proto 的 GossipService::Exchange 交换视图(proto 消息按本课定义补进 forge.proto);
5. 单元测试:GC 停顿场景 —— 构造均值 1000ms、抖动小的间隔序列,验证 3s 无心跳时 phi 已超 8;再构造抖动大的序列,验证同样 3s 时 phi 仍低于 8。

生产路径不许 unwrap。跑 cargo test -p forge-cluster 和 cargo clippy,贴结果。

小结

  • 故障检测的本质约束:死了、慢了、断了在观察侧不可区分,一切判断都是概率
  • 脑裂的根因是双方都自认多数;gossip 只传情报,有法律效力的裁决必须走仲裁(Raft 课兑现)
  • gossip 用「每轮随机挑 k 个交换视图」把消息量从平方降到线性,收敛是对数轮
  • phi accrual 把固定超时换成自适应怀疑度,误判率和检测延迟的交易价格随网络波动自动调整
  • incarnation 让被冤枉的节点自己辟谣;计划内维护走 cordon,别劳驾故障检测
  • 成员列表有了,下一课回答:对象到底该放到哪个节点上