Storforge
编码预计 60 分钟

条带化写路径

文件数据切成 chunk、攒成条带、EC 编码、并行落到多节点 —— 把 L3 的所有零件串成 Weka 式的写路径,小写聚合是关键。

学完这节你能做到

  • 实现 文件偏移 → 条带 → 节点 的地址翻译
  • 实现小写聚合:日志式追加 + 后台整理
  • 端到端校验和:从客户端到盘

把 L3 的零件串成一条写路径

L3 结束时你手里有:reed-solomon 4+2 编解码、一致性哈希放置、带超时重试的 RPC。 但那是对象存储的用法 —— 一个 key 一次写完。文件系统的数据面难在 任意偏移、任意大小的读写:编译器会在文件末尾追加 200 字节,链接器会 seek 回头 改一个 header,日志会每次 append 4KB。这节课在 forge-fs 里造地址翻译层和写路径, 把这些不规矩的 IO 喂给规矩的 EC 条带。

Weka 对照:这条路径就是它的「条带 + EC + 日志式聚合写」,我们做粒度更粗的等价物。

地址翻译层:extent map 的设计

先定几何参数,再谈翻译:

/// forge-fs: 条带几何。定死在超级块里,建 fs 后不可变
pub const CHUNK_SIZE: u64 = 512 * 1024;              // 单分片 512 KiB
pub const DATA_SHARDS: usize = 4;
pub const PARITY_SHARDS: usize = 2;
/// 一个满条带承载的用户数据量:4 x 512KiB = 2 MiB
pub const STRIPE_DATA: u64 = CHUNK_SIZE * DATA_SHARDS as u64;

文件偏移到条带的翻译是纯算术:

/// 文件内偏移 -> (第几个条带, 条带内哪个数据分片, 分片内偏移)
pub fn locate(offset: u64) -> StripeAddr {
    let stripe_no = offset / STRIPE_DATA;
    let in_stripe = offset % STRIPE_DATA;
    StripeAddr {
        stripe_no,
        data_shard: (in_stripe / CHUNK_SIZE) as u32,   // 0..4
        offset_in_chunk: in_stripe % CHUNK_SIZE,
    }
}

但「第几个条带」还要落到「哪 6 个节点的哪个对象」—— 这就是 extent map: inode 里的 extent_root 指向一张有序表,每项记录一段文件范围对应的条带 ID, 条带 ID 再经 L3 的一致性哈希算出 6 个分片的归属节点。

/// 一段连续文件范围 -> 一个条带对象
#[derive(Serialize, Deserialize, Clone)]
pub struct Extent {
    pub file_off: u64,     // 起始文件偏移,STRIPE_DATA 对齐
    pub stripe_id: StripeId, // 全局唯一,放置函数的输入
    pub len: u64,          // 有效数据长度,可以小于 STRIPE_DATA(文件尾)
}

为什么不直接用「ino + 条带号」当 stripe_id?因为条带会被重写(小写聚合、 后台整理都会生成新条带替换旧的),ID 和位置解耦后,替换 = 改一条 extent 记录, 旧条带异步回收 —— 你运维 Ceph 时熟悉的「先写新、再切指针、后删旧」。

ichunk 为什么选 512 KiB

太小(如 64 KiB)条带只有 256 KiB,放置和元数据开销摊不薄;太大(如 4 MiB) 小文件浪费严重、重建单位太重。512 KiB x 4 的条带是 2 MiB,对齐大多数 NVMe 的 最优写尺寸区间,重建一个 chunk 也只要几毫秒。Weka 用的 chunk 远小于此 (它的元数据开销被 bucket 摊薄了),我们的粒度粗,这是诚实的取舍。

满条带写与部分条带写

写路径分岔口。判据只有一个:这次写能不能凑满一整个条带?

  • 满条带写(整 2 MiB 对齐):直接走 L3 通路 —— 切 4 个数据 chunk, RS 算 2 个校验 chunk,并行发 6 个节点,等 6 个确认(写不允许降级,读才允许)。 零额外惩罚,顺序大 IO 的黄金路径,编译产物、拷贝大文件都走这里。
  • 部分条带写(几 KB 的 append、条带中间改几百字节):如果原地更新, 必须先读出条带里的旧数据和旧校验块,合并后重算 RS 再写回 —— 一次 4 KiB 的用户写膨胀成多次读 + 6 次写。这就是你运维 Ceph EC 池时 4k 随机写只有三副本池十分之一性能的机制性原因:读改写惩罚

Weka 的答案不是优化读改写,而是让它不发生

小写聚合:日志结构的答案

小写不碰条带,先追加进写日志:

  1. 客户端把小写(偏移 + 数据)编码成日志记录,追加到当前日志段
  2. 日志段本身也是 EC 4+2 落盘的(持久性不打折),追满 STRIPE_DATA 就密封换新段
  3. 落盘确认即可向应用返回 —— 延迟是一次追加,不是读改写
  4. 后台整理任务把日志段里的有效数据按文件归并,凑成满条带写入, 更新 extent map,回收日志段

这就是 L1 Bitcask 的分布式变奏:所有写都是追加,读时按「日志新于条带」的优先级合并, GC(这里叫整理)负责把日志沉淀为规整的条带。你在 L1 已经写过一遍单机版, 所以这节课的心智负担比看起来小。

