Storforge
编码预计 60 分钟

从零写一个 blob store

设计 forge-store 的盘上格式:定长 superblock、extent 分配、每块数据带 crc32c 校验。你将第一次体会「格式定了就改不动」的敬畏感。

学完这节你能做到

  • 设计并文档化一个版本化的盘上格式
  • 实现 extent 分配器的最简版本(bitmap)
  • 写读路径时校验 crc32c,损坏时返回明确错误

格式定了就改不动

代码写错了,改一行重新部署;盘上格式写错了,用户的数据已经按错的格式躺在几百块盘上了。 你经历过文件系统升级「先卸载、跑转换工具、祈祷」的场面 —— 根源就是格式没留后路。三条铁律先立好:

  1. magic number 开路。文件头 4 字节放固定标识,打开先验:拿 ext4 盘喂给 forge、路径写错 指向别的文件 —— 认错直接拒开,不把别人的数据解析成垃圾再写坏。file 和 blkid 靠的就是它。
  2. 版本号紧随其后。格式一定会变,version 是唯一的逃生通道:新代码读老版本走兼容逻辑, 老代码读新版本明确报「请升级」,而不是错着解析。
  3. endianness 一次说死。forge 全部字段固定小端(x86 和绝大多数 ARM 的本机序),显式 to_le_bytes / from_le_bytes,永远不 transmute 整个 struct 落盘 —— struct 的内存布局 受对齐和填充摆布,直接落盘等于把编译器的实现细节焊死进格式。

superblock:一块盘的身份证

forge-store 管理一个大文件(或整块裸盘),开头 64 字节是 superblock —— 相当于 ext4 superblock 或 GPT 头。v1 布局直接看它躺在盘上的样子:点击图例里的字段条目,十六进制转储会高亮它的字节范围。 逐个点一遍,确认 version 的 u16 小端是 01 00 而不是 00 01,block_size 的 4096 是 00 10 00 00:

on-disk formatforge-store superblock v1(64 字节)64 B
0000000046 4f 52 47 01 00 00 00  00 10 00 00 00 00 04 00 
0000001000 00 00 00 3f a2 77 10  9c 4e 42 d1 8a 03 5b 66 
00000020c1 ee 24 7b 00 00 00 00  00 00 00 00 00 00 00 00 
0000003000 00 00 00 00 00 00 00  00 00 00 00 9d 4b 12 e7 

注意 reserved 那 24 字节 —— 现在看是浪费,两年后它是你唯一能加字段而不换版本号的地方, GPT 头、NVMe Identify 结构里全是这种留白。编解码代码长这样 —— 手写、逐字节、无魔法。 代码由 AI 写,你要读到能指着每个偏移说出它是哪个字段:

use forge_util::checksum::crc32c_of;

pub const MAGIC: [u8; 4] = *b"FORG";
pub const SUPERBLOCK_LEN: usize = 64;

pub struct Superblock {
    pub version: u16,
    pub flags: u16,
    pub block_size: u32,
    pub total_blocks: u64,
    pub uuid: [u8; 16],
}

impl Superblock {
    pub fn encode(&self) -> [u8; SUPERBLOCK_LEN] {
        let mut buf = [0u8; SUPERBLOCK_LEN];
        buf[0..4].copy_from_slice(&MAGIC);
        buf[4..6].copy_from_slice(&self.version.to_le_bytes());
        buf[6..8].copy_from_slice(&self.flags.to_le_bytes());
        buf[8..12].copy_from_slice(&self.block_size.to_le_bytes());
        buf[12..20].copy_from_slice(&self.total_blocks.to_le_bytes());
        buf[20..36].copy_from_slice(&self.uuid);
        // 36..60 保持全零(reserved)
        let crc = crc32c_of(&buf[0..60]);
        buf[60..64].copy_from_slice(&crc.to_le_bytes());
        buf
    }

    pub fn decode(buf: &[u8]) -> Result<Self, StoreError> {
        if buf.len() < SUPERBLOCK_LEN {
            return Err(StoreError::TooShort);
        }
        if buf[0..4] != MAGIC {
            return Err(StoreError::BadMagic);
        }
        let stored = u32::from_le_bytes(buf[60..64].try_into().expect("长度已校验"));
        if crc32c_of(&buf[0..60]) != stored {
            return Err(StoreError::ChecksumMismatch { what: "superblock" });
        }
        let version = u16::from_le_bytes(buf[4..6].try_into().expect("长度已校验"));
        if version != 1 {
            return Err(StoreError::UnsupportedVersion { found: version });
        }
        decode_fields(buf, version) // 其余字段按偏移 from_le_bytes 解出,略
    }
}

decode 的检查顺序有讲究:先 magic(是不是我的文件),再 crc(内容坏没坏),再 version (我认不认识)—— 每层拒绝都给出可区分的错误,值班时你最恨那种只会说 error 的系统。

extent bitmap:最简分配器

superblock 之后放 extent bitmap:每个数据块占 1 个 bit,1 表示已占用。1 GiB 的存储区 共 262144 个 4 KiB 块,bitmap 只要 32 KiB —— 8 个块装下,常驻内存毫无压力。

