Storforge
编码预计 55 分钟

故障重建与再平衡

掉盘之后的数据补齐是分布式存储最凶险的时刻:重建流量打满怎么办?优先级怎么排?实现声明式的重建引擎:目标状态 - 现状 = 任务队列。

学完这节你能做到

  • 实现扫描-对账-补齐的重建循环
  • 实现重建限速与业务流量的隔离
  • 解释 Weka 众包式重建为什么随规模变快

最凶险的时刻不是故障,是故障之后

运维分布式存储的人都知道这个悖论:节点挂掉的瞬间系统往往没事(降级读扛着), 真正的事故常发生在重建开始之后 —— 重建流量和业务流量抢盘、抢网、抢 CPU, 延迟起飞,应用超时,重试风暴,然后雪崩。你在 Ceph 上调过 osd_max_backfillsosd_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 讲过的结论,现在是你写的代码。

Checkpoint单选

重建队列为什么按「条带已丢失分片数」降序,而不是按文件或 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 倍读放大。

AI 结对:实现声明式重建引擎pair with ai

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 倍读放大