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
}

三个设计点,每个都要弄懂到能用自己的话复述 —— 让 AI 指着代码讲,讲到你能复述为止:

  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

红线在第 3、4、6 条 —— 落盘顺序与崩溃安全。不用逐行读代码,改成逼它把话说明白: 让 AI 把「一次满条带写」和「一次后台整理」各画成一条时间线,标出每一步之后崩溃分别 留下什么、谁负责清理。你要能自己复述出同一个答案:范围记录是唯一真相, 孤儿回收对账它 —— 分片落盘不是提交,指针切换才是。 AI 在这里最常犯的错是把「删日志段」放在「更新索引」之前,数据窗口就是这么开出来的; 让它明确指出是哪一条测试证明了这个顺序,然后自己去把断言改坏、看它变红。 另外让它把「废弃条带的三种来源」(提交失败的半成品、被重写替换掉的旧条带、整理完的日志段) 列清楚,每一种由谁在什么条件下回收 —— 列不全的,回收路径里就有漏网的垃圾。

目标:给 forgefs 造数据通路 —— 把应用那些任意偏移、任意大小的读写,翻译成规规矩矩的 EC 条带,并且让几 KB 的小写不必付读改写的代价。数据面的持久化和编解码复用 L3 已有的 reed-solomon 与放置模块;到存储节点的那一层要能换成内存实现,好让整条通路脱离真集群单独测试。

行为规格:
1. 几何参数定死在超级块里、建好文件系统后不可变:单个分片 512 KiB,4 个数据分片 + 2 个校验分片,于是一个满条带承载 2 MiB 用户数据。文件内偏移到「第几个条带、条带内第几个数据分片、分片内偏移」是纯算术,边界必须精确:偏移 0、正好落在条带边界、一次写跨两个条带,三种都要对;
2. 一段连续的文件范围对应一个条带对象,记录起始偏移(按 2 MiB 对齐)、条带 ID、有效数据长度(文件尾那条可以短于 2 MiB)。条带 ID 与位置解耦:条带会被重写(小写沉淀、后台整理都会生成新条带替换旧的),替换就该是改一条范围记录、旧条带异步回收,而不是原地覆盖;
3. 满条带写:切 4 份算 2 份校验,并行发 6 个节点,必须 6 个全部确认才算成功。写路径不许降级 —— 允许 4 个就提交等于主动制造降级条带,把重建债务留给未来的自己;节点不够就明确报错,让上层排队或告警;
4. 提交点是范围记录的指针切换,不是分片落盘。分片写了 5 个就崩,指针没切,旧数据必须完好无损;那些半成品分片由后台按「没有任何范围记录引用的孤儿条带」回收。同一个(条带 ID, 分片序号)重复提交必须安全,否则重试就不敢做;
5. 小写不许碰条带:把小写编码成记录追加进写日志,日志段本身同样 EC 落盘(持久性不打折),追满 2 MiB 就密封换新段,落盘确认即可返回。读路径两级查找:先查这段范围有没有还没沉淀的日志记录,命中则日志优先,否则走范围记录读条带;
6. 后台整理把密封段里仍然有效的记录按文件归并,凑满条带走满条带写,成功之后才原子更新索引、最后才删日志段。顺序反了就出数据窗口:先删段再更新索引的话,中间崩一次这批小写就人间蒸发。整理中途被强杀,重跑必须安全,不许写出重复条带、不许让范围记录指错;
7. 每个分片带一个 crc32c,从写入方一路带到盘上、读回来再验一次。内存实现要能故意注入位翻转,让我看到端到端校验真的会当场抓住它。

交付:实现 + 覆盖上述每条的自动化测试 + 一个小命令行工具,能对着内存实现做这几件事:按指定偏移和长度写、整文件读回、列出当前所有条带和孤儿条带、手工触发一次后台整理、开关小写聚合、指定让第几个分片提交失败。(模块怎么拆、用什么库、错误怎么建模你定,最后告诉我为什么选它。)
你的验收动作自己跑、自己看,不用读代码
  • 用那个小工具写一个 100 MiB 的文件再整文件读回比 md5;然后随机偏移、随机长度小写 5000 次,每次都读回比对。一次不符就是地址翻译或者「日志优先于条带」的优先级错了。再往其中一个分片注入一次位翻转,读回来必须当场报校验失败或者走重构拿到正确数据,绝不许把翻转过的字节当正常数据交出去
  • 对照实验:同一批一万次 4 KiB 随机小写,分别开着和关着小写聚合各跑一遍,记两组耗时和两组产生的分片写次数。关掉聚合那组必须明显更慢、写次数明显更多 —— 差不出来说明聚合根本没走追加路径,或者对照组也没真的做读改写
  • 破坏性验证:让第 5 个分片提交失败,写一批数据,然后强杀进程。重启后确认这段范围读回来的是旧数据(不是半新半旧、也不是报错),并用列条带的命令看到那批半成品被认成孤儿、随后被回收
  • 在后台整理跑到一半时强杀进程,重启让它重跑,再整文件读回比 md5;重复 10 次。这一步专抓第 6 条的顺序错误 —— 把删日志段放在更新索引之前的实现,会在这里丢掉整整一段小写
  • 让 AI 把「6 个分片没全成功就绝不切指针」那条断言故意改成允许 4 个就切,确认测试变红,而且上面第三条实验会读出半新半旧的数据。绿灯不会说谎才有意义
必须问到听懂的问题答不上来就别合并
  • 6 个分片写了 5 个的瞬间断电,我下次读这段文件读到什么?旧数据、新数据,还是半新半旧?为什么?
  • 一个 4 KiB 的小写落进日志就返回成功了,这一刻断电,这条写还在吗?它和「直接改条带」相比,盘上各发生了几次读、几次写?
  • 后台整理正在把日志沉淀成条带,这期间我读同一段数据,读到的是日志里的还是条带里的?谁说了算?
  • 哪些条带算孤儿?判定孤儿的依据是什么、谁在什么时候删它们?误删一个还在被引用的条带会有什么后果?

小结

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