Files
go-kv/docs/audit-3.2.md
T
dailz 98fbac07f2 fix: stop WAL recovery from advancing MANIFEST or using CURRENT (C2+C3)
Phase 1 default state had two data-loss paths in WAL recovery.

C2: resolveRecoverySegmentID fell back to CURRENT when MANIFEST=0.
    Since segment_manager writes CURRENT on every segment create/rotate,
    the first recovery in Phase 1 (MANIFEST always 0 without flush) would
    start from the active segment, skipping earlier unflushed segments.

C3: Recover called manifest.Save after every recovery, advancing
    recoverySegmentID past segments that were still the only durable copy
    of their data (no SSTable flush yet). Next restart would filter those
    segments out and permanently lose the data.

Per design §3.2 line 280, recovery must not update MANIFEST; per line
604-06, CURRENT must not be used as recovery start. Both fixes are
required together — fixing C3 alone leaves C2's data-loss window open.

Changes:
- wal/recover.go: remove manifest.Save calls on both success and
  tail-repair paths; remove CURRENT fallback in resolveRecoverySegmentID.
  RecoveryResult.NextSegmentID is now in-memory only (consumed by DB.Open
  to seed the new WalWriter, but never persisted to MANIFEST).
- wal/recover_test.go: rewrite TestRecoverUpdatesManifest as
  TestRecoverDoesNotUpdateManifest; add TestRecoverPreservesExistingManifest,
  TestRecoverIdempotentClean, TestRecoverIdempotentAfterTruncation,
  TestRecoverIgnoresCurrentFallback.
- db_test.go: add TestOpenThreeTimesKeepsData (three opens to catch C3's
  second-restart data loss; single-segment to avoid unrelated C8 bug
  where segment_manager passes byte offset as startSequence).

Verified: each new test fails on pre-fix code and passes after the fix.
Full suite green including -race.

Audit context: docs/audit-3.2.md (with Oracle revisions from bg_ef425776
and bg_2e86d33b; C8 added). Plan: .omo/plans/fix-c2-c3-wal-recovery.md
(Momus + Oracle reviewed v1.2).
2026-06-15 13:40:13 +08:00

24 KiB
Raw Blame History

WAL 代码审核报告(对照 docs/design.md § 3.2

审核范围:/wal/.../memtable/.../manifest/.../config/...db.go。 审核时间:基于 commit e34de4afix: implement group commit collection window)。

下面按严重程度排列,每条给出 设计要求 → 代码现状 → 后果 → 修复建议


🔴 Critical(破坏持久化 / 恢复正确性,必须修)

C1. CRC 多项式错误:用 IEEE 而非 crc32c

  • 设计要求(§3.2 Physical Record、WAL File Header):crc32cCastagnoli0x82F63B78),用于识别 torn write / partial write。
  • 代码现状
    • wal/record.go:30,55crc32.ChecksumIEEE
    • wal/header.go:44,83crc32.ChecksumIEEE
  • 后果:CRC 校验值与设计文档不一致;如果未来要做跨实现兼容(其他客户端 / 工具按 crc32c 校验),所有 segment 都会被判损坏。当前自洽但与规范脱钩。
  • 修复:改用 hash/crc32.Castagnoli(即 crc32.MakeTable(crc32.Castagnoli)),所有 ChecksumIEEE 替换为 Checksum(data, castagnoliTable)。常数需要加测试固定。

