来自 #139 的架构级评审建议。不阻塞合入,仅供参考是否有更好的架构解法。
🛑 [阻塞 · 并发] firstGTE 无锁遍历跳表,并发写入时出现越界 storage/zstorage/memtable.go:148
问题根因:firstGTE(memtable.go:163-175)在持读锁时遍历跳表的 Next[i] 指针(sl.level-1 到 0 逐层下降)。读锁保护了 m.active/m.dirty 引用的切换,但跳表内部节点的 Next[i] 字段在并发写入时可被修改(SkipList.insert 会写 p.Next[i] 和 newNode.Next[i],这些在 put 路径上受 m.mu.Lock() 写锁保护)。在持 RLock 时读 Next[i],写入路径持写锁,这本身是安全的(Go sync.RWMutex 保证读锁与写锁互斥)。但关键在于:firstGTE 的遍历读取 p.Next[i] 时,同一跳表的插入操作可能在另一个 goroutine 等待写锁。由于 RWMutex 的读锁会阻塞写锁,所以不会出现直接的 data race。然而此处存在一个更微妙的问题:firstGTE 在持读锁期间访问 sl.head 和 p.Next[i],若跳表在同一时刻被 sl.insert 修改(通过写锁保护),读锁确保不会并发发生。所以实际上这不是 data race——但前提是 m.mu 在 ScanRange 全程持有,且写操作(put/delete)都在 m.mu 保护下修改跳表。需要确认 put/delete 确实都在 m.mu 写锁保护下执行。
为什么低级解法不够:以目前代码来看,此问题属于误报——读锁确实保护了遍历。但若后续有人把某些跳表操作移出 m.mu 保护(如为了性能将读路径从 RLock 改为原子加载),这个遍历就会崩溃。
架构级方案:此处无需改动。但应加一条明确的架构层约定注释:firstGTE 必须在 m.mu(或等效的写保护锁)持有期间调用,该约束应通过类型系统或文档强制执行。另建议:将 firstGTE 改为在快照副本上操作(见上方案)以彻底解耦。
代价/收益:代价:无需额外改动。收益:消除未来重构时的隐患。
🛑 [阻塞 · 并发] firstGTE 无锁遍历跳表,并发写入时出现越界
storage/zstorage/memtable.go:148问题根因:
firstGTE(memtable.go:163-175)在持读锁时遍历跳表的Next[i]指针(sl.level-1到 0 逐层下降)。读锁保护了m.active/m.dirty引用的切换,但跳表内部节点的Next[i]字段在并发写入时可被修改(SkipList.insert会写p.Next[i]和newNode.Next[i],这些在put路径上受m.mu.Lock()写锁保护)。在持 RLock 时读Next[i],写入路径持写锁,这本身是安全的(Gosync.RWMutex保证读锁与写锁互斥)。但关键在于:firstGTE的遍历读取p.Next[i]时,同一跳表的插入操作可能在另一个 goroutine 等待写锁。由于 RWMutex 的读锁会阻塞写锁,所以不会出现直接的 data race。然而此处存在一个更微妙的问题:firstGTE在持读锁期间访问sl.head和p.Next[i],若跳表在同一时刻被sl.insert修改(通过写锁保护),读锁确保不会并发发生。所以实际上这不是 data race——但前提是 m.mu 在 ScanRange 全程持有,且写操作(put/delete)都在 m.mu 保护下修改跳表。需要确认 put/delete 确实都在 m.mu 写锁保护下执行。为什么低级解法不够:以目前代码来看,此问题属于误报——读锁确实保护了遍历。但若后续有人把某些跳表操作移出 m.mu 保护(如为了性能将读路径从 RLock 改为原子加载),这个遍历就会崩溃。
架构级方案:此处无需改动。但应加一条明确的架构层约定注释:
firstGTE必须在m.mu(或等效的写保护锁)持有期间调用,该约束应通过类型系统或文档强制执行。另建议:将firstGTE改为在快照副本上操作(见上方案)以彻底解耦。代价/收益:代价:无需额外改动。收益:消除未来重构时的隐患。