Storforge
编码预计 60 分钟

WAL 与崩溃一致性

突然断电时写到一半的数据怎么办?Write-Ahead Log 是所有存储系统的标准答案。实现追加日志、torn write 检测和启动时的恢复重放。

学完这节你能做到

  • 解释 torn write、reorder、fsync 丢失三种崩溃场景
  • 实现带长度前缀 + 校验和的日志记录格式
  • 写出启动恢复逻辑:扫到损坏记录即截断

崩溃模型:断电瞬间盘上是什么

上一课故意留了个窟窿:bitmap 更新到一半崩溃,盘上就是一笔糊涂账。要补这个窟窿, 先得把「崩溃时盘上可能是什么状态」想清楚 —— 这叫崩溃模型,存储引擎设计的第一性问题。

你处理过断电后的机房,见过 fsck 跑几个小时、数据库 crash recovery 卡住的场面。 把那些现象翻译成三种精确的崩溃场景:

  1. torn write(撕裂写):一次 8 KiB 的写,断电时可能只有前 4 KiB 到了盘上。 盘对单个 512 B / 4 KiB 物理扇区的写通常是原子的,跨扇区没有任何原子性承诺。 盘上出现「前半新、后半旧」的弗兰肯斯坦记录。
  2. reorder(乱序落盘):你按 A、B 的顺序 write,盘缓存和调度器可能让 B 先持久、A 后持久。 两次 fsync 之间的写,持久顺序不确定 —— 「我先写了数据再写指针,指针在数据肯定在」这种推理不成立。
  3. fsync 丢失:还没 fsync 的数据,断电就是没了,干干净净。这个最好理解,上上课讲透了。
!不许再说「大概率没事」

运维时你可以说「这机器 UPS 撑得住,断电概率低」。写引擎时不行:forge 的验收标准是 kill -9 一万次零丢失 —— 概率论救不了你,只有不变量救得了你: 无论崩在哪个指令之间,重启后都要能恢复到某个一致状态。

所有存储系统对这三种场景的标准答案都是同一个:Write-Ahead Log。 任何修改先以追加形式写进日志并 fsync,然后才改真正的数据结构。崩溃后重放日志, 就能把没做完的事补完或干净地丢弃。ext4 的 journal、PostgreSQL 的 WAL、 RocksDB 的 log,你天天运维的东西里全有它。

WAL 记录格式:length-prefix、crc、sequence

追加日志的格式必须能回答一个问题:扫到一半,怎么知道这条记录是完整的? 答案是三件套 —— 长度前缀定边界,crc 验完整性,序列号防「前世残骸」:

+----------+----------+----------+=====================+
| len: u32 | seq: u64 | crc: u32 |  payload (len 字节)  |
+----------+----------+----------+=====================+
     crc 覆盖 seq + payload,不含 len
use forge_util::checksum::crc32c_of;

pub const WAL_HEADER_LEN: usize = 16;

pub struct WalRecord {
    pub seq: u64,
    pub payload: Vec<u8>,
}

impl WalRecord {
    pub fn encode(&self) -> Vec<u8> {
        let mut buf = Vec::with_capacity(WAL_HEADER_LEN + self.payload.len());
        buf.extend_from_slice(&(self.payload.len() as u32).to_le_bytes());
        buf.extend_from_slice(&self.seq.to_le_bytes());
        let mut crc_input = Vec::with_capacity(8 + self.payload.len());
        crc_input.extend_from_slice(&self.seq.to_le_bytes());
        crc_input.extend_from_slice(&self.payload);
        buf.extend_from_slice(&crc32c_of(&crc_input).to_le_bytes());
        buf.extend_from_slice(&self.payload);
        buf
    }
}

三个字段各挡一种崩溃:

  • len 定边界 —— 但它自己可能是垃圾(torn write 撕在头部),所以读的时候要做合法性检查: len 超过文件剩余长度或超过单条上限(比如 16 MiB),直接判定损坏。
  • crc 验尸 —— torn write 撕在 payload 中间时,len 看起来合法,crc 会揭穿它。
  • seq 严格递增 —— 防一种阴险场景:日志文件是复用/预分配的,旧日志的残骸还躺在后面。 扫描时读到一条 crc 合法但 seq 不等于「上一条加一」的记录,说明撞上前世残骸了,停。

payload 里装什么?上一课的三种操作,编码成最简单的 tag + 字段: put(key、value)、delete(key)。bitmap 的变更不用记 —— 它可以从操作序列推导出来,能推导的东西就不要存两份,少一份就少一种不一致。

group commit:吞吐与延迟的第一次交易

每条 WAL 记录写完都 fsync,才有持久保证。但上上课你测过数字:一次 fsync 在 NVMe 上 20~100 µs,SATA SSD 上约 1 ms —— 每写必刷,单线程吞吐立刻被钉死在每秒一万以下,SATA 上只有一千。

标准解法是 group commit:多个并发写请求攒成一批,一次 fsync 送走全部。 本质是一笔交易:每个请求多等一小会儿(延迟略升),换整体吞吐十倍百倍。 PostgreSQL 的 commit_delay、MySQL binlog 的 group commit,你调过的参数就是这个思路。

