来自 #275 的架构级评审建议。不阻塞合入,仅供参考是否有更好的架构解法。
⚠️ [重要 · 错误处理] writeTail 与数据段写之间仍缺落盘级原子性 storage/sstable.go:475
问题根因:修复把错误检查推进到每一次写入,但 writeTail 与之前的数据段写出(WriteToSSTable 的 file.Write、MergeSSTable 的 bw.Flush)之间没有任何原子边界——任何一个尾部写入在中间失败,都会留下一个「数据完整但尾部残缺」的中间态文件,而这个文件在崩溃/失败后被保留在磁盘(WriteToSSTable 的 error 分支不删除已创建的文件,defer file.Close 只关不删)。下次重启 LoadSSTableMetaList 会重新加载它并跌进 EnsureMeta 兜底路径计算出错误的 MaxKey。即:错误被上报了,但被感染的中间态文件仍在磁盘上等待被重新读取。
为什么低级解法不够:在本 diff 上只是把文件删掉相当于在 WriteToSSTable 的 error 处理里补 os.Remove(fullPath)——这确实能堵住本 PR 的场景,但没有质问『哪些失败场景会留下不可读的中间态文件』『SSTable 的容错模型是跳过坏文件还是原子替换』。一个简单的 Remove 既会误删本可恢复的文件,又没覆盖 MergeSSTable 等其它写路径的同类失败场景。它只是又一次逐点打补丁。
架构级方案:把『写完一块 SSTable』当做一个原子提交单元来建模:要么得到一份 footer 完整、可被读路径解析的良文件,要么文件不存在。具体做法是把写尾失败时的清理与文件发布分离——新增一个统一的 writeSSTable 编排层:1) 写入临时文件 tmp;2) 每步错误统一上报并删除 tmp;3) file.Sync() 成功后再原子 rename 到正式路径并发布 meta/缓存。当前代码是『直接在正式路径上边写边用』,没有原子发布点。这同时也解决写尾与数据段各自独立失败时的一致性问题。
代价/收益:代价:需要引入临时文件 + rename 的发布流程,对 flush/compaction 两个调用方各重构一次;临时文件在崩溃时会残留,需要启动清理或复用同名 tmp。收益:从设计上保证『磁盘上的 .sst 要么完整要么不存在』,彻底消除尾巴残缺中间态被重启加载的风险,也省去每个调用方各自处理坏文件的零散逻辑。
storage/sstable.go:475问题根因:修复把错误检查推进到每一次写入,但 writeTail 与之前的数据段写出(WriteToSSTable 的 file.Write、MergeSSTable 的 bw.Flush)之间没有任何原子边界——任何一个尾部写入在中间失败,都会留下一个「数据完整但尾部残缺」的中间态文件,而这个文件在崩溃/失败后被保留在磁盘(WriteToSSTable 的 error 分支不删除已创建的文件,defer file.Close 只关不删)。下次重启 LoadSSTableMetaList 会重新加载它并跌进 EnsureMeta 兜底路径计算出错误的 MaxKey。即:错误被上报了,但被感染的中间态文件仍在磁盘上等待被重新读取。
为什么低级解法不够:在本 diff 上只是把文件删掉相当于在 WriteToSSTable 的 error 处理里补 os.Remove(fullPath)——这确实能堵住本 PR 的场景,但没有质问『哪些失败场景会留下不可读的中间态文件』『SSTable 的容错模型是跳过坏文件还是原子替换』。一个简单的 Remove 既会误删本可恢复的文件,又没覆盖 MergeSSTable 等其它写路径的同类失败场景。它只是又一次逐点打补丁。
架构级方案:把『写完一块 SSTable』当做一个原子提交单元来建模:要么得到一份 footer 完整、可被读路径解析的良文件,要么文件不存在。具体做法是把写尾失败时的清理与文件发布分离——新增一个统一的 writeSSTable 编排层:1) 写入临时文件 tmp;2) 每步错误统一上报并删除 tmp;3) file.Sync() 成功后再原子 rename 到正式路径并发布 meta/缓存。当前代码是『直接在正式路径上边写边用』,没有原子发布点。这同时也解决写尾与数据段各自独立失败时的一致性问题。
代价/收益:代价:需要引入临时文件 + rename 的发布流程,对 flush/compaction 两个调用方各重构一次;临时文件在崩溃时会残留,需要启动清理或复用同名 tmp。收益:从设计上保证『磁盘上的 .sst 要么完整要么不存在』,彻底消除尾巴残缺中间态被重启加载的风险,也省去每个调用方各自处理坏文件的零散逻辑。