Storforge
编码预计 55 分钟

故障重建与再平衡

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

学完这节你能做到

  • 实现扫描-对账-补齐的重建循环
  • 实现重建限速与业务流量的隔离
  • 解释 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 讲过的结论,现在是你写的代码。

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

红线在并发与状态机,但你不用读并发代码 —— 换成让它交答案再用实验打它。 第一,限速器等配额时是不是占着锁睡觉:让 AI 明确回答「一个任务在等配额的那几百毫秒里, 别的任务能不能同时申请配额」,答「不能」或者含糊其辞的,就是那个一眼假的写法。 第二,孤儿延迟回收:让它讲清楚删之前有没有重新对账一次 —— 10 分钟前的孤儿判定可能早过期了,拿旧判定去删就是自己制造数据丢失。 第三,退让状态机的题要你自己出:构造「P99 只抖高一个窗口就恢复」的序列, 让它把速率轨迹的数字打出来给你看,速率不该被一次毛刺打到谷底半天爬不上来。 最后让它把「重建」和「再平衡」的差别列成两列对照 —— 优先级、数据来源、要不要重算,三行都说对了,说明它真按同一个循环做的,而不是写了两套。

目标:把「节点挂了之后怎么把数据补回来」做成一个永远在跑的对账循环,而不是一次性搬迁脚本。同一个循环顺带把「加了新节点之后的再平衡」也做掉 —— 它们只是同一个循环的两种输入。

行为规格:
1. 每 10 秒对账一次:算出「按放置规则这个节点现在该持有哪些分片」和「本地实际有哪些分片」。该有而没有的进重建队列,有而不该有的进延迟回收队列。单个任务失败只记日志、不中断循环 —— 下一轮对账自然重试,不要维护任何断点续传状态;
2. 孤儿分片默认延迟 10 分钟才真删,而且删之前必须重新对账一次:故障检测永远不完美,节点闪断又回来时激进删除等于自己给自己制造数据丢失;
3. 重建顺序按风险排,不按文件顺序:同一条带已经丢的分片数越多越靠前。4+2 丢 1 个还有余量,丢 2 个是再丢即死,先把这批从危险区拉回降级区,几分钟内就能恢复全系统的再容错能力;同优先级内把任务顺序打散,别让多个重建任务碰巧都从同一台机器上读;
4. 补一个分片时先探一下原持有者上这个分片是不是还能直接整块拷(扩容再平衡就是这种情况),能拷就拷,拷不到才并行读同条带的 4 个幸存分片重算 —— 重算要付 4 倍读放大,能省就省;分片落盘前后各校验一次校验和;
5. 重建流量要有硬上限,按字节限速,速率可以在线改,允许攒最多 2 秒配额的突发。等配额的时候绝不能占着锁睡觉 —— 一个人在那儿睡,所有人跟着堵;
6. 自适应退让:持续统计业务请求的 P99 延迟(5 秒滑动窗口),超过 20 毫秒就把重建速率减半,连续 3 个窗口低于 10 毫秒就把速率乘 1.1,速率钳在 10 MB/s 到 400 MB/s 之间。乘性减、加性增,TCP 拥塞控制的老配方 —— 业务永远优先,重建吃剩下的;
7. 重建状态是运维接口,不只是内部机制:一条命令要能一眼看到积压多少个分片、当前速率多少、是不是正因为业务延迟超标而退让、危险条带(丢 2 分片)还剩几个。半夜排障时最恨的就是黑盒重建,别把这个黑盒留给未来的自己。

交付:实现 + 一个能在单机上模拟 6 个节点的测试环境(含杀节点、拉回节点、加新节点三个动作)+ 覆盖这几件事的自动化测试:杀 1 个节点后危险条带先于普通条带补完;限速的实际速率与设定值相符且突发不超过 2 秒配额;喂进去一串 P99 数值,断言速率轨迹符合预期。(用什么库、状态怎么组织、限速器内部怎么实现你定,最后告诉我为什么选它。)
你的验收动作自己跑、自己看,不用读代码
  • 在单机模拟环境里杀掉 1 个节点,用状态命令每 5 秒看一次:危险条带数应该先归零、普通积压后归零。记下两个归零时刻 —— 顺序反了或者两者同步下降,说明按风险排序根本没生效
  • 限速要用外部观察验,不能只信它自己打印的数字:速率设成 50 MB/s 触发重建,在节点上用 iostat 或者看网卡收发字节,实际速率应该稳在 50 MB/s 附近,而不是一路顶到盘的极限。再把速率在线改成 200 MB/s,确认几秒内跟上
  • 退让的对照实验,这一项最容易翻车:一边跑业务负载一边杀节点,盯状态命令的速率轨迹,业务压力上来时速率必须下调;然后停掉业务负载,速率应该在几十秒内爬回来。爬不回来就是被一次毛刺打到谷底出不来了 —— 你运维时被烂限速器坑过的正是这个场景
  • 闪断实验验延迟回收:杀掉一个节点,不到 10 分钟就把它拉回来。确认它盘上原有的分片没有被立刻删光、集群里也没出现「某个分片谁都不认」的情况,而孤儿队列在随后一轮对账里自己清空了
  • 让 AI 把「孤儿删除前重新对账一次」这一步故意去掉,重跑上一条闪断实验,你必须能观察到明显差别(比如一批分片被删了又重建一遍)。观察不到差别说明这条实验没打到点上,得先把实验改对
必须问到听懂的问题答不上来就别合并
  • 重建正在进行,业务这时去读一个正好缺分片的条带,读到的是什么?比正常读慢多少、慢在哪一段?
  • 一个节点闪断 30 秒又回来,这 30 秒里集群做了什么?它回来之后那些白搬的数据怎么处理、谁来删、什么时候删?为什么不马上删?
  • 重建限速设成 50 MB/s,补完一个节点的数据要多久?这个估算里最不确定的一项是什么?
  • 业务 P99 一直高居不下,重建速率会被压到多低?会不会压到根本补不完、危险条带一直挂着没人管?这种情况我从哪条命令上能看出来?

小结

  • 重建的凶险不在故障本身,在重建流量与业务的资源争抢 —— Ceph 那对 recovery 参数背后的问题,现在由你的代码回答
  • 声明式循环:want 减 have 得任务队列,幂等、自愈、无断点状态;孤儿延迟回收,给故障误判留反悔期
  • 优先级 = 风险管理:丢 2 分片的条带最先重建,几分钟内恢复全系统的再容错能力
  • 限速两层:令牌桶给硬上限,业务 P99 驱动的乘性减加性增做自适应 —— 业务优先,重建吃剩
  • 再平衡不是新功能,是同一个对账循环换了输入;能直接拷贝就不走 RS 重构,省 4 倍读放大