因为真实场景需极简依赖、可控落盘时机或适配特定硬件io,第三方库抽象层反成负担;自研kv引擎只需聚焦序列化、索引与持久化,用追加写wal+内存map实现最小可行方案。

为什么不用直接上 BoltDB 或 Badger,而要自己写 KV 引擎
因为真实场景里,你常会遇到:需要极简依赖(比如嵌入式设备)、想控制数据落盘时机、或必须适配特定硬件 IO 模式(如只读 Flash)。这时候第三方库的抽象层反而成了负担。BoltDB 依赖 mmap,在某些容器环境或低内存设备上会失败;Badger 默认启用 value log,小 key 小 value 场景下磁盘碎片严重。自己写一个最小可行 KV 引擎,核心就三件事:数据如何序列化、键如何索引、写操作怎么持久化。
用 Go 原生 encoding/binary + os.File 实现追加写日志(WAL)
不搞 LSM Tree 或 B+Tree,先做最朴素的 append-only log:每条记录 = 4 字节长度 + key 长度 + key 字节 + value 长度 + value 字节。这样读取时靠偏移量顺序扫描,写入无锁,崩溃后也能从末尾往前找合法 record。
-
os.O_CREATE | os.O_RDWR | os.O_APPEND打开文件,确保每次Write()都追加到末尾 - 写前先
binary.Write(w, binary.BigEndian, uint32(len(key))),再写 key 字节,再写 value 长度和内容 - 务必在
Write()后调用file.Sync(),否则断电可能丢失最后几条——这是最容易被忽略的点 - 不要用
bufio.Writer包裹文件,它会缓冲,绕过Sync()语义
内存中用 map[string][]byte 做读缓存,但必须处理脏页回写
纯内存 map 读快,但重启就丢数据。所以得把 WAL 里的最新值加载进 map,同时标记哪些 key 是“脏”的(即内存有更新但未刷入 WAL)。关键不是缓存策略,而是怎么避免重复刷写:
- 启动时遍历 WAL 文件,覆盖式加载(后写的 key 覆盖前面的),用
map[key]struct{value []byte; dirty bool}结构体比单独维护 dirty set 更省内存 - 每次
Put()先写 WAL,成功后再更新 map 并置dirty = true;Get()直接查 map,不碰磁盘 - 不实现后台刷盘线程,而是让
Close()做一次全量 flush——简单场景够用,也避免并发写 WAL 和刷盘冲突
删除操作不能真删文件,得用 tombstone 标记
WAL 是追加写,没法原地擦除。所以 Delete(key) 实际是写一条 key + nil value 记录(或约定 value 长度为 0)。后续 Get() 查到该 key 的最后一条记录是 tombstone,就返回 nil。
- 扫描 WAL 加载时,遇到 tombstone 就从 map 中
delete(m, key),而不是留着占内存 - 不做 compaction 就永远不清理旧记录——这恰恰是设计选择:牺牲磁盘空间换实现简单性和崩溃一致性
- 如果某次
Put()后立刻Delete(),WAL 里会出现两条记录,加载时后者生效,逻辑正确
真正难的不是写代码,是决定哪些东西**不实现**:没有事务隔离、不支持范围查询、不校验 CRC、不压缩 value。这些取舍一旦定下来,整个结构就稳了。WAL 文件路径、map 容量上限、是否允许空 value——这些都该做成初始化参数,而不是硬编码。
golang免费学习笔记(深入):立即使用
在学习笔记中,你将探索golang的核心概念和高级技巧!