C2. Recovery 用 CURRENT 作为兜底,违反"MANIFEST 是唯一权威"

  • 设计要求(§3.2 CURRENT / MANIFEST 权威性,line 600-66):

    MANIFEST 是 recovery 起点和 checkpoint 状态的权威源……CURRENT 只表示写入侧上次尝试记录的 active WAL segment hint……不能作为 recovery 起点、终点或排除 segment 的依据

  • 代码现状wal/recover.go:109-124
    if mf.RecoverySegmentID > 0 { return mf.RecoverySegmentID, nil }
    if segID, ok := manifest.ReadCurrent(dir); ok { return segID, nil }  // ← 违规
    return 0, nil
    
  • 后果CURRENT 是 best-effort 写入、可能落后 / 指向已被截断或未 durable-ready 的 segment。用它做 recovery 起点,要么漏恢复(如果它指向比 MANIFEST 更新的 segment,而那个 segment 实际上没 durable-ready),要么恢复出空集(如果它指向已被 MANIFEST 覆盖删除的旧 segment)。这条路径在 MANIFEST.RecoverySegmentID==0 时(首次创建 DB 或刚 flush 后尚未写过 MANIFEST)会被触发。
  • 修复:删掉 CURRENT 兜底,MANIFEST.RecoverySegmentID==0 时直接返回 0CURRENT 仅在写入侧作为 hint 顺手写。

Oracle 修订(bg_ef425776C2 在 Phase 1 比 C3 更严重。Phase 1 没有 flushMANIFEST 一直保持 0segment_manager.go:47,90 每次创建/轮转 segment 都会写 CURRENT 指向 active segment。默认状态下首次 Recover 就会触发 fallback,不需要"CURRENT 缺失/落后"这种特殊场景。修 C3 之前必须先修 C2,否则数据丢失窗口依然存在。


C3. Recovery 主动写 MANIFEST,违反"recovery repair 不更新 MANIFEST"

  • 设计要求(§3.2 line 280):

    MANIFEST 只作为 checkpoint / recovery 起点元数据;recovery repair 不更新 MANIFEST,也不通过 MANIFEST 记录恢复终点。

  • 代码现状wal/recover.go:77,100
    if saveErr := manifest.Save(dir, result.NextSegmentID); saveErr != nil { ... }
    
    无论恢复成功还是尾部截断,都会把 MANIFEST.RecoverySegmentID 改写为"最后一个 segment + 1"。
  • 后果直接数据丢失。设想 segment-5 是 active,写了若干 batch 后崩溃,恢复时只重放了部分 batch 并把尾部截断。代码把 MANIFEST 推进到 segment-6。下次启动时 recovery 直接从 segment-6 开始,跳过了 segment-5 中已经 durable 的 batch。设计明确要求 MANIFEST 只能由 checkpointMemTable flush 完成 + SSTable 元数据落盘)推进。
  • 修复:删掉 recovery 中的 manifest.Save。MANIFEST 推进只能发生在 flush 完成后(未来 §3.3 实现)。

