# WAL 代码审核报告(对照 `docs/design.md` § 3.2) 审核范围:`/wal/...`、`/memtable/...`、`/manifest/...`、`/config/...`、`db.go`。 审核时间:基于 commit `e34de4a`(fix: implement group commit collection window)。 下面按严重程度排列,每条给出 **设计要求 → 代码现状 → 后果 → 修复建议**。 --- ## 🔴 Critical(破坏持久化 / 恢复正确性,必须修) ### C1. CRC 多项式错误:用 IEEE 而非 crc32c - **设计要求**(§3.2 Physical Record、WAL File Header):crc32c(Castagnoli,0x82F63B78),用于识别 torn write / partial write。 - **代码现状**: - `wal/record.go:30,55` → `crc32.ChecksumIEEE` - `wal/header.go:44,83` → `crc32.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` ```go 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 时直接返回 0;CURRENT 仅在写入侧作为 hint 顺手写。 > **Oracle 修订(bg_ef425776)**:C2 在 Phase 1 比 C3 更严重。Phase 1 没有 flush,MANIFEST 一直保持 0;`segment_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` ```go 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 只能由 checkpoint(MemTable 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-138` 在 `ReplaySegmentFile` 末尾,只要处于 `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_ef425776)**:C4 还有一个更严重的失败模式。`wal/recover.go:61` 总是对 `segments[len(segments)-1]` 调用 `truncateSegment`,但 `RecoverFromSegments` 的 `TailCorruptionError` 可能来自非尾段。结果:真正损坏的 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` ```go 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 err`,DB.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` ```go 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-326`(Put)先检查 `writeStopped`,再 `queue.Submit`;而 `Close` 先 `writeStopped.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 传给新 segment(Oracle bg_2e86d33b 发现) - **设计要求**(§3.2 WAL File Header、Segment 连续性校验 line 639-663):每个 segment 的 `startSequence` 必须严格衔接前一 segment 最后一个 batch 后的 sequence;recovery 时 `segment.StartSequence != expectedSequence` 必须报错。 - **代码现状**:`wal/segment_manager.go:64-66` ```go 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`: ```go 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,绕过 CRC(payload 为空时 CRC 只覆盖 length+type),恢复出空 batch 进而触发 `entry count is zero` 之类的错误,被误判为"尾部损坏可截断",实际上属于中间损坏。 - **修复**:`DecodePhysicalRecord` 加 `if length == 0 { return ErrZeroLength }`,并在 parser 把它当中间损坏(非尾部不截断)。 --- ### H2. Physical Record 不校验 `type != Invalid (0)` - **设计要求**(§3.2 fragment 类型表 line 366-372):type=0 是 invalid,用于损坏检测。 - **代码现状**:`wal/record.go` 解析时不校验 type;`FragmentCollector.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` ```go worstCaseSize := uint64(len(encodedBatch)) + uint64(PhysicalRecordHeaderSize) + uint64(PhysicalRecordHeaderSize) ``` 只加 14 bytes(2 × 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 推到 seq;`wal/writer.go:228` 每次 `processBatch` fsync 后立刻调用 `MarkDurable(lastSequence)`。 - **后果**:当前 Phase 1 只有 Always 模式 + 单 writer 串行 fsync,**功能上恰好正确**(每次 fsync 覆盖且只覆盖当前 batch,durableSequence == 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.Get` 用 `node.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 节点带 sequence,Put 时直接 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 offset(commit 预留),要么显式承认 Phase 1 是软限制并在设计里标注;估算公式按最坏层高(maxLevel=20)算。 --- ## 🟡 Medium(设计偏离但不立即致错) ### H8. `findValidOffset` 不跟踪 batch 边界,截断点错位(Oracle bg_ef425776 升级 M1) - **设计要求**(§3.2 尾部截断持久化 line 786):截断目标必须是"最后一个完整 WAL Batch 的结束位置 `lastCompleteBatchEnd`"。 - **代码现状**:`wal/recover.go:137-203` 的 `findValidOffset` 只用 `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` / `Never`,Phase 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.aborted` 是 `sync.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 不会真正写 WAL,OK。**但需要测试覆盖这个路径**。 ### M9. 错误消息中"segment 损坏"和"尾部损坏"混用,难审计 - 多处 `fmt.Errorf` 没用 `%w` 包装 `ErrWALCorrupted`,调用者难以 `errors.Is`。 - **修复**:定义 `ErrWALCorrupted` / `ErrWALTailCorrupted` 哨兵错误,所有 corruption 路径用 `%w` 包装。 --- ## 🟢 Low / 建议 - **L1**:`wal/header.go` 把 `walFileHeaderSize` 等小写常量和 `wal/header.go` 与 `wal/constants.go` 中重复的大写常量合并,避免两套维护。 - **L2**:`wal/segment_writer.go:42` `os.O_EXCL` 在并发 Open 同一 tmp 名时直接失败,错误信息可加上"可能是上次崩溃残留"。 - **L3**:`memtable/skiplist.go:209` `rand.Float64()` 非并发安全(Go 1.20+ 默认 auto-seed 后是安全的,但显式用 `math/rand/v2` 更清楚)。 - **L4**:`config/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 / 失败被吞 | **重复 repair,DB 状态不一致** | | **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**。