fix(storage): SSTable 写尾吞掉 I/O 错误——静默丢数据 - #275
Conversation
块索引与 Footer 的写入此前丢弃了全部返回值(binary.Write / file.Write 共 8 处), 定位偏移的 file.Seek 也用 _ 忽略错误。后果不是「少写几个字节」,而是静默的数据丢失: - 尾部残缺 → footer magic 校验失败 → loadBlockIndexFromFile 返回 nil → 重启时 EnsureMeta 在新格式文件上算出错误的 MaxKey(该行为已由既有注释记载), [MinKey,MaxKey] 过滤随即跳过整个文件,数据读不到; - 而调用方以为落盘成功:Flush 会把 dirty 置 nil,丢弃内存中的唯一副本; CompactSSTable 会删除全部源文件。两条路径都以「成功」为前提销毁另一份副本。 - Seek 失败时 indexStart 为 0,Footer 会声称块索引位于数据区起点。 WriteToSSTable 与 MergeSSTable 的写尾逻辑本就完全相同(原注释即写明「以下写尾与 WriteToSSTable 完全一致」),故合并为单一 writeTail:逐段检查错误并包裹出失败段落, 读路径只有一份解析实现,写路径也只留一份。两处局部的 blk 类型提为包级 blockMeta。 另两类未检查的写经核对是安全的,补注释固定该不变量而非加噪声:数据段先写 bytes.Buffer (其 Write 按文档永不返回错误);MergeSSTable 的数据循环写 bufio.Writer(错误具粘性, 首个错误会在被检查的 bw.Flush() 处浮现)。 新增 sstable_tail_test.go:在尾部每一个字节位置注入写失败,断言 writeTail 必须返回 包裹了底层错误、且标明失败段落(block index / bloom / footer)的错误;另有一例校验 写出的字节仍被读路径的 Footer 解析接受,确保两个调用方共用实现后布局未漂移。 已复验崩溃恢复:写入 5 万 key → kill -9 → 重启抽样 516 个 key,缺失 0。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
🐯 BanGD 数据库内核评审整体风险:🟢 低 变更总结:这是一个针对 SSTable 写入尾部(块索引 + 布隆段 + Footer)I/O 错误被静默吞掉的缺陷修复。改动把 WriteToSSTable 与 MergeSSTable 两处逻辑完全相同的写尾代码合并为单一 writeTail 函数(读路径只有一份解析实现,写路径同样只保留一份以保证字节布局不漂移),并对每段写入检查错误、包裹出失败段落和底层错误。同时还把两处局部的 blk 类型提为包级 blockMeta,并把 writeBloomSection 的参数从 *os.File 放宽为 io.Writer 以复用于内存 buffer 测试。另对两处「经核对安全」的未检查写(bytes.Buffer 数据段、bufio.Writer 的粘性错误)以注释写明其不变量。新增 sstable_tail_test.go 逐字节注入写失败断言 writeTail 必需返回带段落信息的内部错误。 动机根因:尾部残缺会让 footer magic 校验失败,读路径 loadBlockIndexFromFile 返回 nil 后由 EnsureMeta 顺序扫描兜底——但它在含索引/布隆/Footer 的新格式文件上会把非数据字节当记录读,算出错误的 MaxKey,[MinKey,MaxKey] 范围过滤随即跳过整个文件造成数据读不到;而调用方(Flush / CompactSSTable)以「写成功」为前提销毁唯一副本,故吞错等于静默丢数据。
架构问题(共 2 项)
普通问题(共 2 项)💡 [建议 · 逻辑正确性]
💡 [建议 · 逻辑正确性]
本次评审消耗 token:共 101271 tokens(输入 86211,输出 4180,缓存命中 10880,缓存写入 0)|维度 [concurrency, memory, lock, storage, performance]|补充阅读周边文件 [storage/bloom.go, storage/iterator.go]|对抗式复核 3 票/条,过滤疑似误报 0 条 |
巡检时发现的数据丢失缺陷,不是风格问题。
缺陷
块索引与 Footer 的写入丢弃了全部返回值(
binary.Write/file.Write共 8 处),定位偏移的file.Seek也用_忽略错误:后果不是「少写几个字节」,是静默丢数据
读侧:尾部残缺 → footer magic 校验失败 →
loadBlockIndexFromFile返回 nil → 重启时LoadSSTableMetaList保留MaxKeyLoaded=false,由EnsureMeta顺序扫描兜底——而它在新格式文件上会把索引/布隆字节当记录读,算出错误的 MaxKey(这一行为本就记在代码注释里)。[MinKey,MaxKey]过滤随即跳过整个文件,数据读不到。写侧:调用方以为落盘成功,于是销毁另一份副本——
Flushm.dirty = nil,丢弃内存中的唯一副本CompactSSTableDeleteSSTable(meta)删除全部源文件两条路径都以「写成功」为前提销毁副本。也就是说,吞掉这里的错误 = 静默丢数据。
Seek失败还会让indexStart为 0,Footer 会声称块索引位于数据区起点。修法
WriteToSSTable与MergeSSTable的写尾逻辑本就完全相同(原注释即写着「以下写尾与 WriteToSSTable 完全一致,保证字节布局相同」),故合并为单一writeTail:逐段检查并包裹出失败段落。读路径只有一份解析实现,写路径也应只留一份——否则布局迟早漂移。两处局部的blk类型提为包级blockMeta。另两类未检查的写,经核对是安全的
不盲目加检查,而是把不变量写进注释:
bytes.Buffer—— 其Write按文档永不返回错误(容量不足直接 panic)。MergeSSTable的数据循环写bufio.Writer—— 错误具粘性,首个错误会在已被检查的bw.Flush()处浮现。测试
sstable_tail_test.go在尾部每一个字节位置注入写失败,断言writeTail必须返回包裹了底层错误、且标明失败段落(block index / bloom / footer)的错误。另一例校验写出的字节仍被读路径的 Footer 解析接受,确保两个调用方共用实现后布局未漂移。验证
go build ./...(含-tags pprof)、go vet ./...、go test -race ./...、gofmt全绿——其中reload_recover/recency/merge/sstable_bloom都读真实文件,可作磁盘格式回归。改了写路径故复验崩溃恢复:写入 5 万 key →
kill -9→ 重启抽样 516 个 key,缺失 0。🤖 Generated with Claude Code