Oracle 修订(bg_ef425776:单修 C3 不足以解决 Phase 1 数据丢失,必须和 C2 一起修。测试方向需要区分干净 WAL 和尾部损坏 WAL 两种幂等性。


C4. 非尾段 fragment 状态被当成尾段损坏

  • 设计要求(§3.2 Fragment 状态机 line 731):

    Segment 边界不是合法的 fragment 边界……如果扫描到 WAL 尾部时仍处于 CollectingFragments 状态……丢弃该 incomplete batch。设计同时明确:CollectingFragments + 不是最后恢复 segment → 视为 WAL 中间损坏,报错

  • 代码现状wal/recovery.go:133-138ReplaySegmentFile 末尾,只要处于 FragmentCollecting 就返回 TailCorruptionError不区分当前 segment 是否是最后一个RecoverFromSegments 也直接透传。
  • 后果:如果 segment-5 中间出现 First + Middle* 但没有 Last(中间损坏),但 segment-6 还存在,代码会把 segment-5 的中间损坏当尾部截断处理,截掉 segment-6 的有效数据。设计要求此时硬报错。
  • 修复ReplaySegmentFile 需要接收 isLast bool 参数,或者在 RecoverFromSegments 的 segment 循环里检查:非最后段返回 CollectingFragments 必须返回普通 corruption 错误,不是 TailCorruptionError

Oracle 修订(bg_ef425776C4 还有一个更严重的失败模式。wal/recover.go:61 总是对 segments[len(segments)-1] 调用 truncateSegment,但 RecoverFromSegmentsTailCorruptionError 可能来自非尾段。结果:真正损坏的 segment 不动,最后一段的有效数据被错误截掉。


C5. 尾部截断不 fsync、不删空 segment、不 fsync 目录,且失败被吞

  • 设计要求(§3.2 尾部截断持久化 line 786-800):
    1. ftruncate 当前 active segment 到 lastCompleteBatchEnd
    2. fsync 被截断的 segment
    3. 删除 startSequence == expectedSequence 且不含任何 complete batch 的后续空 segment
    4. fsync WAL directory 全部成功后 recovery 才能进入恢复完成状态。若任一步失败,recovery 必须报错,DB 不得进入可写状态
  • 代码现状wal/recover.go:60-69
    validOffset, truncErr := findValidOffset(lastSeg.FilePath)
    if truncErr != nil {
        result.TruncateError = fmt.Errorf("%w (find valid offset: %v)", err, truncErr)  // 吞掉
    } else if truncErr := truncateSegment(lastSeg.FilePath, validOffset); truncErr != nil {
        result.TruncateError = fmt.Errorf("%w (truncate: %v)", err, truncErr)          // 吞掉
    }
    
    truncateSegment 只是 os.Truncate,没有 fsync 文件、没有删后续空 segment、没有 fsync 目录,失败也只是写进 result.TruncateError 然后正常返回成功
  • 后果:崩溃恢复后 WAL 尾部可能再次暴露已被"截断"的脏字节,下次启动会重复 repair 或 repair 出不同边界;DB 已经接受新写入,破坏了"恢复后的 WAL 状态即权威"这一不变量。
  • 修复:把 truncation 拆成独立函数,按设计 4 步串行执行,任何一步失败 return errDB.Open 必须失败。

C6. SegmentWriter 创建时目录 fsync 失败被静默忽略

  • 设计要求(§3.2 WAL 元数据持久化协议 line 248-273):durable-ready 协议第 5 步 fsync WAL directory 是 segment 进入 durable-ready 的硬条件。目录 fsync 失败时 segment 不得承载可确认写入;若已有 Batch 依赖该 segment,进入 write-stopped。
  • 代码现状wal/segment_writer.go:86-89
    if dirFD, derr := os.Open(dir); derr == nil {
        dirFD.Sync()   // 错误被忽略
        dirFD.Close()
    }
    
  • 后果:rename 已发生但目录元数据未落盘。掉电后恢复可能看不到这个 segment 文件,但 WAL writer 已经向调用方确认了该 Batch 成功("不丢已确认写入"被破坏)。这是 §3.2 Always 策略下最严重的 durability 漏洞之一。
  • 修复:目录 fsync 错误必须返回,触发 write-stopped;同时显式管理 durable-ready 状态机(新字段 durableReady bool),未就绪时禁止 AppendBatch。

C7. Put/Delete vs Close 存在 send-on-closed-channel 竞态

  • 代码现状wal/writer.go:314-326Put)先检查 writeStopped,再 queue.Submit;而 ClosewriteStopped.Store(true)queue.Close()。两者之间没有同步。
  • 后果:并发调用 Put 和 Close 时,Put 通过 writeStopped 检查后、Submit 之前,Close 把 channel 关掉,Put 的 cq.ch <- req 触发 panic。这不是 §3.2 设计直接约束,但破坏写入路径稳定性。
  • 修复Close 用 RWMutex 保护,Submit 用 RLock 检查 closed 标志;或者把 close 时机延后到所有 in-flight submit 完成。

C8. Segment 轮转时把字节偏移当 sequence number 传给新 segmentOracle bg_2e86d33b 发现)

  • 设计要求(§3.2 WAL File Header、Segment 连续性校验 line 639-663):每个 segment 的 startSequence 必须严格衔接前一 segment 最后一个 batch 后的 sequencerecovery 时 segment.StartSequence != expectedSequence 必须报错。
  • 代码现状wal/segment_manager.go:64-66
    if sm.active.RemainingPayload() < worstCaseSize {
        if err := sm.rotate(sm.active.CurrentOffset()); err != nil { ... }
    }
    
    rotate(newStartSequence uint64) 的参数名是 newStartSequence,但传入的 sm.active.CurrentOffset() 返回的是字节偏移(从 WalFileHeaderSize=32 累加),不是 sequence number。
  • 后果:发生 segment 轮转后,新 segment 的 header 里 startSequence = 上一 segment 的字节偏移。Recovery 时 wal/recovery.go:155-157
    if segment.StartSequence != nextSequence { error }
    
    立刻失败。Phase 1 多 segment 场景的 recovery 实际上是坏的
  • 修复SegmentManager 需要跟踪当前 nextSequence(或从 WalWriter 拿),rotate 时把它传下去,而不是 CurrentOffset()
  • 对 C2+C3 patch 的影响:相关 e2e 测试(TestOpenThreeTimesKeepsData)必须单 segment,不能强制轮转,否则会撞上 C8 而不是 C2+C3。

