Skip to content

fix+test(storage): 故障注入测试,并修掉它们暴露的三处数据丢失缺陷 - #281

Merged
NeverENG merged 3 commits into
mainfrom
test/fault-injection
Aug 13, 2026
Merged

fix+test(storage): 故障注入测试,并修掉它们暴露的三处数据丢失缺陷#281
NeverENG merged 3 commits into
mainfrom
test/fault-injection

Conversation

@NeverENG

Copy link
Copy Markdown
Owner

上一轮那个 SSTable 静默丢数据的缺陷,成因是「正常路径全绿、错误路径无人测」。于是给 WAL 原子替换与 compaction 中途失败补故障注入测试——结果这些测试又抓出三处既有缺陷,全部会导致数据读不到,且都不报错

测试抓出的三处缺陷

① 尾部残缺的 SSTable,整个文件的数据全部读不到

两个缺陷叠加:

a. ReadAllFromSSTable 从错误的偏移开始解析。 readDataEndOffset 为读 footer 已把文件偏移 seek 到末尾附近,而「移回开头」这一步却写在 if dataEnd > 0 的条件里:

dataEnd := ss.readDataEndOffset(file)
if dataEnd > 0 {                       // ← 无 footer 时不会回到开头
    file.Seek(0, io.SeekStart)
}

没有 footer 的文件因此从末尾开始解析,恒返回 0 条——老格式的全量读回退路径实际从未生效

b. 解析失败即让整次读取失败。 任一记录解不出时 return nil, err,而唯一的调用方 readFromSSTableFull 丢弃该错误后遍历 nil 切片。改为就地停止、返回已解出的条目:与 WAL.Replay 对撕裂尾写的处理一致,理由也相同——数据区总是先于尾部写出,无法解析的字节只可能出现在有效数据之后。

c. 连带去掉 MaxKey 的不安全推导。 EnsureMeta 用顺序扫描推 MaxKey,会把数据区之后的块索引/布隆/footer 当记录读出,得到错乱的上界,使范围过滤把命中 key 整段跳过。改为 MaxKeyKnown 语义:仅当 MaxKey 取自块索引或写入时填入才可信,否则不施加上界。多扫一个文件是可接受的代价,漏读不是。

② 启动期 flush 的元信息会被异步扫描抹掉

LoadSSTableMetaList 整体替换 metas,而 NewEngine 以 goroutine 启动它,于是与并发的 AddMeta 相争。生产路径可达:

NewKVServer → NewEngine()(扫描在后台开始)
            → replayWAL()(重放触发 flush → AddMeta 登记新 SSTable)
            → 扫描完成,metas 被整体覆盖 → 刚登记的那条没了

该 SSTable 的数据直到下次重启前都读不到,而出问题的位置恰好是崩溃恢复路径

改为同步——引擎在知道磁盘上有哪些 SSTable 之前不应对外服务。文件级的块索引/布隆预热仍是异步的,那只是缓存预热,与顺序无关。

这个缺陷是被测试「间接」发现的:一个断言「compaction 失败后 L0 仍有 3 个文件」的用例报告了 4 个,追下去发现同一路径在 metas 里出现两次,而磁盘上只有一份。

故障注入测试

WAL.Rewrite — 注入方式是在 .tmp 路径上放一个目录使 OpenFile 必然失败,可移植,不依赖磁盘写满或权限细节。

  • 重写失败后旧 WAL 必须完整可重放(Rewrite 的意义正是把 WAL 换成更小的快照,而快照里的 active+dirty 尚无 SSTable 副本——破坏旧 WAL 即等于丢已 ack 的写)
  • rename 之前崩溃的状态可安全恢复:残留的 .tmp(内容故意残缺)不参与重放
  • 成功路径:只剩新内容、墓碑 op 原样保留(否则被删的 key 会复活)、可继续追加、不残留 .tmp

compaction — 注入方式是把 SSTable 目录改为只读;该手段依赖文件权限,故先探测能否创建文件,root 下直接 t.Skip 而不是给出假绿

  • MergeSSTableCompactSSTable 两层各测一遍:失败时源文件不得删除、数据仍可读
  • 这部分逻辑本就正确,测试的作用是把不变量钉住

两处变异验证

一个从未失败过的测试可能什么都没测,故逐一验证:

  • 注入「合并失败仍删源文件」→ compaction 用例立即失败 ✓
  • 把元信息加载退回 go → 启动契约用例立即失败 ✓

验证