pub struct BitmapAllocator {
    words: Vec<u64>,   // 每个 u64 管 64 个块
    total_blocks: u64,
}

impl BitmapAllocator {
    /// first-fit:线性扫出 n 个连续空闲块,标记后返回起始块号;满则 None
    pub fn alloc(&mut self, n: u64) -> Option<u64> {
        let start = self.find_free_run(n)?; // 线性扫描,O(n),先求对再求快
        self.mark_used(start, n);
        Some(start)
    }

    pub fn free(&mut self, start: u64, n: u64) {
        for blk in start..start + n {
            debug_assert!(!self.is_free(blk), "double free 是逻辑 bug,测试期就要炸");
            self.clear(blk);
        }
    }

    // find_free_run / is_free / mark_used / clear:位运算实现,略
}

free 里那个 debug_assert 别让 AI 省掉:释放一个本来就空闲的块说明上层记账错了,越早炸越好 —— 和你巡检脚本里的一致性检查是同一个思路。线性扫描 O(n) 先不优化,性能账 L2 用数据说话再算。

crc32c:每块数据自带指纹

坏盘不总是「整块盘消失」那么痛快,更阴险的是静默损坏:盘返回了数据、没报任何错, 内容却和写下去的不一样(bit rot、固件 bug、线缆问题)。dmesg 里见得到 I/O error, 静默损坏却什么痕迹都没有 —— 唯一防线是端到端校验和:写时算好存下,读时重算比对。 选 crc32c 而不是 md5/sha:它有专用 CPU 指令(x86 SSE4.2、ARM CRC 扩展),单核吞吐 10 GB/s 以上,快到能给每次读写都上;32 bit 碰撞率对「检测意外损坏」绰绰有余(不防恶意伪造, 那是签名的分工)。L0 写好的 crc32c_of 现在派上用场:每个 blob 落盘的记录头 16 字节, [magic2 u32][key_len u32][val_len u32][crc32c u32],crc 覆盖 key + value。 读路径必须先验 crc 再返回,校验失败一个字节都不给 —— 返回明知可疑的数据比报错危险一万倍, 上层会拿它继续计算、继续复制,损坏就静默扩散了:

fn read_record(&self, extent: &Extent) -> Result<(Vec<u8>, Vec<u8>), StoreError> {
    let raw = self.read_extent(extent)?;
    let header = RecordHeader::decode(&raw[0..16])?;
    let end = 16 + header.key_len as usize + header.val_len as usize;
    let payload = raw.get(16..end).ok_or(StoreError::TruncatedRecord)?;
    if crc32c_of(payload) != header.crc {
        // 绝不返回可疑数据 —— 宁可报错,让上层走副本/EC 修复(L3 兑现)
        return Err(StoreError::ChecksumMismatch { what: "record" });
    }
    Ok(split_key_value(payload, header.key_len)) // 按 key_len 切开,略
}

put / get / delete:兑现 L0 的伏笔

L0 讲 trait 时埋的 BlobStore 伏笔现在兑现,顺手加一个 delete —— 趁只有一个实现者,改接口还便宜:

pub trait BlobStore {
    fn put(&mut self, key: &[u8], value: &[u8]) -> Result<(), StoreError>;
    fn get(&self, key: &[u8]) -> Result<Option<Vec<u8>>, StoreError>;
    fn delete(&mut self, key: &[u8]) -> Result<(), StoreError>;
}

FileBlobStore 的实现思路(完整代码交给 AI 结对,你负责验收):

  • put:算记录总长 → 向 bitmap 要连续块 → 写头部 + 数据 → sync_data → 更新内存索引。 同 key 覆盖写:先写新的、再释放旧 extent —— 顺序反了,崩溃窗口里数据就没了。
  • get:查内存索引拿 extent → 读盘 → 验 crc → 返回。索引没有就 Ok(None),「没有」是合法答案。
  • delete:释放 extent、删索引项。眼尖的话你会发现问题:bitmap 改了还没持久化时崩溃怎么办? 没错,本课的引擎崩溃后 bitmap 和数据可能不一致 —— 窟窿是故意留的,下一课 WAL 来补。
Checkpoint单选

superblock 里 magic 和 version 各自防的是什么事故?

AI 结对:用 property test 轰炸分配器

bitmap 分配器这种代码,手写用例永远想不全:分配释放交错、跨 u64 字边界的 run、恰好占满…… 这是 property-based testing 的主场 —— 你定不变量,机器生成几千个随机操作序列去撞。

⚡AI 结对:实现 FileBlobStore 并轰炸分配器pair with ai

验收重点仍是落盘红线:覆盖写是不是先写新、后放旧?刷盘有没有被悄悄省略? 别去逐行找 —— 让 AI 把写入路径的步骤按顺序列出来编号,然后你挨个问「崩在这一步之后盘上是什么」。 属性测试同理:让它把每条不变量用一句中文说清「它在防哪种 bug」, 说不出防什么的不变量就是装饰品,不变量错了生成器再花哨也等于没测。 最后一定要亲手改一个字节,确认读路径报错 —— 这条比任何解释都硬。