🟠 High(破坏格式约束或重要不变量)

H1. Physical Record 不校验 length > 0

  • 设计要求(§3.2 Block 边界处理、Physical Record 解析规则 line 389, 681):length 必须 > 0
  • 代码现状wal/record.go:38-67 DecodePhysicalRecord 只校验 length <= len(data)-headerSize,没校验下界。wal/record_parser.go:69 也没校验。
  • 后果:攻击者 / 损坏数据可注入 length=0 的 record,绕过 CRCpayload 为空时 CRC 只覆盖 length+type),恢复出空 batch 进而触发 entry count is zero 之类的错误,被误判为"尾部损坏可截断",实际上属于中间损坏。
  • 修复DecodePhysicalRecordif length == 0 { return ErrZeroLength },并在 parser 把它当中间损坏(非尾部不截断)。

H2. Physical Record 不校验 type != Invalid (0)

  • 设计要求(§3.2 fragment 类型表 line 366-372):type=0 是 invalid,用于损坏检测。
  • 代码现状wal/record.go 解析时不校验 typeFragmentCollector.Append 的 default 分支会拒绝,但 ParseBlock 收集 records 时不会。
  • 后果:损坏的 type=0 record 会被加入 records 列表,传给 fragment collector 后才报错;这把"物理层损坏"推迟到"batch 层错误",错误分类可能错(按设计应该硬错,但实际可能被当 CRC 失败归为尾部损坏)。
  • 修复DecodePhysicalRecord 校验 recType ∈ {1,2,3,4},否则返回错误。

H3. worstCaseSize 估算不足,segment 可能写超 MaxSegmentSize

  • 设计要求(§3.2 Segment Rotation 约束、config 不变量 line 392-451):判断是否需要 rotate 时必须基于"真实最坏情况"开销:numRecords * prHeaderSize + worstCaseBlockPadding,对默认配置最大 batch 是 903 + 7 = 910 bytes 开销。
  • 代码现状wal/segment_manager.go:62
    worstCaseSize := uint64(len(encodedBatch)) + uint64(PhysicalRecordHeaderSize) + uint64(PhysicalRecordHeaderSize)
    
    只加 14 bytes2 × 7)。对接近 4MB 的大 batch,少估了约 896 bytes。
  • 后果:当 active segment 剩余 payload 在 [encodedBatchSize + 14, encodedBatchSize + 910] 区间时,代码认为放得下,实际写出后超过 MaxSegmentSize。ValidateBatchLimits 只校验"空 segment 能放下",没校验"当前剩余能放下"。下一批次才会触发 rotate,期间 segment 文件实际大小超出配置上限。
  • 修复:把 ValidateBatchLimits 中已经算过的 numRecords/overhead/padding 计算抽成公共函数,segment_manager 用同样的公式判断 remaining。或者更稳:rotate 阈值改为 if active.RemainingPayload() < minFreshSegmentPayload { rotate },即只要剩余不足以容纳最大合法 batch 就 rotate。

