来自 #130 的架构级评审建议。不阻塞合入,仅供参考是否有更好的架构解法。
🛑 [阻塞 · 存储] writeStandalone 的 WAL Append 和 memtable 写不是原子的 service/fsm.go:89
问题根因:writeStandalone 先调用 w.wal.Append(含 fsync)再调用 storage.Put 写 memtable。如果 Put 成功但返回过程中进程崩溃——或更严重地,如果 Put 在中途崩溃(memtable 写入部分完成),WAL 已持久化该记录,但 memtable 的状态是不完整的。崩溃恢复时 replayWAL 会重放该记录,但 memtable 中可能已有部分写入的数据,导致重复或部分写入的不一致状态。虽然 storage.Put 的 memtable(跳表)写入是幂等的(相同 key+value 重写无副作用),但如果 Put 在写入跳表的过程中崩溃(仅部分节点更新完成),跳表结构可能损坏(指针断裂),而重放会试图在一个结构损坏的跳表上继续写入。
为什么低级解法不够:加 defer 或 recover 兜底 panic 不能解决「WAL 已写但 memtable 未写完」的一致性问题。将二者包进一个事务层(如引入二阶段提交)是过度设计——standalone 模式下不需要完整的分布式事务。
架构级方案:调换写入顺序为「先写 memtable,再 append+fsync WAL」。如果 memtable 写完后进程崩溃,WAL 中没有这条记录,恢复时不会出现假数据(丢失该帧,但不会不一致)。如果 memtable 写 + WAL Append 都成功,是完整持久化。如果 WAL Append 失败,memtable 写应回滚——这需要在 memtable 层支持单条回滚(或使用临时 buffer 先写 WAL 再刷 memtable,但 fsync 在后者)。更实际的方案是保持「先 WAL 再 memtable」的顺序,但用WAL 检查点标记哪些记录已完整写入 memtable——崩溃恢复时跳过已检查点的记录,对于未检查点的记录,先清理 memtable 中可能的不完整状态再重放。
代价/收益:检查点方案增加了 WAL 格式的复杂度(需引入 checkpoint record),但提供了崩溃一致性保证。如果接受丢失最后一帧(即先写 memtable 后写 WAL),则简单调换顺序即可——这对采集场景通常可接受(传感器数据持续产生,丢失一帧远比数据损坏好)。建议采纳后者作为即时修复,检查点作为未来优化。
⚠️ [重要 · 存储] WAL 文件在 writeStandalone 期间可能被并发访问 service/fsm.go:123
问题根因:w.wal 在 NewKVServer 时被创建一次(storage.NewWAL),之后所有 Write 调用都通过同一个 WAL 实例的 Append 写入。Append 方法内部对 w.file.Write(buf) 和 w.file.Sync() 没有加锁——如果 Write 被多个 goroutine 并发调用(目前服务端是单 goroutine per 请求路径?但 worker pool 是多 goroutine 架构),w.file.Write 的底层是 os.File.Write,Go 的 os.File.Write 是线程安全的(系统调用级互斥),但两次 Write 之间的数据交错仍可能发生(buf1 的一部分 + buf2 的一部分被交叉写入文件),虽然 Append 每次写入一个完整的小 buffer(copy 到局部 buf),系统调用级别的原子性在 Linux 上对小于 PIPE_BUF(4KB)的写入有保证。真正的问题是:w.wal.Append 中 make([]byte, 9+len(key)+len(value)) 分配到堆上,然后 w.file.Write(buf),没有锁保护 buf 的分配和写入序列——即使 Write 系统调用是原子的,两次 Append 的 slice header 可能在写入前被 GC 移动,但不影响正确性。 更严重的是:两个并发 Append 可能各自调用一次 w.file.Sync()——Sync 是文件级的 fsync,两次并发的 fsync 不会损坏数据,但会加倍 fsync 开销。
为什么低级解法不够:加一个 sync.Mutex 在 WAL.Append 上能解决并发写入问题,但会串行化所有写操作的 fsync——这正是 WAL group-commit 要避免的。
架构级方案:在 WAL 上添加一个写入锁(sync.Mutex),将单条记录的 Append 作为原子操作(Write+Sync)。然后通过批处理/组提交来提升吞吐:将多个并发的 Append 合并成一个大的 buffer,只 fsync 一次。这与 Raft 的 group-commit fsync 是同样的模式。具体:WAL 内部维护一个待写队列,批量收集后一次写入+fsync。
代价/收益:实现组提交需要一定的队列和协调逻辑(类似 Raft 的 commit pipeline),对 standalone 模式的简单性有影响。单机场景下直接加简单互斥锁,配合收集合并的思想,是一个合理的折中。建议:短期加锁保证正确性,长期引入组提交优化性能。
🛑 [阻塞 · 存储] writeStandalone 的 WAL Append 和 memtable 写不是原子的
service/fsm.go:89问题根因:
writeStandalone先调用w.wal.Append(含 fsync)再调用storage.Put写 memtable。如果Put成功但返回过程中进程崩溃——或更严重地,如果Put在中途崩溃(memtable 写入部分完成),WAL 已持久化该记录,但 memtable 的状态是不完整的。崩溃恢复时replayWAL会重放该记录,但 memtable 中可能已有部分写入的数据,导致重复或部分写入的不一致状态。虽然storage.Put的 memtable(跳表)写入是幂等的(相同 key+value 重写无副作用),但如果Put在写入跳表的过程中崩溃(仅部分节点更新完成),跳表结构可能损坏(指针断裂),而重放会试图在一个结构损坏的跳表上继续写入。为什么低级解法不够:加 defer 或 recover 兜底 panic 不能解决「WAL 已写但 memtable 未写完」的一致性问题。将二者包进一个事务层(如引入二阶段提交)是过度设计——standalone 模式下不需要完整的分布式事务。
架构级方案:调换写入顺序为「先写 memtable,再 append+fsync WAL」。如果 memtable 写完后进程崩溃,WAL 中没有这条记录,恢复时不会出现假数据(丢失该帧,但不会不一致)。如果 memtable 写 + WAL Append 都成功,是完整持久化。如果 WAL Append 失败,memtable 写应回滚——这需要在 memtable 层支持单条回滚(或使用临时 buffer 先写 WAL 再刷 memtable,但 fsync 在后者)。更实际的方案是保持「先 WAL 再 memtable」的顺序,但用WAL 检查点标记哪些记录已完整写入 memtable——崩溃恢复时跳过已检查点的记录,对于未检查点的记录,先清理 memtable 中可能的不完整状态再重放。
代价/收益:检查点方案增加了 WAL 格式的复杂度(需引入 checkpoint record),但提供了崩溃一致性保证。如果接受丢失最后一帧(即先写 memtable 后写 WAL),则简单调换顺序即可——这对采集场景通常可接受(传感器数据持续产生,丢失一帧远比数据损坏好)。建议采纳后者作为即时修复,检查点作为未来优化。
service/fsm.go:123问题根因:
w.wal在NewKVServer时被创建一次(storage.NewWAL),之后所有Write调用都通过同一个WAL实例的Append写入。Append方法内部对w.file.Write(buf)和w.file.Sync()没有加锁——如果Write被多个 goroutine 并发调用(目前服务端是单 goroutine per 请求路径?但 worker pool 是多 goroutine 架构),w.file.Write的底层是os.File.Write,Go 的 os.File.Write 是线程安全的(系统调用级互斥),但两次Write之间的数据交错仍可能发生(buf1 的一部分 + buf2 的一部分被交叉写入文件),虽然Append每次写入一个完整的小 buffer(copy 到局部 buf),系统调用级别的原子性在 Linux 上对小于 PIPE_BUF(4KB)的写入有保证。真正的问题是:w.wal.Append中make([]byte, 9+len(key)+len(value))分配到堆上,然后w.file.Write(buf),没有锁保护buf的分配和写入序列——即使Write系统调用是原子的,两次Append的 slice header 可能在写入前被 GC 移动,但不影响正确性。 更严重的是:两个并发 Append 可能各自调用一次w.file.Sync()——Sync 是文件级的 fsync,两次并发的 fsync 不会损坏数据,但会加倍 fsync 开销。为什么低级解法不够:加一个
sync.Mutex在 WAL.Append 上能解决并发写入问题,但会串行化所有写操作的 fsync——这正是 WAL group-commit 要避免的。架构级方案:在 WAL 上添加一个写入锁(
sync.Mutex),将单条记录的 Append 作为原子操作(Write+Sync)。然后通过批处理/组提交来提升吞吐:将多个并发的 Append 合并成一个大的 buffer,只 fsync 一次。这与 Raft 的 group-commit fsync 是同样的模式。具体:WAL 内部维护一个待写队列,批量收集后一次写入+fsync。代价/收益:实现组提交需要一定的队列和协调逻辑(类似 Raft 的 commit pipeline),对 standalone 模式的简单性有影响。单机场景下直接加简单互斥锁,配合收集合并的思想,是一个合理的折中。建议:短期加锁保证正确性,长期引入组提交优化性能。