从零写一个 blob store
设计 forge-store 的盘上格式:定长 superblock、extent 分配、每块数据带 crc32c 校验。你将第一次体会「格式定了就改不动」的敬畏感。
学完这节你能做到
- 设计并文档化一个版本化的盘上格式
- 实现 extent 分配器的最简版本(bitmap)
- 写读路径时校验 crc32c,损坏时返回明确错误
格式定了就改不动
代码写错了,改一行重新部署;盘上格式写错了,用户的数据已经按错的格式躺在几百块盘上了。 你经历过文件系统升级「先卸载、跑转换工具、祈祷」的场面 —— 根源就是格式没留后路。三条铁律先立好:
- magic number 开路。文件头 4 字节放固定标识,打开先验:拿 ext4 盘喂给 forge、路径写错
指向别的文件 —— 认错直接拒开,不把别人的数据解析成垃圾再写坏。
file和blkid靠的就是它。 - 版本号紧随其后。格式一定会变,
version是唯一的逃生通道:新代码读老版本走兼容逻辑, 老代码读新版本明确报「请升级」,而不是错着解析。 - 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:
注意 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 来补。
superblock 里 magic 和 version 各自防的是什么事故?
AI 结对:用 property test 轰炸分配器
bitmap 分配器这种代码,手写用例永远想不全:分配释放交错、跨 u64 字边界的 run、恰好占满…… 这是 property-based testing 的主场 —— 你定不变量,机器生成几千个随机操作序列去撞。
验收重点仍是落盘红线:覆盖写是不是先写新、后放旧?刷盘有没有被悄悄省略? 别去逐行找 —— 让 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 来补