集群成员与故障检测
「节点挂了」在代码里是什么?心跳、超时、误判 —— 实现基于 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 秒 φ 就飙高;本来就抖的网络, 阈值自动放宽 —— 你不用再半夜改超时参数了。
节点状态机里为什么要有 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 时早就实践过了。
本次的第一个重心是合并的顺序无关性:gossip 网络里消息到达顺序不可控, 合并函数只要有一点顺序依赖,集群视图就会永久分歧,而普通单测根本测不出来 —— 让 AI 讲清楚它的模糊测试是怎么构造乱序输入的,讲到你能自己说出「它在验证什么性质」。 第二个重心是 gossip 循环里的锁:持锁期间发 RPC 是经典死锁源, 让 AI 把这个循环的时序讲出来(什么时候拿锁、什么时候放、网络调用发生在哪一步), 然后用上面的丢包演练去证明它 —— 断网 20 秒期间,节点的其他命令响应必须依然正常。
目标:给 forge 建成员管理 —— 每个节点自己维护一份「集群里有谁、谁还活着」的视图,不依赖中心节点,几秒内就能把「某台疑似挂了」的情报传遍全网。 行为规格: 1. 每个成员记录这些事实:节点 ID、地址、化身号(incarnation,节点为自己辟谣用的自增版本)、状态(ALIVE / SUSPECT / DEAD)、上次直接听到它声音的时间; 2. 传播方式用 gossip:每秒每个节点随机挑最多 3 个 ALIVE 节点,交换彼此的完整成员视图。每节点的消息量固定,不随集群规模增长; 3. 两份视图的合并规则必须完全确定、与顺序无关:同一节点取化身号大的;化身号相同时取更严重的状态(ALIVE 弱于 SUSPECT 弱于 DEAD)。先合 A 再合 B 与先合 B 再合 A 结果必须一致,重复合并同一份视图结果不变 —— gossip 网络里消息到达顺序不可控,合并只要有一点顺序依赖,不同节点的视图就会永久分歧; 4. 故障判定不用固定超时,用自适应怀疑度:记录每个节点最近 100 次心跳的到达间隔,按历史规律算出「这么久没消息有多反常」,反常度超过阈值 8 转 SUSPECT,SUSPECT 持续 10 秒转 DEAD。样本不足 3 个时退化成固定 5 秒超时;间隔一向极稳的节点也不能因为一点点偏差就被判死(抖动按 10 毫秒兜底); 5. 平反机制:被误判成 SUSPECT 的节点听到流言后,把自己的化身号加一并重新宣告 ALIVE,合并规则保证它胜出。没有这个机制,「它死了」的旧流言会和「它活着」的新消息打架,永不收敛; 6. 运维接口:列出成员(状态、化身号、最后心跳、怀疑度)、主动标记节点下线(计划内维护走这条路,别劳驾故障检测)、取消标记; 7. gossip 循环里不许在持有共享状态锁的情况下发网络请求 —— 必须先取一份视图快照、放锁、再交换。持锁发 RPC 是经典的死锁与卡顿来源; 8. 任何预期内的失败都要变成明确的错误返回给调用方,而不是让进程直接崩溃。 交付:实现 + 合并顺序无关性的模糊测试 + 两个对照测试(一个心跳一向平稳的节点,3 秒无消息就该被怀疑;一个本来就抖得厉害的节点,同样 3 秒还不该被怀疑)+ 说明你选了哪些库、为什么。
- 本机起 3 个节点,等 10 秒后在每个节点上分别列成员,确认三份列表内容一致
- kill -9 掉一个节点,掐表看另外两个节点上它多久变 SUSPECT、多久变 DEAD,对照 8 和 10 秒这两个设定
- 用 iptables 把一个节点的 gossip 端口双向丢包 20 秒再放开,确认它被判 SUSPECT 后回来能自动平反成 ALIVE,而不是一直挂着 DEAD
- 给某个节点进程发 SIGSTOP 冻结 3 秒再 SIGCONT,模拟一次 GC 停顿,确认它没有被误判成 DEAD、也没有触发任何数据迁移
- 对一个节点执行主动下线命令,确认状态立刻变化、不用等故障检测超时;取消后能恢复
- 三个节点里有两个都认为 n2 死了,n2 自己却好好活着 —— 集群最后会收敛到哪个结论?靠什么收敛?
- gossip 消息在不同节点上的到达顺序是不一样的,为什么大家最后还能看到同一份成员表?如果合并规则有一点顺序依赖,现象会是什么、我怎么才能发现?
- 一个心跳时通时断的慢节点,自适应怀疑度会怎么表现?它比固定超时好在哪、又坏在哪?
- 成员表说 n2 死了,就能直接把 n2 的数据责任交给别人吗?为什么这件事必须等到 Raft?
小结
- 故障检测的本质约束:死了、慢了、断了在观察侧不可区分,一切判断都是概率
- 脑裂的根因是双方都自认多数;gossip 只传情报,有法律效力的裁决必须走仲裁(Raft 课兑现)
- gossip 用「每轮随机挑 k 个交换视图」把消息量从平方降到线性,收敛是对数轮
- phi accrual 把固定超时换成自适应怀疑度,误判率和检测延迟的交易价格随网络波动自动调整
- incarnation 让被冤枉的节点自己辟谣;计划内维护走 cordon,别劳驾故障检测
- 成员列表有了,下一课回答:对象到底该放到哪个节点上