forge 的最简实现:一个后台刷盘线程,前台把记录塞进队列后等通知:

/// 前台:追加记录,等它真正持久后才返回
pub fn append(&self, payload: Vec<u8>) -> Result<u64, StoreError> {
    let (done_tx, done_rx) = std::sync::mpsc::channel();
    self.queue_tx
        .send(Pending { payload, done_tx })
        .map_err(|_| StoreError::WalClosed)?;
    // 阻塞到刷盘线程 fsync 完成 —— 返回即持久,语义绝不含糊
    done_rx.recv().map_err(|_| StoreError::WalClosed)?
}

/// 后台刷盘线程主循环(简化)
fn flush_loop(&mut self) -> Result<(), StoreError> {
    loop {
        let batch = self.drain_queue_blocking(); // 至少一条;顺手带走已排队的全部
        if batch.is_empty() {
            return Ok(()); // 队列关闭,正常退出
        }
        for p in &batch {
            let rec = WalRecord { seq: self.next_seq, payload: p.payload.clone() };
            self.file.write_all(&rec.encode())?;
            self.next_seq += 1;
        }
        self.file.sync_data()?; // 一次 fsync,送走一整批
        for p in batch {
            let _ = p.done_tx.send(Ok(self.next_seq - 1)); // 逐个唤醒等待者
        }
    }
}

批的大小不用刻意调:负载轻时每批就一条(延迟最优),压力上来队列自然变长、 批自然变大(吞吐自动爬升)—— 这叫自适应批处理,不需要你运维时最讨厌的那种魔法参数。

Checkpoint单选

断电后 WAL 末尾有一条记录:len 字段合法,crc 校验失败。最可能发生了什么,该怎么处理?

恢复重放与截断

启动时的恢复逻辑,规则就一条:顺序扫描,逐条验证,遇到第一条坏记录即截断到它之前。

pub fn recover(path: &Path) -> Result<(Vec<WalRecord>, u64), StoreError> {
    let data = std::fs::read(path)?;
    let mut records = Vec::new();
    let mut offset = 0usize;
    let mut expect_seq: Option<u64> = None;

    loop {
        let Some(header) = try_read_header(&data, offset) else { break };
        let end = offset + WAL_HEADER_LEN + header.len as usize;
        if header.len as usize > MAX_RECORD_LEN || end > data.len() {
            break; // len 是垃圾,或记录越过文件尾:torn write
        }
        let payload = &data[offset + WAL_HEADER_LEN..end];
        if crc32c_of(&[&header.seq.to_le_bytes()[..], payload].concat()) != header.crc {
            break; // payload 撕裂
        }
        if let Some(expected) = expect_seq {
            if header.seq != expected {
                break; // 前世残骸:crc 对但 seq 不连续
            }
        }
        expect_seq = Some(header.seq + 1);
        records.push(WalRecord { seq: header.seq, payload: payload.to_vec() });
        offset = end;
    }

    truncate_and_sync(path, offset)?; // 物理截掉尾巴,防残骸干扰下一次追加
    Ok((records, offset as u64))
}

两个容易被 AI 和人同时忽略的细节:

  • 截断要真截。只在内存里忽略坏尾巴不够 —— 新记录追加在坏数据后面, 下次恢复扫到坏记录就提前停了,新数据全部「失联」。必须 set_len 物理截断,再 fsync。
  • 截断丢掉的是什么? 只可能是「还没向调用方确认」的写 —— 因为确认在 fsync 之后, 而 fsync 过的记录不会坏在尾部。这就是 WAL 的核心不变量:确认过的,重放必在; 截掉的,必然从未确认过。丢了但没确认,不算丢 —— 调用方本来就该按超时重试。

恢复出的记录如何使用,下一课组装引擎时展开:重放 put/delete 序列、重建 bitmap 与内存索引。

动手:dm-flakey 断电模拟

kill -9 只能杀进程,page cache 还在,内核还会替你把数据刷下去 —— 它模拟不了断电。 真断电的效果是「盘缓存和未刷脏页凭空蒸发」,Linux 内核自带的 device-mapper 靶子 dm-flakey 可以逼真地模拟:周期性让设备变坏,写入被静默丢弃。你做过存储运维, dmsetup 不陌生,这次用它打自己的引擎:

# 底座:一个 1 GiB 的 loop 设备(别拿真盘练手)
truncate -s 1g /tmp/forge-disk.img
LOOP=$(losetup --find --show /tmp/forge-disk.img)
SECTORS=$(blockdev --getsz "$LOOP")

# flakey 靶子:每 6 秒周期里,5 秒正常,1 秒 drop_writes(写被静默扔掉)
dmsetup create forge-flaky --table \
  "0 $SECTORS flakey $LOOP 0 5 1 1 drop_writes"

# 引擎对着 /dev/mapper/forge-flaky 持续写入,循环中随机 kill -9
# 然后 dmsetup remove、重新建普通映射,启动恢复,验证不变量