H4. MaxImmutableCount 配置存在但从不强制

  • 设计要求(§3.2 写入流程 step ⑤ line 119):

    若 Immutable MemTable 队列已达上限,该 Batch 必须在 WAL write 之前等待后台 flush 释放容量。

  • 代码现状wal/writer.go:244-265 reserveMemTable 只在 active 满时 rotate完全不检查 immutable 队列长度MemTableList.rotateActive 无脑 append。
  • 后果:写入速度快于 flush 时,immutable 队列无限增长,OOM;同时也违反了"WAL write 前等待 flush"的设计约束。
  • 修复reserveMemTable 在 rotate 前检查 len(immutable) >= MaxImmutableCount,是则阻塞等待条件变量(需要 flush goroutine 来唤醒)。Phase 1 没有 flush,至少应该在超限时返回 ErrWriteStopped 或阻塞,而不是无脑 append。

H5. Per-batch (segmentID, endOffset, endSequence) 跟踪缺失,durableSequence 推进模型错误

  • 设计要求(§3.2 line 205-245):每个完整 Batch 必须记录 (segmentID, endOffset, endSequence);后台 fsync worker snapshot (segmentID, endOffset, endSequence);fsync 成功后只能推进到满足"segment 已 durable-ready + offset 覆盖 + endSequence 连续"的最大 batch。跨 segment 推进还需要目标 segment 的 header + rename + 目录都已 fsync。
  • 代码现状wal/sequence.go:68-78 MarkDurable 只是 CAS 把 durableSequence 推到 seqwal/writer.go:228 每次 processBatch fsync 后立刻调用 MarkDurable(lastSequence)
  • 后果:当前 Phase 1 只有 Always 模式 + 单 writer 串行 fsync功能上恰好正确(每次 fsync 覆盖且只覆盖当前 batchdurableSequence == publishedSequence)。但一旦未来引入 Periodic / 异步 fsync / 多 batch 合并 fsync,模型立刻崩溃。设计明确要求"durableSequence 不能只根据'最近一次 fsync 成功'模糊推进"。
  • 修复:哪怕 Phase 1,也要按设计记录每 batch 的 (segmentID, endOffset, endSequence),并实现 fsyncSnapshot 比较逻辑。否则是对未来扩展的债务性违约。

H6. MemTable publish 机制不符合 release/acquire 模型

  • 设计要求(§3.2 step ⑧-⑩ line 121):

    MemTable skiplist / arena 节点必须先通过原子发布机制写入读路径可见结构(例如 atomic.Pointer store-release,或等价的 release publish),并且 Batch 内所有 entry 的节点都完成发布后,才能用 atomic.Uint64.Store 推进 publishedSequence。普通读者必须先 Load 当前 publishedSequence,再遍历 MemTable;读到 entry 后仍以 entry.sequence <= loadedPublishedSequence 判断可见性。

  • 代码现状memtable/memtable.go:87-127 Publish

    1. skiplist.mu 下遍历收集 pending 节点;
    2. 释放锁后,对每个 entry 调用 skiplist.Put(..., pending=false) 创建新节点替换旧节点;
    3. mt.published.Store(upToSequence)

    读路径 skiplist.Getnode.pending 判断可见性,完全不读 publishedSequence

  • 后果

    1. 读路径看到的可见性边界是"pending 标志位",不是 publishedSequence。Phase 1 单 key autocommit 下语义等价,但设计要求的是后者。
    2. Publish 创建新节点替换旧节点,旧 pending 节点成为 GC 垃圾;多个 batch 同时 publish(理论上)会竞争同一 key 的替换路径。
    3. 弱内存序架构上,新节点写入(通过 atomic.Pointer.Store)发生在 published.Store 之前,顺序对的;但设计要求的 publish-then-load-sequence 模式没有被代码使用,未来引入 MVCC 的 commitSequence / visibleCommitSequence 时会失配。
    4. MemTable.published 字段写完后没人读,是死代码。
  • 修复:要么按设计实现:skiplist 节点带 sequencePut 时直接 published(不 pending),读路径 Load publishedSequence 后过滤;要么保留 pending 模型但在设计文档里明确改写,并删除/利用 MemTable.published。


