里程碑①:kill -9 验收
给 forge-store 上酷刑:随机写入中 kill -9、写入中拔盘(dm-flakey)、注入位翻转。全部扛住才算过关 —— 验收标准就是这节课的脚本。
学完这节你能做到
- 跑通崩溃注入测试脚本并解读结果
- 用 AI 分析一次真实的数据损坏 case
- 写出本阶段的设计文档:格式、不变量、已知限制
验收的规矩:先造测谎仪,再上刑具
四课的产出到了交卷时刻。里程碑①的验收标准写在 curriculum 里:kill -9 万次不丢不坏、 断电模拟扛得住、位翻转必须被抓住。这和你以前做存储 POC 验收是同一件事, 只是这次被验的系统是你自己写的 —— 心态会很不一样,你会突然理解厂商工程师的表情。
破坏之前先解决一个方法论问题:怎么知道数据「不丢不坏」? 光靠引擎自己说了不算, 需要一个独立的「预言机」(oracle):写入负载完全由随机种子决定,任何 key 的正确值 都能从种子重新推算;每个已确认(put 返回 Ok)的 key 追加记进一个带 fsync 的 清单文件。验证器拿清单对引擎逐个盘问:
- 清单里的 key,
get必须返回Ok(Some(value))且值与种子推算一致 —— 否则丢数据,FAIL - 任何
get都不许 panic、不许返回错值 —— 否则坏数据,FAIL - 不在清单里的 key 允许存在(崩溃时已落盘但没来得及确认),这不是错
这正是 L1 冻结的语义承诺的可执行化:「put 返回即持久」逐字翻译成脚本。
测谎仪自己不能说谎:review 时盯死 acked.log 的 fsync 是否在下一条操作之前 —— 顺序错了,oracle 记录的就不是「已确认」而是「大概写过」,整个验收失去意义。 另外确认 verify 对「引擎多出来的未确认 key」不误报,否则万次 kill 里必然出现假阳性。
给 forge-cli 增加 torture 子命令组,作为 forge-store 的验收工具: 1. forge-cli torture fill --dir DIR --seed N --ops M:打开(或创建)DIR 下的 KvEngine,用 seed 派生的确定性伪随机流执行 M 次操作(80% put、15% 覆盖写、5% delete;key 为 seed 与序号派生,value 为 key 派生的 128 字节到 64 KiB 随机串);每次操作返回 Ok 后,把操作记录(op 类型、key、序号)追加写入 DIR/acked.log 并 fsync,然后才执行下一条;进程随时可能被 kill -9,这是预期用法; 2. forge-cli torture verify --dir DIR:读 acked.log(尾部半条记录按 WAL 规则截断,属正常),重放出"每个 key 的最终应有状态",逐 key 盘问引擎:应存在的 key 必须 get 到且值逐字节一致,应删除的 key 必须返回 None;任何不一致打印细节并以非零码退出;全过退出码 0,打印统计; 3. forge-cli torture corrupt --dir DIR --flip FILE:OFFSET:把指定封存文件 OFFSET 处的一个字节异或 0xFF(用于位翻转验收); 4. acked.log 用与 WAL 相同的 len/crc 记录格式,复用 forge-store 已有代码;全部错误走 Result,零 unwrap。 跑 cargo test 与 clippy,然后演示:fill 一万次操作、verify 通过、corrupt 翻转一个字节后 verify 必须抓到并非零退出。
验收项 1:万次随机 kill -9
工具就位,上第一道刑。脚本的关键是 kill 的时机随机 —— 让崩溃点均匀散布在 写数据、fsync、封存、GC 的每个缝隙里:
#!/usr/bin/env bash
# torture-kill.sh:万次随机 kill -9,任何一轮 verify 失败立即停下留现场
set -u
DIR=/tmp/forge-torture
BIN=./target/release/forge-cli
rm -rf "$DIR" && mkdir -p "$DIR"
for i in $(seq 1 10000); do
"$BIN" torture fill --dir "$DIR" --seed "$i" --ops 5000 &
PID=$!
# 随机存活 10~500 毫秒后处决;kill 不到说明它自己跑完了,也算一轮
sleep "0.$(printf '%02d' $((RANDOM % 50 + 1)))"
kill -9 "$PID" 2>/dev/null
wait "$PID" 2>/dev/null
if ! "$BIN" torture verify --dir "$DIR"; then
echo "FAIL at round $i —— 现场保留在 $DIR,别动,开始验尸"
exit 1
fi
done
echo "PASS: 10000 rounds"
跑之前先估个账:每轮 5000 次操作加一次全量 verify,数据量滚到几个 GiB 时 单轮约 1~3 秒,一万轮就是几个小时 —— 放后台跑,像你挂 badblocks 一样。 前一百轮建议盯着:早期 bug 密度高,通常撑不过 50 轮。
失败了怎么办?现场就是宝藏。$DIR 里有完整的数据文件和 acked.log,
verify 的输出指明了哪个 key 出了什么错 —— 拿 xxd 定位那条记录的字节,
对着上一课的 GC 五步和 WAL 截断逻辑推演崩溃点。定位手法和你分析 core dump 一个套路:
先锁定现场,再倒推时间线。
万次 kill -9 全过了,能宣布引擎断电安全吗?
验收项 2:dm-flakey 断电模拟
Quiz 里说破了:kill -9 杀不死 page cache。第二道刑用 WAL 课介绍过的 dm-flakey, 让「写被静默丢弃」真实发生 —— 这近似断电时盘缓存蒸发的效果:
#!/usr/bin/env bash
# torture-flakey.sh:引擎写入期间设备周期性丢写,循环 200 轮
set -u
IMG=/tmp/forge-flakey.img
DIR=/mnt/forge-flakey
BIN=./target/release/forge-cli
truncate -s 2g "$IMG"
LOOP=$(losetup --find --show "$IMG")
SECTORS=$(blockdev --getsz "$LOOP")
for i in $(seq 1 200); do
# 每 4 秒周期:3 秒正常,1 秒 drop_writes(写被丢但不报错 —— 最狠的模式)
dmsetup create forge-flaky --table \
"0 $SECTORS flakey $LOOP 0 3 1 1 drop_writes"
mkfs.ext4 -qF /dev/mapper/forge-flaky && mount /dev/mapper/forge-flaky "$DIR"
"$BIN" torture fill --dir "$DIR" --seed "$i" --ops 20000 &
PID=$!
sleep $((RANDOM % 8 + 2))
kill -9 "$PID" 2>/dev/null; wait "$PID" 2>/dev/null
# 撤掉故障注入,换干净映射再验证
umount "$DIR" && dmsetup remove forge-flaky
dmsetup create forge-ok --table "0 $SECTORS linear $LOOP 0"
mount /dev/mapper/forge-ok "$DIR"
"$BIN" torture verify --dir "$DIR" || { echo "FAIL at round $i"; exit 1; }
umount "$DIR" && dmsetup remove forge-ok
done
losetup -d "$LOOP"; echo "PASS: 200 rounds"
典型症状:verify 报「acked.log 里确认过的 key 读不到」。倒推几乎总是同一类根因 —— 确认早于持久:group commit 先唤醒等待者再 sync_data、acked.log 忘了 fsync、 或者 rename 后漏了 fsync 目录。drop_writes 恰好把"以为写了其实没写"的那部分蒸发,谎言现形。 这类 bug 在验收项 1 里永远测不出来。
验收项 3:手工位翻转,读路径必须报错
第三道刑最简单也最本质:静默损坏。用 torture corrupt 往封存文件里翻一个字节, 读路径必须报错,绝不返回脏数据 —— 这是 blob-store 课立下的军规,现在验兵:
#!/usr/bin/env bash
# torture-bitflip.sh:翻转 50 个随机位置,verify 必须每次都抓到
set -u
DIR=/tmp/forge-bitflip
BIN=./target/release/forge-cli
rm -rf "$DIR" && mkdir -p "$DIR"
"$BIN" torture fill --dir "$DIR" --seed 42 --ops 50000
for i in $(seq 1 50); do
FILE=$(ls "$DIR"/data-*.seal | shuf -n 1)
OFF=$((RANDOM * 32768 + RANDOM)) # 随机偏移,落在头部或数据都行
OFF=$((OFF % $(stat -c %s "$FILE")))
"$BIN" torture corrupt --dir "$DIR" --flip "$(basename "$FILE"):$OFF"
if "$BIN" torture verify --dir "$DIR" 2>/dev/null; then
echo "FAIL: 翻转 $FILE 偏移 $OFF 后 verify 竟然全过 —— 损坏漏检"
exit 1
fi
# 翻回来,恢复现场进入下一轮
"$BIN" torture corrupt --dir "$DIR" --flip "$(basename "$FILE"):$OFF"
done
echo "PASS: 50 flips all detected"
注意这个脚本的判定是反的:verify 成功才是失败。有两种漏检方式要分别想清楚 —— 翻在记录数据区,crc 必须抓到;翻在记录头的 len 字段,可能表现为解析出一条 畸形记录,引擎要报格式错误而不是 panic 或越界。跑完顺手看一眼报错信息的质量: 它有没有说清哪个文件、哪条记录、期望和实际的 crc?两年后半夜排障的人会感谢现在的你。
复盘:哪些 bug 是谁的锅
三项全绿不是终点,复盘才是本课的教学目的。翻出这一阶段所有修过的 bug (git log 和 AI 对话记录都在),按两个维度分类:AI 写错的还是你 spec 没说清的; review 该抓住的还是只有破坏性测试才能暴露的。多数学习者的统计结果长这样: 纯语法和边界 bug,AI 自产自销,测试就能拦;而「fsync 顺序不对」「rename 后漏 fsync 目录」 这类崩溃一致性 bug,测试全绿、review 才能抓 —— 恰好落在三条红线里, 这就是红线为什么存在的实证。最后把阶段成果固化成设计文档,格式就用你写惯的运维文档三段式: 是什么(格式与不变量)、怎么运维(工具与验收)、边界在哪(已知限制)。
任务一练的是你未来的日常:分布式阶段的故障现场比这复杂十倍,现在用单机现场练手感。 review 任务二时拿出你审运维文档的标准 —— 每条不变量是否真有测试背书? 「已知限制」有没有避重就轻?诚实的限制清单比漂亮的功能清单值钱。
两个任务,基于 forge-store 当前代码: 任务一(验尸练习):我保留了一个验收失败现场(或者请你先人为构造一个:在 GC 的 rename 与删除旧文件之间插入 panic,跑出一个崩溃现场)。请: 1. 用 xxd 检查现场目录里各数据文件的头部与尾部,列出每个文件的状态(完整/截断/存在 .tmp); 2. 结合 KvEngine 的启动恢复代码,逐步推演:重启后索引会如何重建,哪些 key 受影响; 3. 给出结论:这是 bug 还是设计内的安全状态?如果是 bug,指出最小修复。 任务二(设计文档):写 docs/forge-store-v1.md,只写事实不写愿景,三部分: 1. 盘上格式:superblock 布局、数据记录格式、hint 格式,每个字段的字节偏移与含义; 2. 不变量清单:put 返回即持久、损坏必报错、GC 五步顺序、恢复截断规则 —— 每条注明由哪个测试或验收脚本保障; 3. 已知限制:单线程写入、索引全内存、bitmap 分配器 O(n)、无压缩 —— 每条注明预计在哪个阶段解决。 文档里出现的每个数字(偏移、大小、阈值)都必须与代码一致,写完请逐项对照源码自检一遍。
小结
- 验收先造 oracle:种子派生负载 + fsync 过的确认清单,把「put 返回即持久」翻译成可执行的判定
- 三道刑各测一类故障:kill -9 测崩溃恢复逻辑,dm-flakey 测 fsync 的诚实度,位翻转测读路径的警觉性
- kill -9 不等于断电 —— page cache 会替你圆谎,drop_writes 才让「确认早于持久」的 bug 现形
- 位翻转验收的判定是反的:verify 通过即漏检失败;报错信息质量也在验收范围内
- 复盘规律:测试拦得住 happy path 的 bug,崩溃一致性 bug 只有红线 review 和破坏性测试能抓
- 里程碑①达成:forge-store 弄不坏了。L2 开始解决下一个问题 —— 它还不够快