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