H7. arena.Reserve 只是检查,不是真正预留

  • 设计要求(§3.2 step ⑤ line 119):按 Batch 内所有 entry 的最大内存占用(key、value 或 ValueLogPointer、skiplist 节点、arena 对齐与层高开销)计算需要预留的 Arena 字节数。
  • 代码现状memtable/arena.go:75-84 Reserve 只读 offset 不修改;memtable/memtable.go:55-70 每条 entry 估算 metadataOverhead = 32,没考虑 maxLevel * sizeof(pointer) = 20 * 8 = 160 bytes 的层高开销。skiplist 节点实际存在堆上(newSkipNode 不用 arena)。
  • 后果Reserve 是个粗略的 budget gate,实际 heap 用量可能超过 MemTableSize;多线程并发 reserve 还可能 double-count(两个 batch 都看到 remaining 够,都写入,实际超限)。Phase 1 不用 arena 存节点,所以不会触发 ErrArenaFull,但容量预算失效。
  • 修复:要么把 Reserve 改成真 atomic advance offsetcommit 预留),要么显式承认 Phase 1 是软限制并在设计里标注;估算公式按最坏层高(maxLevel=20)算。

🟡 Medium(设计偏离但不立即致错)

H8. findValidOffset 不跟踪 batch 边界,截断点错位(Oracle bg_ef425776 升级 M1

  • 设计要求(§3.2 尾部截断持久化 line 786):截断目标必须是"最后一个完整 WAL Batch 的结束位置 lastCompleteBatchEnd"。
  • 代码现状wal/recover.go:137-203findValidOffset 只用 DecodePhysicalRecord 校验物理记录,不跟踪完整 batch 边界 / fragment 状态机。对尾部 First + Middle*Last 的情况:
    • recovery.go:133 在最后一个完整 batch 结尾报尾部损坏
    • findValidOffset 可能返回不完整 fragment 之后的 EOF比真正应截断的位置更靠后
  • 后果:残留半截 fragment 在文件尾部。下次启动 recovery 还会在同位置触发尾部损坏,反复 repair,每次结果可能不同
  • 修复:让 ParseRecordsFromFile 直接返回 lastValidOffset(基于完整 batch 边界),作为 truncation 的权威依据;删掉独立的 findValidOffset 重复解析。

原 M1"两次解析可能不一致")已升级为 H8,因为 Oracle 给出具体证据:不跟踪 batch 边界会让截断点错位,导致反复 repair。

M2. EncodeWalBatch(0, entries) 预校验后立刻丢弃,再编一次

  • wal/writer.go:192-204 先用 baseSequence=0 编码一次只为校验,然后再用真实 baseSequence 编码。功能对(baseSequence 不影响 size),但白编一次 4MB buffer。
  • 修复:抽 validateEncodedSize(entries) 函数,只算 size 不分配 buffer;或者直接信任 ValidateBatchLimits 已经覆盖的检查(其实已经覆盖了),删掉这次预编码。

M3. SyncMode 是字符串 "always",不是设计里的策略枚举

  • 设计明确三种策略 Always / Periodic / NeverPhase 1 只支持 Always。代码用裸字符串,类型不安全,未来加 Periodic 时容易漏改地方。
  • 修复:定义 type SyncMode int + 常量,配置序列化层做字符串映射。

M4. CommitQueue 容量 = MaxBatchEntries,可能过大

  • wal/writer.go:82 queueCapacity := max(1, int(cfg.MaxBatchEntries)) 默认 10000,意味着 channel 缓冲 10000 个 *CommitRequest。每个 request 至少带 1 个 entry 的 key/value clone。高并发下内存占用被低估。
  • 修复:独立配置项 CommitQueueCapacity,默认更小(例如 1024)。

M5. SegmentWriter 没有 durableReady 状态字段

  • 设计要求 segment 显式进入 durable-ready 状态才能承载可确认写入。代码隐式假设"构造函数返回即可写",没有状态机字段。
  • 修复:加 durableReady bool 字段,AppendBatch 前断言;构造函数中所有 fsync 步聚通过后才置 true。

