故障重建与再平衡
掉盘之后的数据补齐是分布式存储最凶险的时刻:重建流量打满怎么办?优先级怎么排?实现声明式的重建引擎:目标状态 - 现状 = 任务队列。
学完这节你能做到
- 实现扫描-对账-补齐的重建循环
- 实现重建限速与业务流量的隔离
- 解释 Weka 众包式重建为什么随规模变快
最凶险的时刻不是故障,是故障之后
运维分布式存储的人都知道这个悖论:节点挂掉的瞬间系统往往没事(降级读扛着),
真正的事故常发生在重建开始之后 —— 重建流量和业务流量抢盘、抢网、抢 CPU,
延迟起飞,应用超时,重试风暴,然后雪崩。你在 Ceph 上调过 osd_max_backfills、
osd_recovery_max_active,半夜盯着 ceph -s 里的 recovery 速度和业务延迟做拉锯 ——
那些参数背后的机制,这节课你要亲手实现一遍。
L3 的 milestone 里 forge 已经有了「节点回归后补齐数据」的雏形,但那是一次性脚本思维。 这节课把它升级成声明式重建引擎,并且解决三个真问题:补什么先补、开多大并行、 怎么给业务流量让路。
声明式重建:像 K8s controller 一样思考
命令式思维写重建是这样的:「检测到节点 X 挂了,遍历它的数据,逐个搬走」。 这条路你运维时见过它的所有死法:搬到一半 X 回来了怎么办?搬的过程中又挂一个呢? 脚本自己崩了呢?每种情况都要补 if,补到最后没人敢改。
K8s 教过你更好的模式:不描述动作,描述目标状态,循环对账。
Deployment 说「我要 3 个副本」,controller 永远在算 期望 - 现状,差多少补多少 ——
中途任何扰动,下一轮对账自然吸收。forge 的重建循环同构:
/// forge-node: 声明式重建循环。目标状态 - 现状 = 任务队列
pub async fn reconcile_loop(&self) -> Result<(), NodeError> {
loop {
// 期望:放置函数说这个节点该持有哪些 chunk(输入:当前成员视图 + extent 全集)
let want = self.placement.expected_chunks(&self.membership.live_view()).await?;
// 现状:本地盘上实际有哪些 chunk(扫 forge-store 的索引,不扫全盘)
let have = self.local_index.chunk_set().await?;
let missing = want.difference(&have); // 该有而没有:去重建
let orphan = have.difference(&want); // 有而不该有:延迟回收
for task in self.prioritize(missing) {
self.limiter.acquire(task.estimated_bytes()).await; // 限速,下文详解
if let Err(e) = self.reconstruct_chunk(task).await {
// 单个失败只记录,不中断循环 —— 下一轮对账自然重试
tracing::warn!(chunk = %task.chunk_id, error = %e, "reconstruct failed");
}
}
self.gc_orphans_later(orphan); // 孤儿延迟删,防误判(节点闪断)
tokio::time::sleep(RECONCILE_INTERVAL).await;
}
}
这个循环的美德和 K8s controller 一样:幂等、自愈、无状态。节点闪断又回来,
want 自动变回去,白搬的数据当孤儿慢慢回收;重建中再挂一个节点,下一轮 want
重算,任务队列自动更新;进程重启,循环从头对账,没有「断点续传」要维护。
注意孤儿是延迟回收 —— 成员视图误判(L3 讲过故障检测永远不完美)时,
激进删除等于自己给自己制造数据丢失。
优先级与重建源:先救命,再治病
prioritize 不是随便排序,是风险管理。4+2 的条带丢 1 个分片还有余量,
丢 2 个分片就是再丢即死。Weka 的重建先扫两级:
fn prioritize(&self, missing: impl Iterator<Item = RebuildTask>) -> Vec<RebuildTask> {
let mut tasks: Vec<_> = missing.collect();
// 关键序:同条带已丢失的分片数降序 —— 丢 2 个的条带排最前
tasks.sort_by_key(|t| std::cmp::Reverse(t.stripe_missing_count));
tasks
}
丢 2 个分片的条带全集群可能只占百分之几(两个故障域同时中招的交集), 优先把它们从「危险区」拉回「降级区」,系统的再容错能力几分钟内就恢复了, 剩下的慢慢补 —— 这比按 inode 顺序扫描的朴素做法,把「暴露窗口」缩短一个量级。
重建源选择上,兑现上一课埋的伏笔:重建一个 chunk 要读同条带 4 个幸存分片, 这 4 个分片散布在不同节点 —— 所以读放大天然被全集群分摊。实现时再加两条: 同一条带的 4 路读并行发;多个重建任务错开源节点(简单做法:按 chunk_id 哈希打散任务顺序),避免碰巧都从同一台机器读。这就是「众包式重建」的完整形态: 节点越多,每个节点分摊的重建读写越少,重建越快 —— L0 讲过的结论,现在是你写的代码。
重建队列为什么按「条带已丢失分片数」降序,而不是按文件或 inode 顺序?
限速:令牌桶与自适应退让
重建跑太慢,暴露窗口长;跑太快,业务延迟起飞 —— 你在 Ceph 上手动拧的那对参数, 现在要做成机制。两层:
第一层,令牌桶保底。给重建流量一个硬上限(比如每节点 200 MB/s), 简单可预测,值得先做对:
/// forge-util: 字节令牌桶。重建每读写一个 chunk 前先 acquire
pub struct ByteLimiter {
rate: AtomicU64, // 当前速率上限,bytes/s,可在线调
bucket: Mutex<BucketState>, // 余额 + 上次补充时间,溢出封顶一个 burst
}
impl ByteLimiter {
pub async fn acquire(&self, bytes: u64) {
loop {
let wait = {
let mut b = self.bucket.lock().await;
b.refill(self.rate.load(Ordering::Relaxed));
match b.try_take(bytes) {
Ok(()) => return,
Err(deficit) => deficit_to_wait(deficit, self.rate.load(Ordering::Relaxed)),
}
}; // 锁在 sleep 前释放 —— 拿着锁睡觉是并发红线
tokio::time::sleep(wait).await;
}
}
}
第二层,自适应退让。固定上限的缺陷你运维时深有体会:业务高峰时 200 MB/s 还是太吵,凌晨空闲时又白白浪费。做法是把 L2 forge-bench 练出来的本事接进来: 节点持续统计业务 IO 的 P99 延迟(滑动窗口),重建循环每隔几秒对账一次 —— P99 超过阈值(比如 20 毫秒)就把令牌桶速率减半,连续几个窗口健康就加回 10%。 乘性减、加性增,TCP 拥塞控制的老配方,收敛快、恢复稳。业务永远优先, 重建吃剩下的 —— Weka 同样把重建流量放在低于业务 IO 的调度等级上,思路一致。
把当前速率、退让状态、重建积压量全部暴露成 forge-cli 命令和 metrics:
forge-cli rebuild status 应该一眼能看到「积压 1.2 万个 chunk,
当前 80 MB/s,因业务 P99 超标退让中」。你半夜排障时最恨的就是黑盒重建 ——
现在你是作者,别把同样的黑盒留给未来的自己。
再平衡:扩容也是同一个循环
新节点加入,一致性哈希环上它接管一段区间,一批 chunk 的「期望位置」变了 ——
注意,这不需要任何新代码。声明式循环的 want 由放置函数算出,成员视图一变,
新节点的对账循环发现自己缺一堆 chunk,开始拉取;老节点发现多出一堆孤儿,延迟回收。
重建和再平衡是同一个循环的两种输入,这就是声明式的红利。
再平衡与重建仍有两处差异要在代码里区别对待:优先级上再平衡永远最低
(没有丢数据风险,stripe_missing_count 为 0 自然排在队尾,顺手就对了);
搬迁方式上再平衡可以直接从旧持有者整块拷贝(它还活着),不需要走 RS 重构 ——
reconstruct_chunk 里先探测源 chunk 是否健在,健在就拷贝,不在才重构,
省下 4 倍读放大。
review 的红线在并发与状态机。第一,ByteLimiter 里锁的持有范围 —— AI 爱写「拿着 Mutex 睡 sleep」,一眼假,必须锁外等待。第二,orphan 延迟回收: 确认它在删除前重新对账一次 want(10 分钟前的孤儿判定可能已过期)。 第三,退让状态机的单测要你自己出题:构造「P99 抖一下就恢复」的序列, 看速率会不会被一次毛刺打到谷底回不来 —— 这正是你运维时被烂限速器坑过的场景。
在 forge-node crate 里实现声明式重建引擎: 1. reconcile_loop:每 10 秒对账一次,want 来自放置函数(输入当前成员视图),have 来自本地 chunk 索引;missing 进重建队列,orphan 进延迟回收队列(默认延迟 10 分钟,可配); 2. 优先级:按同条带已丢失分片数降序;任务顺序再按 chunk_id 哈希打散以分摊源节点压力; 3. reconstruct_chunk:先探测原持有者上该 chunk 是否可直接拷贝(再平衡场景),可拷则拷,否则并行读同条带 4 个幸存分片走 reed-solomon 重构,落盘前后各校验一次 crc32c; 4. 在 forge-util 里实现 ByteLimiter 令牌桶(rate 可在线改,burst 封顶 2 秒配额),重建读写前 acquire; 5. 自适应退让:业务 P99(由已有的延迟直方图模块提供,窗口 5 秒)超过 20ms 时速率减半,连续 3 个窗口低于 10ms 时速率乘 1.1,上下限 [10 MB/s, 400 MB/s]; 6. forge-cli 新增 rebuild status 子命令:输出积压 chunk 数、当前速率、退让状态、危险条带(丢 2 分片)剩余数; 7. 测试:用内存 ChunkStore 模拟 6 节点,杀 1 节点后断言危险条带先于普通条带完成;令牌桶单测(速率准确性、burst 封顶);退让状态机单测(P99 序列输入,断言速率轨迹)。 生产路径不许 unwrap。跑 cargo test 和 cargo clippy 贴结果。
小结
- 重建的凶险不在故障本身,在重建流量与业务的资源争抢 —— Ceph 那对 recovery 参数背后的问题,现在由你的代码回答
- 声明式循环:want 减 have 得任务队列,幂等、自愈、无断点状态;孤儿延迟回收,给故障误判留反悔期
- 优先级 = 风险管理:丢 2 分片的条带最先重建,几分钟内恢复全系统的再容错能力
- 限速两层:令牌桶给硬上限,业务 P99 驱动的乘性减加性增做自适应 —— 业务优先,重建吃剩
- 再平衡不是新功能,是同一个对账循环换了输入;能直接拷贝就不走 RS 重构,省 4 倍读放大