MiniKV 是一个使用 Go 实现的轻量级嵌入式 KV 存储组件,适合保存任务状态、checkpoint 和临时运行数据。它以 map[string]string 作为内存索引,通过 WAL 提供重启恢复能力,并实现了 TTL、Batch、Snapshot 和 Compaction 等完整的基础存储流程。
这个项目的目标是用较小的代码规模把存储组件的关键问题做清楚。它不是 Redis 的替代品,也不是分布式数据库。
Set、Get、Delete和SetWithTTL基础操作。Batch批量写入,整批校验后以单条 WAL 记录提交。- 追加式 JSON Lines WAL,每条新记录带 CRC32 checksum。
- 启动时 replay WAL,恢复最终内存状态。
- 识别并截断尾部半写记录;拒绝中间损坏和 checksum 不一致的记录。
- TTL 使用绝对过期时间,结合惰性删除和后台定期清理。
- Snapshot + 增量 WAL 恢复,减少长 WAL 的启动回放成本。
- 手动 Compaction 和按 WAL 大小触发的自动 Compaction。
- 数据目录独占,避免两个 Store 同时写入同一份 WAL。
- 单元测试、race 检测和 benchmark。
要求 Go 1.22 或更高版本。
go get github.com/Yangsss13/miniKVpackage main
import (
"fmt"
"log"
"time"
"github.com/Yangsss13/miniKV"
)
func main() {
store, err := minikv.OpenWithOptions("./data", minikv.Options{
CleanupInterval: time.Minute,
AutoCompactMinBytes: 64 * 1024 * 1024,
})
if err != nil {
log.Fatal(err)
}
defer store.Close()
if err := store.Set("task:1", "running"); err != nil {
log.Fatal(err)
}
value, ok := store.Get("task:1")
if ok {
fmt.Println(value)
}
if err := store.SetWithTTL("task:2", "pending", 10*time.Second); err != nil {
log.Fatal(err)
}
}批量写入:
err := store.Batch([]minikv.BatchOp{
{Op: minikv.BatchSet, Key: "task:1", Value: "running"},
{Op: minikv.BatchSetTTL, Key: "task:2", Value: "temporary", TTL: time.Minute},
{Op: minikv.BatchDelete, Key: "task:old"},
})手动生成 Snapshot 或压缩 WAL:
if err := store.Snapshot(); err != nil {
log.Fatal(err)
}
if err := store.Compact(); err != nil {
log.Fatal(err)
}Set、Delete、SetWithTTL 和 Batch 遵循同一条写入规则:
校验输入
-> 获取写锁
-> 追加 WAL 记录
-> Sync 刷盘
-> 更新 data/expires
-> 释放写锁
-> 按配置检查是否需要自动 Compaction
只有 WAL 写入和 Sync 成功后,MiniKV 才更新内存,避免当前进程看到无法在重启后恢复的新状态。每次写入都调用 Sync,提高了恢复可靠性,也直接限制了写吞吐量。
WAL 使用一行一条 JSON 记录的格式。新记录将操作和 CRC32 checksum 包装在一起:
{"record":{"op":"set","key":"task:1","value":"running"},"checksum":123456789}MiniKV 仍可读取旧版无 checksum 的 WAL 记录。恢复时必须按写入顺序 replay,因为同一个 key 的最终状态取决于最后一次有效操作。
Open(dir) 的恢复流程为:
创建空的 data/expires
-> 打开 WAL
-> 存在 Snapshot 时先加载 Snapshot
-> 从 Snapshot 记录的 wal_offset 继续 replay WAL
-> 没有 Snapshot 时从 WAL 开头 replay
-> 将文件指针移到末尾
-> 启动 TTL cleaner
如果最后一条 WAL 因半写而不完整,恢复会将其截断,视为该操作没有成功提交;如果损坏发生在中间,或完整记录的 checksum 不一致,恢复会直接报错,避免返回不可信状态。
MiniKV 使用两张 map 保存当前状态:
data map[string]string
expires map[string]time.Time永久 key 只存在于 data,TTL key 还会在 expires 中保存绝对过期时间 expire_at。记录绝对时间可以避免程序重启后重新计算完整 TTL,错误延长 key 的生命周期。
过期数据通过两种方式清理:
- 惰性删除:
Get发现 key 已过期时立即删除并返回不存在。 - 定期删除:后台 cleaner 定期扫描
expires,删除无人访问的过期 key。
两者解决的问题不同:
| 机制 | 解决的问题 | 实现方式 |
|---|---|---|
| Snapshot | 减少启动恢复时需要 replay 的记录 | 保存当前 data/expires 和对应的 wal_offset |
| Compaction | 控制 WAL 文件持续增长 | 根据当前有效状态重写一份精简 WAL |
Snapshot 之后的写入仍然追加到 WAL。恢复时先加载 Snapshot,再 replay wal_offset 之后的增量记录。Compaction 会改变整个 WAL 的位置,因此完成后会移除 offset 已失效的旧 Snapshot。
Snapshot 和 Compaction 都持有写锁,以保证生成的磁盘状态与同一时刻的内存状态一致。
Batch 会先校验整批操作,再把整批内容编码成一条 WAL 记录,成功刷盘后统一更新内存。这样可以避免非法操作导致内存只应用半批,也避免恢复时只 replay 到批次的一部分。
它不是带回滚和隔离级别的数据库事务;如果底层 Sync 返回错误,调用方应把本次提交结果视为不确定,并通过重试或重新打开 Store 确认状态。
上层任务系统可以把少量运行时恢复状态编码为 JSON 后写入 MiniKV:
checkpoint:task:42 -> {"task_id":"task:42","current_step":2,"status":"running"}
服务重启后重新打开同一数据目录即可读取 checkpoint。任务定义、执行日志等业务事实仍应由上层系统保存;MiniKV 只负责 checkpoint 的持久化和恢复,不决定业务从哪一步继续执行。
go test ./...
go test -race ./...
go test -bench BenchmarkStore -benchmem -run none -count=1 .测试覆盖基础读写、参数校验、关闭状态、WAL 恢复、TTL、Batch、Snapshot、Compaction、目录独占、checkpoint、并发访问,以及尾部半写和中间损坏等失败场景。
2026-07-15 本地 benchmark(Windows/amd64,13th Gen Intel Core i9-13980HX):
BenchmarkStoreSet-32 306115 ns/op 714 B/op 8 allocs/op
BenchmarkStoreGet-32 70.79 ns/op 13 B/op 1 allocs/op
BenchmarkStoreSetWithTTL-32 373960 ns/op 919 B/op 7 allocs/op
BenchmarkStoreBatch-32 302345 ns/op 1947 B/op 18 allocs/op
BenchmarkStoreCompact-32 14085819 ns/op 8802 B/op 90 allocs/op
BenchmarkStoreRecoverFromFullWAL-32 3122417 ns/op 921116 B/op 14057 allocs/op
BenchmarkStoreRecoverFromSnapshot-32 1447365 ns/op 388946 B/op 3224 allocs/op
结果只反映该机器和当前测试数据。写操作包含 WAL Sync,因此明显慢于内存读取;在这组 1000 条基础记录和 10 条增量记录的样本中,Snapshot 恢复比全量 WAL replay 耗时更低、内存分配更少。
MiniKV 有意保持较小范围,目前不实现 LSM Tree、MVCC、SQL 事务、Raft、复制和分布式存储。
当前实现仍有以下限制:
- JSON Lines WAL 易于阅读和调试,但空间利用率和编解码效率不如二进制格式。
- 每次写入都调用
Sync,可靠性优先,但不适合高吞吐写入场景。 Get可能触发惰性删除,因此当前使用写锁,读并发仍有优化空间。- 定期清理会扫描全部 TTL key;大规模 TTL 场景可进一步使用分批扫描、最小堆或时间轮。
- Snapshot 需要手动触发,Compaction 期间也会阻塞其他读写。
- 目录锁使用操作系统级排他锁;标记文件可以保留,但进程退出后锁会由系统释放,重启不需要人工删除锁文件。
- 临时文件替换流程适合当前学习项目,生产级存储还需要更严格的目录同步、故障注入和跨平台原子替换验证。