验收的不变量就是上面那句话:所有 append 返回过 Ok 的记录,恢复后必须完整存在; 恢复过程本身不许 panic,不许返回脏数据。这套手法在里程碑课会扩成完整脚本。

⚡AI 结对:给 forge-store 加 WAL 模块pair with ai

这是本阶段 review 强度最高的一次:三条红线全中(落盘顺序、错误处理、并发)。 不用逐行读代码,改成逼它把话说明白:让 AI 画出「一批写入从入队到返回成功」的执行顺序, 你要能确认刷盘发生在唤醒等待者之前 —— 顺序反了,群发的持久承诺就是空头支票; 再让它指出是哪一条测试证明了这个顺序,然后自己去把那条断言改坏、看它变红。 同样问清刷盘线程出错时等待者怎么拿到失败、恢复是物理截断而不是内存里忽略。 任何一条它讲不明白、或者讲了却没有测试背书,退回重做。

目标:给 forge-store 加一条写前日志,保证进程被 kill -9 之后,已经确认成功的写入一条都不丢。

行为规格:
1. 盘上格式:每条记录由 4 字节长度 + 8 字节序号 + 4 字节校验和 + 数据组成,全部小端;校验和用 crc32c,覆盖序号与数据、不含长度字段;单条数据上限 16 MiB,超过就明确拒绝;
2. 写入接口在这条记录真正落到盘上(不是只进 page cache)之前不能返回成功;返回时要告诉调用方这条拿到的序号;
3. 多个并发写入允许攒成一批、共用一次刷盘,但每个调用方拿到成功回执时,它那条必须已经落盘 —— 刷盘必须发生在唤醒等待者之前,顺序不许反;
4. 序号严格连续递增,用来识别「日志文件被复用、后面躺着上一世的残骸」这种情况;
5. 重启恢复时顺序扫描日志,三种情况都视为日志到此结束并立即停下:长度字段超过单条上限或越过文件尾、校验和不匹配、序号不等于上一条加一;
6. 停下之后要把文件物理截断到最后一条完整记录之后,并把截断结果刷到盘上。只在内存里忽略坏尾巴不够 —— 新记录追加在坏数据后面,下次恢复扫到坏记录就提前停了,新数据会全部失联;
7. 刷盘线程如果遇到 IO 错误,所有等待中的写入都要收到失败,绝不能永远挂住;日志已经关闭之后再写入,也要立刻拿到明确的失败,不许挂住;
8. 任何预期内的失败都要变成明确的错误返回给调用方,而不是让进程直接崩溃。

交付:实现 + 覆盖上述每条的自动化测试 + 一个能手工制造三种坏日志尾巴的脚本:把长度字段改成一个巨大值、把最后一条记录的后半截掉、在末尾伪造一条校验和正确但序号跳变的残骸。三种情况下恢复都要停在正确位置,恢复后还能继续追加,再恢复一次不受旧残骸影响。(用什么库算 crc、线程之间怎么通信你定,最后告诉我为什么选它。)
你的验收动作自己跑、自己看,不用读代码
  • 跑 AI 给的测试命令确认全绿;再让它把「刷盘早于唤醒等待者」那条测试的断言故意改错,确认测试会红 —— 绿灯不会说谎才有意义
  • 连续追加一万条后 kill -9,重启恢复,确认恢复出的最大序号不小于最后一次成功返回的序号;重复 20 次,一次都不许少
  • 用 dd 把日志文件尾部截掉几十字节,再启动:确认它能起来、只丢最后一条,并且用 stat 看到文件大小真的变小了 —— 物理截断,不是内存里假装
  • 截断恢复之后继续追加几百条,再重启恢复一次,确认这几百条全在。这一步专抓「假截断」:只在内存里忽略坏尾巴的实现会在这里丢掉全部新数据
  • 用 strace -f -e trace=write,fdatasync,fsync 跟一次并发写:应该看到多次 write 之后只跟一次刷盘,而不是每条一次 —— 攒批真的生效了
必须问到听懂的问题答不上来就别合并
  • 如果机器在写入返回成功的那一瞬间断电,这条数据还在吗?为什么?
  • 刷盘线程挂了,正在等待的那些写入会怎样?谁负责告诉它们失败了?
  • 一批攒了 50 条一起刷盘,刷盘失败了:这 50 个调用方分别看到什么?盘上留下什么?
  • 恢复时截掉的那些记录,有没有可能是我已经收到过成功回执的?为什么?

小结

  • 崩溃模型三件事:torn write(跨扇区无原子性)、reorder(fsync 之间顺序不保)、未 fsync 即蒸发
  • WAL 记录三件套:len 定边界、crc 验完整、seq 防前世残骸;能推导的状态不落盘存两份
  • group commit 用一次 fsync 送走一批写,负载越大批越大,不需要调参
  • 恢复即「扫到坏记录截断」,且必须物理截断;核心不变量:确认过的必在,截掉的必未确认
  • kill -9 杀不死 page cache,断电要靠 dm-flakey 模拟 —— 里程碑课的主武器之一
  • 下一课把 WAL、bitmap、blob 组装成完整引擎:Bitcask 模型登场