读路径相应变成两级查找:先查该范围有没有未整理的日志记录(内存索引,分片持有), 命中则日志优先;否则走 extent map 读条带。

Checkpoint单选

为什么日志式聚合能绕开 EC 的读改写惩罚?

并行提交与失败回滚

满条带写要 6 个节点全部确认,失败处理必须想清楚 —— 这是落盘路径,红线代码:

/// forge-fs: 满条带提交。要么 6 分片全部持久化,要么整条带作废
pub async fn commit_stripe(
    &self,
    stripe_id: StripeId,
    shards: [Bytes; DATA_SHARDS + PARITY_SHARDS],
) -> Result<(), FsError> {
    let targets = self.placement.locate(stripe_id)?;   // 6 个节点,故障域约束
    let mut set = JoinSet::new();
    for (shard_idx, (node, data)) in targets.iter().zip(shards.into_iter()).enumerate() {
        let client = self.client_for(node)?;
        set.spawn(async move {
            // put_chunk 幂等:同 (stripe_id, shard_idx) 重放安全
            client.put_chunk(stripe_id, shard_idx as u32, data).await
        });
    }
    while let Some(res) = set.join_next().await {
        match res {
            Ok(Ok(())) => continue,
            Ok(Err(e)) => {
                // 任一分片失败:向所有节点发异步 abort_stripe,整条带作废
                self.abort_stripe(stripe_id);
                return Err(FsError::StripeCommitFailed { stripe_id, source: e.into() });
            }
            Err(join_err) => {
                self.abort_stripe(stripe_id);
                return Err(FsError::Internal(join_err.to_string()));
            }
        }
    }
    // 6/6 落盘成功,才把 extent map 指过来 —— 指针切换是提交点
    self.meta.install_extent(stripe_id).await
}

三个设计点,每个都值得对着代码确认:

  1. 提交点是 extent map 的指针切换,不是 chunk 落盘。chunk 写了 5 个就崩, extent map 没切,旧数据完好 —— 半成品条带由后台按「无 extent 引用的孤儿条带」回收。
  2. put_chunk 必须幂等,重试才安全(L3 的老规矩:先幂等,再重试)。
  3. 写路径不降级。读缺 2 个分片能重构,但写必须 6/6 —— 允许 4/6 提交 等于主动制造降级条带,把重建债务留给未来的自己。节点不够就报错,让上层排队或告警。

端到端校验和贯穿全程:客户端算好每个 chunk 的 crc32c(L0 写的 forge-util 模块), 随 put_chunk 下发,节点落盘前验一次、读取时再验一次 —— 从内存位翻转到网卡固件 bug, 哪一环坏了都能当场抓住,而不是让脏数据混进编译产物。

AI 结对:实现条带写路径与小写日志pair with ai

这个任务的红线是第 2、4 条 —— 落盘顺序与崩溃安全。review 时问自己三个问题: extent 安装之前崩,谁负责回收孤儿条带?整理任务删日志段之前崩,重跑会不会写出 重复条带、extent 会不会指错?abort_stripe 是异步尽力而为,那没 abort 成功的 chunk 靠什么兜底?(答案都应该指向同一个机制:extent map 是唯一真相, 孤儿回收对账它。)AI 若把「删日志段」放在「更新索引」之前,数据窗口就出现了 —— 抓它。

在 forge-fs crate 里实现条带化数据通路(依赖 L3 已有的 reed-solomon 编解码和放置模块,网络层用 trait ChunkStore 抽象,方法 put_chunk / get_chunk / delete_stripe,便于用内存实现做测试):

1. 常量:CHUNK_SIZE = 512 KiB,DATA_SHARDS = 4,PARITY_SHARDS = 2;实现 locate(offset) -> StripeAddr 和 Extent/extent map(BTreeMap 按 file_off 索引);
2. 实现 commit_stripe:并行提交 6 分片,任一失败即 abort 并返回错误,全部成功后才安装 extent;put_chunk 按 (stripe_id, shard_idx) 幂等;
3. 实现小写日志:WriteLog 追加 (ino, offset, data) 记录,段满 2 MiB 密封;读路径先查日志内存索引再查 extent map,日志优先;
4. 实现后台整理 compact_segment:把密封段中仍有效的记录按 ino 归并,凑满条带调 commit_stripe,成功后原子更新索引并删除日志段;整理中途失败必须可安全重跑;
5. 每个 chunk 附带 crc32c,ChunkStore 内存实现在 get 时校验并可注入位翻转用于测试;
6. 测试覆盖:偏移翻译边界(0、条带边界、跨条带)、4 KiB 随机小写后全文件读回逐字节比对、commit 第 5 分片失败后旧数据可读且无 extent 泄漏、整理中途 kill(模拟)重跑后数据一致。

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

小结

  • 地址翻译两级:偏移到条带是纯算术(512 KiB x 4 + 2 几何),条带到节点走 extent map + 一致性哈希;stripe_id 与位置解耦,条带可替换
  • 满条带写走 L3 通路零惩罚;部分条带写若原地更新就是读改写 —— Ceph EC 池小写慢十倍的机制性原因
  • 小写聚合 = 分布式 Bitcask:日志追加(同样 EC 落盘)即返回,后台整理凑满条带,读路径日志优先
  • 提交点是 extent 指针切换;put_chunk 幂等;写不降级,6/6 或失败
  • crc32c 从客户端带到盘再带回来,端到端校验让任何一环的位翻转当场现形