目标:把本课设计的盘上格式落地成一个能用的 blob 存储 —— superblock、extent bitmap 分配器、按 key 存取的写读删,并且用随机操作序列把分配器轰一遍。

行为规格(盘上格式,一个字节都不能差):
1. 文件开头 64 字节是 superblock,字段与偏移严格按课文那张表:magic 是 ASCII FORG(偏移 0,4 字节)、version(偏移 4,2 字节)、flags(偏移 6,2 字节)、block_size(偏移 8,4 字节)、total_blocks(偏移 12,8 字节)、uuid(偏移 20,16 字节)、reserved 全零(偏移 36,24 字节)、crc32c(偏移 60,4 字节,覆盖前 60 字节)。所有多字节字段小端;
2. 打开时的检查顺序必须是 magic → crc → version,三种失败给出三种可区分的错误,让我一看报错就知道是拿错文件、内容坏了、还是格式代际不认识;
3. 每条 blob 记录的头部 16 字节依次是 magic2、key 长度、value 长度、crc32c,各 4 字节小端;crc 覆盖 key 与 value 拼起来的内容;
4. 写入:算出记录总长 → 向 bitmap 要连续块 → 写头部与数据 → 把数据刷到盘上 → 才更新内存索引。同一个 key 覆盖写必须先写新的、再释放旧位置,顺序反了崩溃窗口里数据就没了;
5. 读取:查到位置、一次定点读盘、验 crc。校验失败一个字节都不许返回,必须报错;key 不存在是合法答案,要和「出错了」区分开;
6. 删除:释放占用的块并删掉索引项。这一版允许崩溃后 bitmap 与数据不一致,这个窟窿是故意留的、下一课补 —— 但要在代码注释里写明这是已知缺口;
7. 空间不够、记录被截断、校验和不匹配、magic 不对、版本不认识 —— 每种预期内的失败都要变成明确的错误返回给调用方,不许让进程直接崩掉。

行为规格(分配器与轰炸测试):
8. bitmap 里每个数据块占 1 个 bit,1 表示已占用;分配走 first-fit:线性扫出 n 个连续空闲块,标记后返回起始块号,盘满就明确告诉调用方没空间了。先求对,不要为性能牺牲可读性;
9. 释放一个本来就空闲的块属于上层记账 bug,测试期就要立刻炸出来,不许静默容忍;
10. 用随机化的属性测试轰炸分配器:生成几百步 alloc/free 混合序列,同时维护一个笨到不可能写错的参照实现对拍。不变量:任意两次分配返回的区间互不重叠;释放过的块能被重新分配;已用块数与参照实现始终一致;
11. 轰炸必须专门覆盖三种边界:跨越 bitmap 内部字边界(一个字管 64 个块)的连续分配、恰好把盘分满、分满后再分配必须明确返回没空间。

交付:实现 + 覆盖上述每条的自动化测试 + 一条能把盘上任意位置的一个字节改掉的小命令(我要用它验证读路径会报错)。用什么库做属性测试、错误类型怎么组织你定,最后告诉我为什么这么选。
你的验收动作自己跑、自己看,不用读代码
  • 建一个 1 GiB 的 store,用 xxd -l 64 看文件头,逐字段对着课文里那张十六进制图核:version 必须是 01 00,block_size 的 4096 必须是 00 10 00 00 —— 字节序搞反在这一步就能抓住
  • 跑 AI 给的测试命令确认全绿;再让它把属性测试里「两次分配的区间不重叠」这条不变量故意写反,确认测试立刻变红 —— 绿灯不会说谎才有意义
  • 写进一个 key,用 xxd 找到那条记录,把 value 中间某个字节改掉,再读这个 key:必须报校验和错误,绝不能把脏数据交给你
  • 把 superblock 偏移 0 的第一个字节改掉,确认报的是「magic 不对」;改回来,把偏移 4 的 version 改成 99,确认报的是「版本不认识」—— 两种错必须能分辨
  • 一直写到报错(盘满),确认错误是明确的「没空间」;再删掉几个 key 后继续写,确认刚释放的空间能被重新用上
必须问到听懂的问题答不上来就别合并
  • 写入路径里「刷盘」这一步在哪个位置?如果它挪到更新内存索引之后,崩溃时会发生什么?
  • 同一个 key 连续写两次,中间断电,我读到的是新值还是旧值?有没有可能两个都读不到?
  • 删除之后立刻断电,重启时 bitmap 说这块空闲、但旧数据还躺在盘上,下次分配会不会把新数据写到那儿?这算不算数据丢失?
  • crc 只有 32 位,为什么够用?什么情况下它会漏掉损坏?

小结

  • 盘上格式三铁律:magic 认身份、version 留后路、endianness 显式小端;superblock 是盘的身份证,reserved 留白是未来的救命稻草,挂载第一件事验 crc
  • extent bitmap 是最简分配器,先要正确(debug_assert 拦 double free),性能 L2 再算
  • crc32c 有硬件指令、快到能全量开;读路径验校验和,失败必须报错,绝不返回可疑数据
  • put/get/delete 落地了 L0 的 BlobStore trait;崩溃一致性的窟窿是故意留的 —— 下一课 WAL 来补