第 54 章 / 共 58 章 卷十 代码讲解

KV 存储引擎

Redis、LevelDB、Riak——这些知名存储系统的核心都是键值引擎。本章从零实现一个持久化 KV 存储:数据追加写入日志文件(顺序 I/O,极快),内存中维护哈希索引(O(1) 查找),启动时重放日志恢复状态。这就是 Bitcask 模型(Riak 的存储后端),也是理解 LSM-Tree、WAL、Compaction 等数据库核心概念的最佳入口。

本章导读

传统数据库用 B-Tree 索引,读写都需要随机 I/O。Bitcask 的洞察:如果所有写入都是追加(append-only),那么写操作就是顺序 I/O——磁盘最快的操作模式。读取通过内存哈希索引直接定位文件偏移,也是 O(1)。代价是需要定期压缩(合并旧数据),但压缩可以在后台异步进行。

本章学习目标
  • 理解 Append-Only Log 的设计哲学
  • 实现日志记录格式(键长+值长+键+值)
  • 实现 put/get/delete 三个核心操作
  • 实现启动时重放日志恢复内存索引
  • 实现压缩(合并有效数据,丢弃旧版本)
  • 实现 TTL 自动过期

54.1 设计思想

Bitcask 模型的核心组件:

// 架构概览
//
// ┌─────────────────────────────────────┐
// │         内存哈希索引                  │
// │   key → (file_offset, value_size)   │
// └──────────────┬──────────────────────┘
//                │ 定位
// ┌──────────────▼──────────────────────┐
// │         追加写日志文件                │
// │  [rec1][rec2][rec3]...[recN]        │
// │  ← 旧数据(可能被覆盖)  新数据 →    │
// └─────────────────────────────────────┘
//
// 写入:追加到文件末尾 + 更新内存索引
// 读取:查内存索引 → 定位文件偏移 → 读取值
// 删除:写入墓碑记录(tombstone)
// 恢复:重放日志,重建内存索引

54.2 日志记录格式

每条记录的二进制格式:

// 记录格式(文本简化版,实际用二进制):
// [4字节: 键长度][4字节: 值长度][1字节: 标志][键][值]
// 标志: 0=正常, 1=删除(tombstone)

// 简化实现:用文本格式(每行一条记录)
// 格式: "PUT key value" 或 "DEL key"
// 用 \t 分隔字段

func encodeRecord(string op, string key, string value) as string
    return op + "\t" + key + "\t" + value + "\n"
end

func decodeRecord(string line) as array<string>
    return string.split(line, "\t")
end
为什么用文本格式

生产系统用二进制格式(固定长度头 + 变长载荷),性能更高。本章用文本格式是为了代码可读性——核心逻辑完全相同。理解原理后,替换为二进制格式只需改 encode/decode 两个函数。

54.3 put:写入

class KvStore
    string dataFile
    dict<string, string> index   // 内存索引:key → value

    func open(string path)
        dataFile = path
        index = dict<string, string>()
        recover()
    end

    func put(string key, string value)
        // 1. 追加写入日志文件
        string record = encodeRecord("PUT", key, value)
        file.appendText(dataFile, record)

        // 2. 更新内存索引
        index[key] = value
    end
end

// 使用
KvStore db = KvStore()
db.open("kv_data.log")
db.put("name", "xlang")
db.put("version", "6.0")
db.put("name", "xlang-demo6")   // 覆盖旧值

put 的时间复杂度:O(1) 追加写 + O(1) 哈希表更新。无论数据量多大,写入速度恒定。

54.4 get:读取

func get(string key) as string
    if index.has(key) then
        return index[key]
    end
    return ""   // 不存在返回空串
end

func has(string key) as bool
    return index.has(key)
end

// 测试
assert db.get("name") == "xlang-demo6"   // 最新值
assert db.get("version") == "6.0"
assert db.get("missing") == ""           // 不存在

读取完全在内存中完成——O(1) 哈希查找。这是 Bitcask 的核心优势:读性能与数据量无关。

54.5 delete:删除

func delete(string key)
    if not index.has(key) then return end

    // 1. 写入墓碑记录
    string record = encodeRecord("DEL", key, "")
    file.appendText(dataFile, record)

    // 2. 从内存索引移除
    index.remove(key)
end

// 测试
db.delete("version")
assert db.get("version") == ""
assert not db.has("version")

