fix+test(storage): 故障注入测试,并修掉它们暴露的三处数据丢失缺陷 - #281
Conversation
新增故障注入测试(截掉尾部模拟写尾中断)后暴露出两个既存缺陷,两者叠加的后果是: 尾部一残缺,该文件的全部 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>
🐯 BanGD 数据库内核评审整体风险:🟡 中 变更总结:本 PR 是上一轮「SSTable 静默丢数据缺陷」的深化修复:先给 WAL 原子替换与 compaction 中途失败补故障注入测试,测试又暴露三处既有的数据不可读缺陷,一并修掉。核心改动有两层。(1) 语义层:废除
架构问题(共 3 项)
普通问题(共 3 项)
💡 [建议 · 资源管理]
💡 [建议 · 逻辑错误]
本次评审消耗 token:共 236891 tokens(输入 218345,输出 5362,缓存命中 13184,缓存写入 0)|维度 [concurrency, memory, lock, storage, performance]|补充阅读周边文件 [storage/wal.go]|对抗式复核 3 票/条,过滤疑似误报 0 条 |
上一轮那个 SSTable 静默丢数据的缺陷,成因是「正常路径全绿、错误路径无人测」。于是给 WAL 原子替换与 compaction 中途失败补故障注入测试——结果这些测试又抓出三处既有缺陷,全部会导致数据读不到,且都不报错。
测试抓出的三处缺陷
① 尾部残缺的 SSTable,整个文件的数据全部读不到
两个缺陷叠加:
a.
ReadAllFromSSTable从错误的偏移开始解析。readDataEndOffset为读 footer 已把文件偏移 seek 到末尾附近,而「移回开头」这一步却写在if dataEnd > 0的条件里:没有 footer 的文件因此从末尾开始解析,恒返回 0 条——老格式的全量读回退路径实际从未生效。
b. 解析失败即让整次读取失败。 任一记录解不出时
return nil, err,而唯一的调用方readFromSSTableFull丢弃该错误后遍历 nil 切片。改为就地停止、返回已解出的条目:与WAL.Replay对撕裂尾写的处理一致,理由也相同——数据区总是先于尾部写出,无法解析的字节只可能出现在有效数据之后。c. 连带去掉
MaxKey的不安全推导。EnsureMeta用顺序扫描推 MaxKey,会把数据区之后的块索引/布隆/footer 当记录读出,得到错乱的上界,使范围过滤把命中 key 整段跳过。改为MaxKeyKnown语义:仅当 MaxKey 取自块索引或写入时填入才可信,否则不施加上界。多扫一个文件是可接受的代价,漏读不是。② 启动期 flush 的元信息会被异步扫描抹掉
LoadSSTableMetaList整体替换metas,而NewEngine以 goroutine 启动它,于是与并发的AddMeta相争。生产路径可达:该 SSTable 的数据直到下次重启前都读不到,而出问题的位置恰好是崩溃恢复路径。
改为同步——引擎在知道磁盘上有哪些 SSTable 之前不应对外服务。文件级的块索引/布隆预热仍是异步的,那只是缓存预热,与顺序无关。
故障注入测试
WAL.Rewrite— 注入方式是在.tmp路径上放一个目录使OpenFile必然失败,可移植,不依赖磁盘写满或权限细节。Rewrite的意义正是把 WAL 换成更小的快照,而快照里的 active+dirty 尚无 SSTable 副本——破坏旧 WAL 即等于丢已 ack 的写).tmp(内容故意残缺)不参与重放.tmpcompaction — 注入方式是把 SSTable 目录改为只读;该手段依赖文件权限,故先探测能否创建文件,root 下直接
t.Skip而不是给出假绿。MergeSSTable与CompactSSTable两层各测一遍:失败时源文件不得删除、数据仍可读两处变异验证
一个从未失败过的测试可能什么都没测,故逐一验证:
go→ 启动契约用例立即失败 ✓验证
go build ./...(含-tags pprof)、go vet ./...、gofmt全绿;go test -race ./...连跑 3 次稳定。改了读写路径故复验崩溃恢复:写入 5 万 key →kill -9→ 重启抽样 516 个,缺失 0。🤖 Generated with Claude Code