来自 #139 的架构级评审建议。不阻塞合入,仅供参考是否有更好的架构解法。
⚠️ [重要 · 并发] Scan 持读锁期间调用 fn 可能嵌套获取同一锁 service/fsm.go:189
问题根因:ScanRange 全程持 m.mu.RLock()(memtable.go:188),而回调 fn(即 KVServer.Scan 的闭包)中调用了 pred.Eval——这是一个纯函数,在当前 PR 中不嵌套锁。但未来若调用方在 fn 中做任何需要写锁的操作(如在一个遍历中删改数据),将因同一 goroutine 尝试获取 m.mu.Lock() 而死锁(Go 的 RWMutex 不可递归)。根因是「锁范围隐含地覆盖了回调执行期间」,但 API 签名没有声明 fn 内禁止写操作。
为什么低级解法不够:加注释说明「回调内不得写锁」是被动防御;改为用 TryRLock 兜底也不解决设计问题。通用评审者可能看不出这个隐患。
架构级方案:两个方向:① 将扫描路径改为快照迭代器模式——在 ScanRange 开始时创建 active/dirty 跳表的不可变快照(atomic load 指针 + copy-on-write 或引用计数),然后释放读锁,在无锁快照上遍历。这样 fn 可安全执行任何操作,锁仅用于保护快照创建。② 在 ScanRange 的文档注释中明确标注「fn 内禁止获取 m.mu 写锁」,并在未来引入 lint 或 sync.RWMutex 的 RLocker() 代理检查。建议采用方案①,这不仅消除死锁隐患,还能大幅缩短锁持有时间。
代价/收益:代价:快照方案需要跳表支持 O(1) 的原子快照(拷贝头指针数组),或引入引用计数机制防止遍历中节点被释放;实现成本中等。收益:锁持有时间从「整段扫描时长」降为「O(1) 快照创建」,写入并发几乎不受扫描影响,回调函数无锁使用约束。
service/fsm.go:189问题根因:
ScanRange全程持m.mu.RLock()(memtable.go:188),而回调 fn(即KVServer.Scan的闭包)中调用了pred.Eval——这是一个纯函数,在当前 PR 中不嵌套锁。但未来若调用方在 fn 中做任何需要写锁的操作(如在一个遍历中删改数据),将因同一 goroutine 尝试获取m.mu.Lock()而死锁(Go 的 RWMutex 不可递归)。根因是「锁范围隐含地覆盖了回调执行期间」,但 API 签名没有声明 fn 内禁止写操作。为什么低级解法不够:加注释说明「回调内不得写锁」是被动防御;改为用
TryRLock兜底也不解决设计问题。通用评审者可能看不出这个隐患。架构级方案:两个方向:① 将扫描路径改为快照迭代器模式——在
ScanRange开始时创建 active/dirty 跳表的不可变快照(atomic load 指针 + copy-on-write 或引用计数),然后释放读锁,在无锁快照上遍历。这样 fn 可安全执行任何操作,锁仅用于保护快照创建。② 在ScanRange的文档注释中明确标注「fn 内禁止获取 m.mu 写锁」,并在未来引入 lint 或sync.RWMutex的RLocker()代理检查。建议采用方案①,这不仅消除死锁隐患,还能大幅缩短锁持有时间。代价/收益:代价:快照方案需要跳表支持 O(1) 的原子快照(拷贝头指针数组),或引入引用计数机制防止遍历中节点被释放;实现成本中等。收益:锁持有时间从「整段扫描时长」降为「O(1) 快照创建」,写入并发几乎不受扫描影响,回调函数无锁使用约束。