M6. GroupCommitDelay 计时器语义偏离设计

  • 设计:"500µs 或 32KB,先到者触发"。代码 runLoop 每次从 channel 读到第一个 req 后启 timer,500µs 内继续收集。这相当于"从第一个 req 起等待 500µs",与设计一致。但 timer 在每个 batch 循环重建,如果 channel 持续有 req,低负载时其实仍能 500µs 触发,OK。不算 bug,但建议注释清楚

M7. MemTable.abortedsync.Map 且从不清理

  • memtable/memtable.go:36。每次 Abort(seq) 写入,从不删除。长时间运行 + 频繁 abort 会内存泄漏。
  • 修复Publish 时把已 publish 的 sequence 从 aborted 里删掉;或者用 bitmap。

M8. processRemaining 在 write-stopped 后仍会处理队列

  • wal/writer.go:165-173 在 close 时 drain 队列。但 processBatch 内部会先检查 writeStopped,如果已停止会直接 sendError。所以 drain 不会真正写 WALOK。但需要测试覆盖这个路径

M9. 错误消息中"segment 损坏"和"尾部损坏"混用,难审计

  • 多处 fmt.Errorf 没用 %w 包装 ErrWALCorrupted,调用者难以 errors.Is
  • 修复:定义 ErrWALCorrupted / ErrWALTailCorrupted 哨兵错误,所有 corruption 路径用 %w 包装。

🟢 Low / 建议

  • L1wal/header.gowalFileHeaderSize 等小写常量和 wal/header.gowal/constants.go 中重复的大写常量合并,避免两套维护。
  • L2wal/segment_writer.go:42 os.O_EXCL 在并发 Open 同一 tmp 名时直接失败,错误信息可加上"可能是上次崩溃残留"。
  • L3memtable/skiplist.go:209 rand.Float64() 非并发安全(Go 1.20+ 默认 auto-seed 后是安全的,但显式用 math/rand/v2 更清楚)。
  • L4config/config.go:124 SyncMode != "always" 错误消息建议列出合法值。
  • L5:测试覆盖建议补:① crc32c 固定向量;② 非尾段 CollectingFragments 必须 hard error;③ MANIFEST 不被 recovery 修改;④ Put(k, []) 与 Delete(k) 的 Get 结果在恢复后仍能区分;⑤ dir fsync 失败时 segment 不可写。

总结(必须立刻修的)

编号 一句话 风险等级
C1 CRC 多项式错(IEEE → crc32c 格式不符规范
C2 Recovery 用 CURRENT 兜底 恢复起点错误
C3 Recovery 写 MANIFEST 下次启动跳过有效 segment,丢数据
C4 非尾段 CollectingFragments 当尾段损坏 中间损坏被静默截断
C5 截断无 fsync / 不删空 segment / 失败被吞 重复 repairDB 状态不一致
C6 SegmentWriter dir fsync 失败被吞 掉电后已确认写入消失
C7 Put vs Close 的 channel 竞态 偶发 panic
C8 Segment 轮转把字节偏移当 startSequence 多 segment recovery 直接失败

C3 和 C6 是最严重的:C3 直接导致数据丢失,C6 直接破坏 Always 模式的"不丢已确认写入"承诺。C8 是隐藏炸弹:单 segment 时一切正常,第一次轮转后就坏。建议优先修复 C1-C8 这 8 条,再处理 H1-H7。


修复优先级建议

按数据丢失风险排:C3 → C6 → C5 → C2 → C4 → C1 → C7,再处理 H1-H7。

Oracle 修订(bg_2e86d33b:实际上 C2 和 C3 必须捆绑修复(C2 的触发条件在 Phase 1 默认状态下就成立,比 C3 更宽松),单独修 C3 不止血。修订后优先级:C2 + C3(一起)→ C6 → C5 + H8(一起,同一文件)→ C4 → C8 → C1 → C7