不用第三方库也能做出高性能本地 KV 引擎,关键在于 WAL + 内存索引 + 固定 record 格式保障写入不丢、读取不卡、重启不崩;os.WriteFile 不可靠因并发覆盖、崩溃截断、全量重写等问题;真正可用的引擎需追加写、结构化布局、崩溃可恢复;WAL 必须用 os.O_APPEND + binary.Write + file.Sync() 且禁用 bufio.Writer;内存 map 需启动加载 WAL + 运行时标记 dirty;Delete 必须用 tombstone 而非物理删除;边界处理(sync、tombstone 解析、损坏 record 跳过)决定可靠性。

直接上结论:不用第三方库也能做出高性能本地 KV 引擎,关键不是“快”,而是“写入不丢、读取不卡、重启不崩”——这三点靠 WAL + 内存索引 + 固定 record 格式就能稳住,比 BoltDB 更可控,比 Badger 更轻。
为什么 os.WriteFile 不能当 KV 持久化用
它不是“写失败”,而是“写得看似成功,实则不可靠”:
- 并发两个
os.WriteFile同时写同一个文件,后写的直接覆盖前写的,键值对静默丢失 - 进程崩溃在
WriteFile中途,文件被截断,整个 KV 数据库不可恢复 - 哪怕只改一个
user:123的状态,也要把几万条记录全读进内存再全量写回,O(n) 时间 + O(n) 内存
真正可用的本地 KV 引擎必须满足:追加写(append-only)、结构化布局(record header + key + value)、崩溃可恢复(WAL replay)。
用 encoding/binary + os.File 实现安全 WAL
不搞 LSM、不引入 mmap,就用最朴素的追加写日志,但每一步都得踩准:
立即学习“go语言免费学习笔记(深入)”;
- 打开文件用
os.O_CREATE | os.O_RDWR | os.O_APPEND,确保每次Write()都落到末尾 - 每条 record 格式固定:
uint32(len(key))+ key 字节 +uint32(len(value))+ value 字节 - 写完必须立刻调用
file.Sync(),否则断电或 kill -9 就丢最后几条——这是 90% 自研引擎崩溃后数据不一致的根源 - 绝对不要用
bufio.Writer包裹文件句柄,它的缓冲会绕过Sync()语义
示例片段:
Go 配置库,使用 spf13/viper — 分层优先级(flag > env >file > KV > default),提供 BindPFlag/BindPFlags、SetEnvPrefix + SetEnvKeyReplace 等功能。
func (w *WALWriter) Write(key, value []byte) error {
if err := binary.Write(w.file, binary.BigEndian, uint32(len(key))); err != nil {
return err
}
if _, err := w.file.Write(key); err != nil {
return err
}
if err := binary.Write(w.file, binary.BigEndian, uint32(len(value))); err != nil {
return err
}
_, err := w.file.Write(value)
if err != nil {
return err
}
return w.file.Sync() // 这行不能省
}
内存 map 怎么和 WAL 协同不丢数据
纯 map[string][]byte 读快,但重启就空了;全靠 WAL 加载又太慢。折中方案是“启动时加载 + 运行时标记脏页”:
- 启动时顺序扫描 WAL 文件,遇到正常 record 就
m[key] = value,遇到 tombstone(len(value) == 0)就delete(m, key) -
Put()必须先成功写 WAL,再更新内存 map 并标记dirty = true;Get()直接查 map,不碰磁盘 - 关闭时做一次 flush:遍历所有
dirty == true的 key,再写一遍 WAL(或按需合并),避免关机前最后一波写丢失 - 别用
sync.Map替代普通 map——它只为高并发读设计,不解决持久化问题,反而增加 GC 压力
结构体建议定义为:type entry struct { value []byte; dirty bool },比单独维护 dirtySet map[string]struct{} 更省内存且无重复判断开销。
Delete 不是真的删,tombstone 是唯一安全做法
WAL 是追加写,物理删除等于重写整个文件,违背设计初衷。正确做法是写一条逻辑删除记录:
-
Delete(key)实际调用Write(key, nil)或约定value length == 0为 tombstone -
Get()查到某 key 的最后一条 record 是 tombstone,就返回nil,不查更早的 record - 加载 WAL 时,遇到 tombstone 立即从内存 map 中
delete(m, key),不保留占位 - 不做后台 compaction —— 这不是缺陷,是显式取舍:用磁盘空间换实现简单性和崩溃一致性
注意:如果业务有“Put → Delete → Put 同 key”的高频场景(比如 session 刷新),WAL 里会出现冗余记录,但只要 Get 能正确识别最后一条,就完全不影响语义。
真正难的不是写 WAL 或建索引,而是每条 write 调用后是否真 sync 到磁盘、每条 delete 是否被正确解释为 tombstone、每次重启时 WAL 扫描是否跳过损坏 record——这些边界条件不处理好,性能再高也是纸老虎。


