go build ./...(含 -tags pprof)、go vet ./...gofmt 全绿;go test -race ./... 连跑 3 次稳定。改了读写路径故复验崩溃恢复:写入 5 万 key → kill -9 → 重启抽样 516 个,缺失 0

🤖 Generated with Claude Code

NeverENG and others added 3 commits August 13, 2026 16:46
新增故障注入测试(截掉尾部模拟写尾中断)后暴露出两个既存缺陷,两者叠加的后果是:
尾部一残缺,该文件的全部 key 都读成「不存在」,且不报任何错误。

一、ReadAllFromSSTable 从错误的偏移开始解析。readDataEndOffset 为读 footer 已把偏移
seek 到文件末尾附近,而「移回开头」这一步却写在 if dataEnd > 0 的条件里。没有 footer 的
文件因此从末尾开始解析,恒返回 0 条——老格式的全量读回退路径实际从未生效。改为无条件
移回开头。

二、解析失败即让整次读取失败。任一记录解不出时 return nil, err,而唯一的调用方
readFromSSTableFull 丢弃该错误后遍历 nil 切片。改为就地停止、返回已解出的条目:与
WAL.Replay 对撕裂尾写的处理一致,理由也相同——数据区总是先于尾部写出,无法解析的字节
只可能出现在有效数据之后。

三、连带去掉 MaxKey 的不安全推导。EnsureMeta 用顺序扫描推 MaxKey,会把数据区之后的
块索引/布隆/footer 也当记录读出,得到错乱的上界,使范围过滤把命中 key 整段跳过。改为
MaxKeyKnown 语义:仅当 MaxKey 取自块索引或写入时填入才可信,不可信则不施加上界。
多扫一个文件是可接受的代价,漏读不是。EnsureMeta 及其预热 goroutine 一并删除。

compaction 侧无需改动:其迭代器遇到解析失败会置 err,MergeSSTable 据此返回 nil,
CompactSSTable 随即保留源文件——对可疑文件拒绝合并而非静默丢弃,正是期望行为。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
LoadSSTableMetaList 会整体替换 metas,而 NewEngine 以 goroutine 启动它,于是它与并发的
AddMeta 相争。生产路径上这条竞态是可达的:NewKVServer 先构造引擎(扫描随即在后台开始),
紧接着 replayWAL 重放记录,重放触发的 flush 调 AddMeta 登记新 SSTable 的元信息——随后
完成的扫描把 metas 整体覆盖,刚登记的那条被抹掉,该 SSTable 的数据直到下次重启前都读不到。
出问题的位置恰好是崩溃恢复路径。

改为同步:引擎在知道磁盘上有哪些 SSTable 之前不应对外服务。文件级的块索引/布隆预热仍是
异步的——那只是缓存预热,与顺序无关。

新增 TestNewEngineSeesExistingSSTablesImmediately 固定该契约:NewEngine 返回后已有
SSTable 必须立即可读,用例故意不 sleep,一旦加载退回异步即失败(已用变异验证)。
同时更新 reload_recover_test.go 中提到已删除的 EnsureMeta 的注释。

该缺陷是写故障注入测试时顺带发现的:一个测试断言「compaction 失败后 L0 仍有 3 个文件」
却报告 4 个,追下去发现同一路径在 metas 里出现两次——磁盘上只有一份。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
上一轮那个 SSTable 静默丢数据的缺陷,成因是「正常路径全绿、错误路径无人测」。WAL 的原子
替换与 compaction 的中途失败同属「失败即丢数据」的位置,此前也没有任何故障注入测试。

WAL.Rewrite
  - 重写失败后旧 WAL 必须完整可重放:注入方式是在 .tmp 路径上放一个目录,使 OpenFile
    必然失败——可移植,不依赖磁盘写满或权限细节。Rewrite 的意义正是把 WAL 换成一份更小的
    快照,而快照里的 active+dirty 尚无 SSTable 副本,破坏旧 WAL 即等于丢掉已 ack 的写。
  - rename 之前崩溃的状态可安全恢复:残留的 .tmp(内容故意残缺)不参与重放。
  - 成功路径:重写后只剩新内容、墓碑 op 原样保留(否则被删的 key 会复活)、可继续追加、
    不残留 .tmp。

compaction
  - 合并失败时源文件不得删除、数据仍可读。注入方式是把 SSTable 目录改为只读;该手段依赖
    文件权限,故先探测能否创建文件,root 下直接 t.Skip 而不是给出假绿。
  - MergeSSTable 与 CompactSSTable 两个层次各测一遍。结论是这部分逻辑本就正确,测试的
    作用是把不变量钉住——已用变异验证:注入「合并失败仍删源文件」后用例立即失败。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@NeverENG
