Storforge
编码预计 60 分钟

从零写一个 blob store

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

学完这节你能做到

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

格式定了就改不动

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

  1. magic number 开路。文件头 4 字节放固定标识,打开先验:拿 ext4 盘喂给 forge、路径写错 指向别的文件 —— 认错直接拒开,不把别人的数据解析成垃圾再写坏。fileblkid 靠的就是它。
  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 结构里全是这种留白。编解码代码,手写、逐字节、无魔法:

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 别删:释放一个本来就空闲的块说明上层记账错了,越早炸越好 —— 和你巡检脚本里的一致性检查是同一个思路。线性扫描 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

验收重点(落盘红线):put 的覆盖写是不是先写新、后放旧?sync_data 有没有被悄悄省略? 再读 proptest 的不变量定义 —— 那几行才是测试的灵魂,不变量错了生成器再花哨也等于没测。 跑通后手动把盘上某条记录改一个字节,确认 get 报错而不是返回脏数据。

在 forge-store crate 里完成两件事:

一、实现本课设计的引擎:
1. Superblock 的 encode/decode(64 字节布局按课文表格,小端,crc32c 用 forge-util 的 crc32c_of,覆盖前 60 字节);
2. BitmapAllocator:alloc(n) 返回 n 个连续空闲块的起始号(first-fit),free(start, n) 释放;double free 用 debug_assert 拦截;
3. FileBlobStore 实现 trait BlobStore 的 put/get/delete;记录头 16 字节:magic2、key_len u32、val_len u32、crc32c u32(覆盖 key+value);put 走 write 加 sync_data;get 必须先验 crc,失败返回 ChecksumMismatch 错误;
4. 错误类型 StoreError 用 thiserror 定义,变体至少含 BadMagic、UnsupportedVersion、ChecksumMismatch、TooShort、TruncatedRecord、NoSpace、Io;生产路径零 unwrap。

二、用 proptest 写 property test 轰炸 BitmapAllocator:
1. 随机生成几百步 alloc/free 操作序列,维护一个朴素的参照实现(Vec 存布尔)对拍;
2. 不变量:任意两次 alloc 返回的区间互不重叠;free 过的块能被重新分配;已用块数与参照一致;
3. 专门覆盖:跨 64 位字边界的连续分配、恰好把盘分满、分满后 alloc 返回 None。

跑 cargo test -p forge-store 和 cargo clippy,贴结果。

小结

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