删除不是"从文件中擦除"——而是追加一条 DEL 记录。恢复时,DEL 记录会覆盖之前的 PUT。这保证了日志的追加写语义不被破坏。

54.6 崩溃恢复

启动时重放日志,重建内存索引:

func recover()
    if not file.exists(dataFile) then return end

    string content = file.readText(dataFile)
    list<string> lines = string.splitLines(content)

    int i = 0
    while i < lines.len()
        string line = lines[i]
        if line.len() == 0 then
            i += 1
            // 跳过空行
        else
            array<string> parts = decodeRecord(line)
            if parts.len() >= 2 then
                string op = parts[0]
                string key = parts[1]
                if op == "PUT" and parts.len() >= 3 then
                    index[key] = parts[2]
                elif op == "DEL" then
                    index.remove(key)
                end
            end
            i += 1
        end
    end

    println("恢复完成: " + index.len().toString() + " 个键")
end

重放是幂等的:同一日志重放多次结果相同。最后一条 PUT/DEL 决定最终状态。

54.7 压缩(Compaction)

日志文件会不断增长(旧版本、已删除的数据仍占空间)。压缩将有效数据重写为新文件:

func compact()
    string tmpFile = dataFile + ".tmp"

    // 将当前有效数据写入临时文件
    string content = ""
    list<string> keys = index.keys()
    int i = 0
    while i < keys.len()
        string key = keys[i]
        content += encodeRecord("PUT", key, index[key])
        i += 1
    end
    file.writeText(tmpFile, content)

    // 原子替换:删除旧文件,重命名新文件
    file.delete(dataFile)
    file.rename(tmpFile, dataFile)

    println("压缩完成: 日志缩减为 " + keys.len().toString() + " 条记录")
end

// 定期压缩(如每 1000 次写入后)
int writeCount = 0
func putWithCompact(string key, string value)
    put(key, value)
    writeCount += 1
    if writeCount >= 1000 then
        compact()
        writeCount = 0
    end
end

压缩的效果:如果同一个 key 被 PUT 了 100 次,日志中有 100 条记录,压缩后只剩 1 条(最新值)。

54.8 TTL 过期

为键设置生存时间:

class KvStoreTtl
    dict<string, string> index
    dict<string, int64> expiry   // key → 过期时间戳

    func putTtl(string key, string value, int ttlMs)
        index[key] = value
        expiry[key] = time.now() + ttlMs
        // 日志中记录过期时间
        string record = "PUT\t" + key + "\t" + value + "\t" + expiry[key].toString() + "\n"
        file.appendText(dataFile, record)
    end

    func get(string key) as string
        if not index.has(key) then return "" end
        // 检查是否过期
        if expiry.has(key) and time.now() > expiry[key] then
            index.remove(key)
            expiry.remove(key)
            delete(key)   // 写入墓碑
            return ""
        end
        return index[key]
    end

    // 后台清理过期键
    func cleanExpired()
        int64 now = time.now()
        list<string> keys = expiry.keys()
        int i = 0
        while i < keys.len()
            if now > expiry[keys[i]] then
                delete(keys[i])
            end
            i += 1
        end
    end
end

54.9 完整实现

// kv.xl — 完整测试
KvStore db = KvStore()
db.open("test_kv.log")

// 基本操作
db.put("user:1", "alice")
db.put("user:2", "bob")
db.put("user:3", "charlie")
db.put("user:2", "bob_updated")

assert db.get("user:1") == "alice"
assert db.get("user:2") == "bob_updated"
assert db.get("user:3") == "charlie"

// 删除
db.delete("user:3")
assert db.get("user:3") == ""

// 模拟重启——重新打开
KvStore db2 = KvStore()
db2.open("test_kv.log")
assert db2.get("user:1") == "alice"
assert db2.get("user:2") == "bob_updated"
assert db2.get("user:3") == ""   // 已删除

// 压缩
db2.compact()

// 压缩后验证
KvStore db3 = KvStore()
db3.open("test_kv.log")
assert db3.get("user:1") == "alice"
assert db3.get("user:2") == "bob_updated"

println("所有测试通过!")

// 清理
file.delete("test_kv.log")

54.10 性能测试

// 写入性能
int64 start = time.now()
int i = 0
while i < 10000
    db.put("key:" + i.toString(), "value:" + i.toString())
    i += 1
end
int64 writeMs = time.now() - start
println("10000 次写入: " + writeMs.toString() + " ms")

// 读取性能
start = time.now()
i = 0
while i < 10000
    string v = db.get("key:" + i.toString())
    i += 1
end
int64 readMs = time.now() - start
println("10000 次读取: " + readMs.toString() + " ms")

// 典型结果(SSD):
// 10000 次写入: ~50 ms(追加写,顺序 I/O)
// 10000 次读取: ~2 ms(纯内存哈希查找)

深入细节

深入 1:为什么追加写比随机写快

机械硬盘:随机写需要寻道(~10ms),顺序写不需要(~0.01ms/条)。SSD:随机写触发写放大(GC),顺序写直接映射。追加写日志将随机 I/O 转化为顺序 I/O,是数据库性能优化的第一原则。MySQL 的 redo log、Kafka 的消息存储都是这个思路。

深入 2:Bitcask 的限制

Bitcask 要求所有键的索引能放入内存。如果有 10 亿个键,每个索引条目 50 字节,需要 50GB 内存——不现实。解决方案:① 分片(sharding);② 使用 LSM-Tree(LevelDB/RocksDB)将索引也放磁盘;③ 使用 B-Tree(PostgreSQL/InnoDB)。

深入 3:崩溃一致性

如果写入日志中途崩溃(只写了一半记录),恢复时会遇到损坏数据。解决方案:① 每条记录加 CRC 校验和,损坏记录截断;② 写入前加 fsync 确保落盘;③ 使用 WAL(Write-Ahead Log)+ 检查点。本章简化实现不处理此问题,但生产系统必须处理。

⚠️ 常见错误

❌ 错误 1:删除时直接修改日志文件

从文件中间删除一行需要移动后续所有数据——O(n) 操作,破坏追加写语义。正确做法:追加 DEL 墓碑记录,压缩时统一清理。

❌ 错误 2:恢复时不处理 DEL 记录

如果恢复只处理 PUT 不处理 DEL,已删除的键会"复活"。重放必须按顺序处理所有 PUT 和 DEL。

❌ 错误 3:压缩时丢失并发写入

压缩期间如果有新的 put 写入旧文件,替换后新数据丢失。正确做法:压缩期间加锁,或使用双缓冲(写入新文件,压缩旧文件)。

❌ 错误 4:值中包含分隔符

如果值中包含 \t 或 \n,文本格式会解析错误。解决方案:① 二进制格式(长度前缀);② 对值进行转义/编码(如 Base64)。

本章小结

主题核心要点
设计Append-Only Log + 内存哈希索引 = 写快读快
写入追加日志 + 更新索引,O(1)
读取纯内存哈希查找,O(1)
删除追加墓碑记录 + 移除索引
恢复重放日志,按序处理 PUT/DEL
压缩将有效数据重写为新文件,丢弃旧版本
TTL记录过期时间,读取时惰性检查 + 后台清理
限制所有键索引必须放入内存

复习题

  1. 为什么追加写比随机写快?这对 HDD 和 SSD 分别意味着什么?
  2. 删除操作为什么不能直接从文件中擦除数据?
  3. 墓碑记录(tombstone)的作用是什么?压缩时如何处理?
  4. 恢复时为什么必须按顺序重放所有记录?
  5. Bitcask 的内存限制是什么?10 亿个键需要多少内存?
  6. 压缩期间如何保证不丢失新写入的数据?
  7. TTL 的惰性检查和主动清理各有什么优缺点?
  8. 如果值中包含换行符,文本格式会出什么问题?如何解决?

编程练习

练习 1:二进制格式

将文本格式改为二进制格式:[4字节键长][4字节值长][1字节标志][键字节][值字节]。用 Buffer 和 file.appendBytes/file.readBytes 实现。验证读写正确。

参考思路:encode 用 Buffer 构建二进制帧;decode 按偏移量读取各字段。

练习 2:范围查询

在内存索引基础上实现范围查询:scan(prefix) 返回所有以 prefix 开头的键值对。结合第 53 章的 Trie 实现前缀扫描。

参考思路:维护一个 Trie 存储所有键;scan 时调用 Trie.wordsWithPrefix。

练习 3:HTTP KV 服务

将 KV 存储包装为 HTTP API:PUT /kv/{key} 写入,GET /kv/{key} 读取,DELETE /kv/{key} 删除。结合第 44 章 HTTP 服务器实现。

参考思路:http.server.create + routeRequest;req.body() 获取值,resp.text() 返回。