NeverENG merged commit c253412 into main Aug 13, 2026
3 checks passed
@github-actions

Copy link
Copy Markdown

🐯 BanGD 数据库内核评审

整体风险:🟡 中

变更总结:本 PR 是上一轮「SSTable 静默丢数据缺陷」的深化修复:先给 WAL 原子替换与 compaction 中途失败补故障注入测试,测试又暴露三处既有的数据不可读缺陷,一并修掉。核心改动有两层。(1) 语义层:废除 SSTableMeta.EnsureMeta 顺序扫描推导 MaxKey 的做法(会把数据段之后的块索引/布隆/footer 当记录误读,产生错乱上界、导致范围过滤整段跳过命中 key),改为 MaxKeyKnown 可信标志——仅当 MaxKey 取自块索引(新格式)或写入时填入才可信,否则不施加上界过滤;同时把 ReadAllFromSSTable 的「移回文件开头」从 dataEnd > 0 条件中提出(否则老格式无 footer 时从末尾解析恒返回 0 条),并把「解析失败即整段失败」改为「就地停止、返回已解出条目」,与 WAL.Replay 处理撕裂尾写一致。(2) 时序/并发层:LoadSSTableMetaList 从 goroutine 改为同步,消除它整体替换 metas 与并发的 AddMeta 相争的窗口——此前启动期 WAL 重放触发的 flush 登记元信息可能被随后完成的扫描抹掉,导致崩溃恢复路径上该 SSTable 直到下次重启都读不到。文件级块索引/布隆预热保持异步(仅缓存预热,与顺序无关)。新增三组故障注入测试(WAL.Rewrite 失败/残留 .tmp/成功原子替换、compaction 失败保留源文件、尾部残缺仍可读、引擎启动立即可见既有 SSTable),并做变异验证确保测试真能抓错。

本评审不阻塞合入;架构级建议以 Issue 形式跟踪,普通问题在下方内联列出。

架构问题(共 3 项)

普通问题(共 3 项)

⚠️ [重要 · 逻辑错误] storage/wal_rewrite_fault_test.go:82 Rewrite 失败用例未真正恢复 .tmp 目录

  • TestRewriteFailureKeepsOldWALIntact 用 os.Mkdir(path+".tmp") 占位使 Rewrite 必然失败。case 结束时该目录仍残余在磁盘上。虽然 t.TempDir 会在测试结束自动清理,但下一个直接调用 NewWAL(path) 并 Append 的并发/顺序测试如果复用同一目录(此处没有),以及如果该测试在循环或重跑中被复用路径,残留目录会让后续 Rewrite 也失败,造成测试间隐式耦合。
  • 建议:在断言结束后加 t.Cleanup 或显式 os.Remove(path+".tmp") 移除占位目录,避免残留状态影响同目录复用。

💡 [建议 · 资源管理] storage/compaction_failure_test.go:96 只读目录探测失败后再次 chmod 未判错

  • TestCompactSSTableKeepsSourcesOnMergeFailure 与 TestCompactionFailureKeepsSourceFiles 在做 probe 失败跳过的 cleanup 时 chmod 0o755,但未检查 chmod 错误。若 chmod 失败(权限/文件系统不支持),后续断言会在只读目录上操作并产生混乱的失败。同类还有 os.Remove(dir+"/probe") 的错误未检查。
  • 建议:对 chmod 和 remove 的错误做显式忽略并加注释(或断言为 nil),保持测试可诊断。

💡 [建议 · 逻辑错误] storage/engine.go:102 启动契约用例静默依赖同步完成信号

  • TestNewEngineSeesExistingSSTablesImmediately 依赖 LoadSSTableMetaList 同步完成(无 sleep),正确验证了修好后的契约。但用例的名字与断言在实现退回 goroutine 时是「因竞态失败而能抓到」,这在 race 检测开启时可靠,未开 race 时仍可能偶发通过(恰好扫描快于 Get)。
  • 建议:若追求确定性,可在断言前显式同步(例如让 NewEngine 返回一个元信息 ready 信号)而不是依赖竞态窗口大小。当前质量可接受,作为建议指出确定性偏弱。

本次评审消耗 token:共 236891 tokens(输入 218345,输出 5362,缓存命中 13184,缓存写入 0)|维度 [concurrency, memory, lock, storage, performance]|补充阅读周边文件 [storage/wal.go]|对抗式复核 3 票/条,过滤疑似误报 0 条

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant