从零写一个 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 结构里全是这种留白。编解码代码,手写、逐字节、无魔法:
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 来补。
superblock 里 magic 和 version 各自防的是什么事故?
AI 结对:用 property test 轰炸分配器
bitmap 分配器这种代码,手写用例永远想不全:分配释放交错、跨 u64 字边界的 run、恰好占满…… 这是 property-based testing 的主场 —— 你定不变量,机器生成几千个随机操作序列去撞。
验收重点(落盘红线):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 来补