WAL 与崩溃一致性
突然断电时写到一半的数据怎么办?Write-Ahead Log 是所有存储系统的标准答案。实现追加日志、torn write 检测和启动时的恢复重放。
学完这节你能做到
- 解释 torn write、reorder、fsync 丢失三种崩溃场景
- 实现带长度前缀 + 校验和的日志记录格式
- 写出启动恢复逻辑:扫到损坏记录即截断
崩溃模型:断电瞬间盘上是什么
上一课故意留了个窟窿:bitmap 更新到一半崩溃,盘上就是一笔糊涂账。要补这个窟窿, 先得把「崩溃时盘上可能是什么状态」想清楚 —— 这叫崩溃模型,存储引擎设计的第一性问题。
你处理过断电后的机房,见过 fsck 跑几个小时、数据库 crash recovery 卡住的场面。 把那些现象翻译成三种精确的崩溃场景:
- torn write(撕裂写):一次 8 KiB 的写,断电时可能只有前 4 KiB 到了盘上。 盘对单个 512 B / 4 KiB 物理扇区的写通常是原子的,跨扇区没有任何原子性承诺。 盘上出现「前半新、后半旧」的弗兰肯斯坦记录。
- reorder(乱序落盘):你按 A、B 的顺序 write,盘缓存和调度器可能让 B 先持久、A 后持久。 两次 fsync 之间的写,持久顺序不确定 —— 「我先写了数据再写指针,指针在数据肯定在」这种推理不成立。
- 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)); // 逐个唤醒等待者
}
}
}
批的大小不用刻意调:负载轻时每批就一条(延迟最优),压力上来队列自然变长、 批自然变大(吞吐自动爬升)—— 这叫自适应批处理,不需要你运维时最讨厌的那种魔法参数。
断电后 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,不许返回脏数据。这套手法在里程碑课会扩成完整脚本。
这是本阶段 review 强度最高的一次:三条红线全中(落盘顺序、错误处理、并发)。 逐行确认三件事 —— sync_data 在唤醒等待者之前(顺序反了,群发的持久承诺就是空头支票); 刷盘线程出错时等待者拿到 Err 而不是永久阻塞; 恢复是物理截断而不是内存里忽略。任何一条说不清楚,退回重写。
在 forge-store crate 里新增 wal 模块,按以下规格实现: 1. 记录格式:len u32 + seq u64 + crc u32 + payload,全小端;crc 用 forge-util 的 crc32c_of,覆盖 seq 与 payload;单条 payload 上限 16 MiB; 2. Wal::append(payload) 阻塞到该记录 fsync 完成才返回 Ok(seq);内部用后台刷盘线程实现 group commit:攒一批、写一批、一次 sync_data、逐个唤醒; 3. Wal::recover(path) 顺序扫描:len 越界、crc 不匹配、seq 不连续三种情况都视为日志尾,立即停止;返回合法记录列表,并把文件物理截断(set_len)到最后一条合法记录之后,截断后 sync_data; 4. 错误全部走 StoreError,生产路径零 unwrap;刷盘线程遇到 io 错误要让所有等待者收到 Err,而不是永远挂起; 5. 测试:正常追加与恢复;手工构造三种坏尾巴(len 写成巨大值、payload 截掉后半、伪造 seq 跳变的残骸)各验证恢复停在正确位置;恢复后继续 append 再恢复,确认不受旧残骸影响。 跑 cargo test -p forge-store 和 cargo clippy,贴结果。
小结
- 崩溃模型三件事:torn write(跨扇区无原子性)、reorder(fsync 之间顺序不保)、未 fsync 即蒸发
- WAL 记录三件套:len 定边界、crc 验完整、seq 防前世残骸;能推导的状态不落盘存两份
- group commit 用一次 fsync 送走一批写,负载越大批越大,不需要调参
- 恢复即「扫到坏记录截断」,且必须物理截断;核心不变量:确认过的必在,截掉的必未确认
- kill -9 杀不死 page cache,断电要靠 dm-flakey 模拟 —— 里程碑课的主武器之一
- 下一课把 WAL、bitmap、blob 组装成完整引擎:Bitcask 模型登场