Skip to content

🐯 [并发] Recover 归还前仍被读取的块 · delivery_bootstrap.go #298

Description

@github-actions

来自 #296 的架构级评审建议。不阻塞合入,仅供参考是否有更好的架构解法。

⚠️ [重要 · 并发] Recover 归还前仍被读取的块 service/delivery_bootstrap.go:44

问题根因:reclaimingOffsetStore.Commit 在 inner.Commit 成功后、同一日志行上调用 kv.ReclaimDelivered(cursor),且它持有了 kv 的引用。但 KVServer.ReclaimDelivered 内部先压 bound 到 reserved 前缀下,随后直接调 storage.ReclaimUpTo。问题在于,这个回收调用发生在 offset 提交的 KV 写(inner.Commit)已经完成之后,而 offset 的 KV 写走的是 kv.Write → raft/standalone WAL,两者在进程内是同一 KVServer。虽然整体来看提交与回收串行,但 Compaction 是后台 goroutine(ListenCompactCh)驱动的,与 ReclaimDelivered 线程间并发,fileMu 已覆盖。更重要的是:ReclaimDelivered 传入的 cursor 未被持久化到本地之前就用于回收,若进程恰在 inner.Commit 持久化之后、ReclaimDelivered 执行之前崩溃,恢复后游标已推进但该批文件尚未回收,属保守(宁可多留)——这是可接受的。真正的风险是反向顺序:ReclaimDelivered 在 standalone 下绕过 cpMu,而写入路径持 cpMu.RLock;回收删掉的文件此刻可能仍有正在 flush 的 dirty 表尚未落盘引用它,极端时序下出现已回收数据的引用仍存于 memtable/在途 flush

为什么低级解法不够:单纯把 ReclaimDelivered 挪回 cpMu 内(加 RLock)只能覆盖 standalone 的 WAL 一致性,却无法覆盖 raft 模式(raft 无 cpMu)。低修是「给回收也套上和写入一致的锁」,但回收与写入本质上是不同维度(回收面向已投递的持久化文件,写入面向活跃 memtable),强行共用一把锁会得偿不同步。

架构级方案:回收不应依赖「调用顺序」保证正确,而应依赖「文件是否仍被资源持有」的判据:在 ReclaimUpTo 中,仅回收那些「当前既不在任一 active/dirty memtable 的 flush 在途引用中、也不在磁盘 WAL 重放所需区间」的文件。更简明的做法:把回收的边界收紧到「只回收已 flush 落下、且其数据早已被 WAL checkpoint 剪枝掉之前的文件」,即把回收判据从『MaxKey < bound』升级为『MaxKey < bound 且该文件不在活跃内存表/在途 flush 的覆盖区间内』。这需要 Engine 暴露 per-file 的活跃引用信息给 Reclaim 决定。

代价/收益:收益:从根上消除『回收了仍有在途引用的文件』这一类竞态,而非依赖调用顺序。代价:需要 Engine 内维护一份 per-file 的活跃引用计数(refcount),或在 flush 完成后才能回收(回收滞后一批)。实现复杂度略升,但换来的是回收动作在任何时序下都安全。

Metadata

Metadata

Assignees

No one assigned

    Labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions