不用现成嵌入式数据库而自写文件型KV,是因为存在极简场景需求:单进程、无网络、不依赖第三方库、数据量小。

为什么不用现成的嵌入式数据库,而要自己写文件型 KV?
因为真有场景需要极简:单进程、无网络、不依赖第三方库、数据量 boltdb 或 badger 太重,gob 或 json 直接序列化整个 map 又无法增量更新——每次改一个 key 就得重写全部内容,IO 开销大且不安全(崩溃易丢数据)。
用追加写 + 索引文件实现原子写入
核心思路是把操作日志(log)和内存索引分离:所有写操作只追加到 data.log,同时维护一个内存 map 记录每个 key 的最新值及其在 log 中的偏移(offset)和长度(size)。重启时重放 log 构建索引。这样写入永不覆盖,崩溃也不会损坏已有数据。
-
data.log格式:每条记录为[4-byte len][key][4-byte len][value],纯二进制,无分隔符 - 索引文件
index.idx只在关闭时保存(或定期快照),不是必须项;重启时靠重放 log 恢复,所以它只是优化项,可选 - 写入前先
file.Write()日志,再file.Sync()强刷磁盘,确保落盘后再更新内存索引——顺序不能反
读取时如何避免每次都扫完整 log?
靠内存索引加速:map[string]struct{ offset, size int64 }。读 Get("foo") 时直接查索引拿到位置,然后 seek + read 对应字节段。没命中索引?说明 key 不存在(因为 log 是最终权威,索引只是它的投影)。
- 如果业务允许“最终一致”,可以跳过
file.Sync()提升吞吐,但断电可能丢失最后几条写入 - 注意
os.File.ReadAt()的偏移计算:key 长度字段占 4 字节,key 内容紧随其后,value 长度字段再之后,value 内容最后——别漏算头长度 - 并发读写需加锁:
sync.RWMutex,读多写少场景下RLock()足够,写操作用Lock()
删除键怎么处理才安全?
不物理删除 log 中的数据,而是写一条 DEL 类型记录(比如 value 长度为 -1),重放 log 时跳过已删 key。这样保证 log 仍是线性、只追加的,也避免文件碎片和随机写。
立即学习“go语言免费学习笔记(深入)”;
- 实际写入时,
Put("k", []byte{})和Delete("k")都生成同一种 DEL 记录:key 后跟0xffffffff(即 -1 的 uint32 表示),value 部分为空 - Compact 操作(压缩旧 log)必须等所有读操作完成后再做,否则可能读到被清理掉的 offset——建议用单独 goroutine + 文件重命名原子切换
- 不要用
os.Remove()直接删老 log,先 rename 成.old,确认新 log 可读后再删,防止误删活跃文件
真正麻烦的是并发 compact 和写入的协调,以及 crash 后 log 截断位置的校验——这些边界情况比主逻辑更耗调试时间。


















