里程碑①:kill -9 验收
给 forge-store 上酷刑:随机写入中 kill -9、写入中拔盘(dm-flakey)、注入位翻转。全部扛住才算过关 —— 验收标准就是这节课的脚本。
学完这节你能做到
- 跑通崩溃注入测试脚本并解读结果
- 用 AI 分析一次真实的数据损坏 case
- 写出本阶段的设计文档:格式、不变量、已知限制
验收的规矩:先造测谎仪,再上刑具
四课的产出到了交卷时刻。里程碑①的验收标准写在 curriculum 里:kill -9 万次不丢不坏、 断电模拟扛得住、位翻转必须被抓住。这和你以前做存储 POC 验收是同一件事, 只是这次被验的系统挂着你的名字 —— 规格是你定的,验收标准是你写的。 心态会很不一样,你会突然理解厂商工程师的表情。
破坏之前先解决一个方法论问题:怎么知道数据「不丢不坏」? 光靠引擎自己说了不算, 需要一个独立的「预言机」(oracle):写入负载完全由随机种子决定,任何 key 的正确值 都能从种子重新推算;每个已确认(写入拿到了成功回执)的 key 追加记进一个刷过盘的 清单文件。验证器拿清单对引擎逐个盘问:
- 清单里的 key,读回来必须存在,且值与种子推算的逐字节一致 —— 否则丢数据,FAIL
- 任何一次读取都不许让进程崩掉、也不许返回一个错的值 —— 否则坏数据,FAIL
- 不在清单里的 key 允许存在(崩溃时已落盘但没来得及确认),这不是错
这正是 L1 冻结的语义承诺的可执行化:「put 返回即持久」逐字翻译成脚本。
测谎仪自己不能说谎。别去逐行读它的代码 —— 让 AI 把「一次写入从调用到记进清单」的步骤按顺序编号讲一遍, 你挨个问「崩在这一步之后,盘上和清单分别是什么」。你要能确认清单的刷盘发生在下一次操作之前 —— 顺序错了,清单记的就不是「已确认」而是「大概写过」,后面三道刑全部失去意义。 再确认 verify 对「引擎多出来的未确认 key」不误报,否则万次 kill 里必然刷出一堆假阳性。 最后一定要亲手往引擎里塞一个已知 bug 看它抓不抓得到 —— 这条比任何解释都硬。
目标:给 forge-cli 加一组 torture 子命令,作为 forge-store 的验收工具。它是我判定「不丢不坏」的唯一依据,所以它自己必须先值得信 —— 一个会说谎的测谎仪比没有测谎仪更糟。 行为规格: 1. forge-cli torture fill --dir DIR --seed N --ops M:打开(没有就创建)DIR 下的引擎,用 seed 派生的确定性伪随机流跑 M 次操作,比例是 80% 新写、15% 覆盖写、5% 删除;key 由 seed 与序号派生,value 由 key 派生、长度落在 128 字节到 64 KiB 之间。同一个 seed 加同一个 ops 必须每次生成完全相同的操作序列 —— 崩溃现场要能复现; 2. 每次操作拿到成功回执之后,把这一条(操作类型、key、序号)追加写进 DIR/acked.log 并刷到盘上,刷完才允许执行下一次操作。这个顺序不许反:先落清单再往下走,否则清单记的就不是「已确认」而是「大概写过」; 3. fill 进程随时可能被 kill -9,这是预期用法不是异常。无论杀在哪个瞬间,acked.log 都只能是「若干条完整记录,末尾最多一条残缺」这一种形态; 4. acked.log 沿用 WAL 那套长度前缀加校验和的记录格式,尾部半条按 WAL 的规则截断,复用 forge-store 已有的实现,不要另写一份; 5. forge-cli torture verify --dir DIR:读 acked.log 重放出「每个 key 的最终应有状态」,再逐个盘问引擎 —— 应该存在的 key 必须读到、且值与种子推算的逐字节一致;应该已删的 key 必须明确回答不存在。任何一处不符,打印出 key、期望、实际、以及它落在哪个文件的哪个偏移,然后以非零退出码结束;全部通过则退出码 0 并打印统计; 6. 清单里没有的 key 允许在引擎里存在(崩溃时已落盘但还没来得及确认),这不算错,verify 绝不能因此报警 —— 万次 kill 里这种情况会大量出现,误报会把真 bug 淹掉; 7. forge-cli torture corrupt --dir DIR --flip FILE:OFFSET:把指定文件 OFFSET 处的一个字节按位取反,同一条命令再跑一次能原样翻回来,用于位翻转验收; 8. 任何预期内的失败(目录不存在、清单损坏、引擎打不开)都要变成清晰的报错加非零退出码,既不许让进程直接崩掉,也不许把错误咽下去当成通过。 交付:实现 + 覆盖上述每条的自动化测试 + 一段说明:verify 分别在哪里判定「丢数据」和「坏数据」,以及它有哪几类问题查不出来。(命令行怎么解析、随机流怎么派生、清单怎么复用 WAL 的代码你定,最后告诉我为什么这么选。)
- 同一个 seed 与 ops 往两个空目录各跑一遍 fill,两次 verify 都通过,并且两个 acked.log 逐字节相同 —— 不能复现的负载,出了问题也追不下去
- 让 AI 临时把 acked.log 那次刷盘去掉,跑一轮 kill -9 加 verify:你应该能看到清单少了记录、verify 却照样报通过。改回来再确认恢复正常 —— 亲眼看见这一步在挡什么
- 让 AI 故意往引擎的写路径塞一个已知 bug(比如每一千次写偷偷丢掉一次),确认 verify 抓得到并打印出具体是哪个 key —— 抓不到的验收工具是摆设,后面三道刑全是白跑
- 手工造一个「引擎里有、清单里没有」的 key,确认 verify 不报警;再挑一个清单里的 key 用 corrupt 翻掉它一个字节,确认 verify 立刻报警。两个方向都试过,才知道它既不误报也不漏报
- 用 strace -f -e trace=write,fdatasync,fsync 跟一小段 fill:每次操作后面都应该真的跟着一次对 acked.log 的写加刷盘,而不是攒在缓存里一起走
- 如果清单那次刷盘挪到下一次操作之后,机器断电时我会看到什么现象?
- verify 报「丢数据」的时候,怎么区分是引擎真丢了,还是清单自己记错了?
- 这套工具查不出哪几类问题?有哪些坏法它会照样打印 PASS?
- 清单文件自己的尾巴坏了,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 才能抓 —— 恰好落在三条红线里, 这就是红线为什么存在的实证。最后把阶段成果固化成设计文档,格式就用你写惯的运维文档三段式: 是什么(格式与不变量)、怎么运维(工具与验收)、边界在哪(已知限制)。
任务一练的是你未来的日常:分布式阶段的故障现场比这复杂十倍,现在用单机现场练手感。 关键是顺序 —— 先看盘,再看代码。让 AI 先把盘上事实摆齐(哪些文件在、多大、头尾长什么样), 再让它推演恢复过程;反过来先讲代码,你会被它的叙述牵着走,看不见盘上真实发生了什么。 review 任务二时拿出你审运维文档的标准:每条不变量是否真有测试背书? 去把那条断言改坏、看它红不红,这一步比通读文档有用得多。 「已知限制」有没有避重就轻?诚实的限制清单比漂亮的功能清单值钱。
目标:两件事 —— 先陪我把一个损坏现场验尸到底,再把 L1 的成果固化成一份不吹牛的设计文档。 行为规格(验尸): 1. 现场:我保留了一个验收失败的目录;如果我没有,请你先人为造一个 —— 让进程死在 GC 把新文件改名之后、删掉旧文件之前,跑出一个真实的崩溃现场; 2. 先看盘,不看代码:用 xxd 检查现场目录里每个文件的头部与尾部,列一张表说明每个文件当前是什么状态 —— 完整、被截断、还是留着没清掉的中间文件; 3. 再推演恢复:按引擎实际的启动恢复规则,一步一步说明重启后内存索引会被重建成什么样、哪些 key 受影响,以及每个受影响的 key 最终会读到新值、旧值,还是不存在; 4. 给结论:这是 bug 还是设计内的安全状态。是安全状态,就说清它靠哪条不变量兜住;是 bug,就给出最小修复,并告诉我补哪一条自动化测试能让它以后再犯就立刻变红。 行为规格(设计文档): 5. 写 docs/forge-store-v1.md,只写事实不写愿景,三部分 —— 盘上格式:superblock 布局、数据记录格式、hint 格式,每个字段的字节偏移、长度、含义、字节序;不变量清单:确认过的写入必在、损坏必报错、GC 的步骤顺序、恢复截断规则,每条注明由哪个测试或哪个验收脚本保障,找不到保障的直接写「无保障」;已知限制:写入是单线程、索引全在内存、分配器是线性扫描、没有压缩,每条注明它是有意取舍还是待办,以及预计哪个阶段解决; 6. 文档里出现的每个数字(偏移、长度、阈值)都必须和代码一致。写完请逐项回去对一遍源码,并把对照结果告诉我; 7. 不许出现「应该」「大概」「计划支持」这类词。不确定的事就写「未验证」。 交付:一张现场文件状态表 + 一份从盘上事实推到结论的验尸报告 + docs/forge-store-v1.md + 一份「文档里每个数字都对照过源码」的自查记录。
- 拿文档里的字节偏移表去核盘:用 xxd 看现场里一个真实文件的头部,逐字段对上。有一处对不上,这份文档就是废纸
- 挑三条不变量,让 AI 指出是哪个测试或哪个脚本保障它,然后你去把那条断言故意改坏,确认它真的会红 —— 说有保障却红不了,等于没保障
- 按验尸结论里的最小修复改完之后,把制造这个现场的手法再跑 20 遍,确认每次恢复出的结果都一致、verify 都通过
- 在现场目录上重启一次引擎,用 stat 和 ls 看那个中间文件与旧文件最后是被清掉了还是留着,和 AI 的推演逐条对照 —— 推演对不上现实,说明它在讲故事
- 把「已知限制」那一节逐条念一遍,每条问自己「这会在什么场景下咬到用户」;答不出来的说明写得太抽象,退回重写
- 现场里如果新文件已经改名、旧文件还在,重启后同一个 key 我会读到哪一份?为什么不会读成两份?
- GC 的步骤如果调换其中两步,分别会在崩溃时留下什么后果?崩在哪一步最难查?
- 不变量清单里哪一条目前没有任何测试或脚本保障?打算怎么办 —— 补测试,还是把它降级成「未验证」?
小结
- 验收先造 oracle:种子派生负载 + fsync 过的确认清单,把「put 返回即持久」翻译成可执行的判定
- 三道刑各测一类故障:kill -9 测崩溃恢复逻辑,dm-flakey 测 fsync 的诚实度,位翻转测读路径的警觉性
- kill -9 不等于断电 —— page cache 会替你圆谎,drop_writes 才让「确认早于持久」的 bug 现形
- 位翻转验收的判定是反的:verify 通过即漏检失败;报错信息质量也在验收范围内
- 复盘规律:测试拦得住 happy path 的 bug,崩溃一致性 bug 只有红线 review 和破坏性测试能抓
- 里程碑①达成:forge-store 弄不坏了。L2 开始解决下一个问题 —— 它还不够快