CHAPTER 02

Bitcask 论文详解

本章为理论基础,不涉及代码实现  |  论文:Bitcask — A Log-Structured Hash Table for Fast Key/Value Data(Riak, 2010)

1. 论文背景

Bitcask 最初由 Riak 团队在 2010 年提出,作为 Riak 分布式数据库的底层存储引擎之一。论文非常短(只有 6 页),但提出了一个极其精巧的设计。

论文开头提出了对一个 KV 存储引擎的期望:

  1. 读写低延迟
  2. 高吞吐量,尤其是随机写入场景
  3. 能处理远超内存容量的数据集
  4. 崩溃恢复友好,快速恢复且不丢数据
  5. 简单的备份和恢复策略
  6. 相对简单、易于理解的代码结构

Bitcask 的设计目标就是同时满足以上所有要求。让我们来看看它是怎么做到的。

2. 核心数据模型

Bitcask 的核心思路可以用一句话概括:

所有写入操作追加到日志文件,内存中维护一个 Key → 磁盘位置 的哈希索引。

这个设计带来了两个关键优势:

磁盘上的数据文件

Bitcask 实例就是一个目录,目录中包含多个数据文件(data file)。在任意时刻,只有一个"活跃文件"(active file)接受写入,其余的都是"旧文件"(older file),只读不写。

当活跃文件的大小达到阈值时,它会被关闭变成旧文件,同时创建一个新的活跃文件。

日志记录格式

每条数据写入磁盘时,都编码成一条 LogRecord(日志记录),格式如下:

CRC32
4 字节
Type
1 字节
Key Size
变长
Value Size
变长
Key
实际数据
Value
实际数据
为什么删除也是"写入"?
因为 Bitcask 是追加写入(append-only)的设计,不能直接在文件中"擦除"某条数据。删除操作本质上是写入一条特殊的标记记录(tombstone),表示"这个 Key 已被删除"。过期数据会在后续的 Merge 过程中被真正清理。

3. 内存索引(KeyDir)

论文中把内存索引称为 KeyDir。它是一个哈希表,存储每个 Key 对应的数据在磁盘上的位置信息:

KeyDir: Key → { file_id, offset, size }

有了 KeyDir,读取流程非常简洁:

  1. 根据 Key 在 KeyDir 中查找,得到 (file_id, offset, size)
  2. 打开对应的数据文件,在 offset 处读取 size 字节
  3. 解码得到 Value,返回
KeyDir 必须全部存放在内存中,这是 Bitcask 的核心约束。这意味着所有 Key 的总量不能超过可用内存。Value 可以很大(因为它存在磁盘上),但 Key 的数量受限于内存。

4. 读写流程

写入流程(Put)

  1. 将 Key-Value 编码成 LogRecord
  2. 追加写入当前活跃文件
  3. 更新内存索引(KeyDir),将 Key 映射到新的磁盘位置

注意:如果是覆盖写(Key 已存在),旧数据不会被删除,只是内存索引指向了新位置。旧数据变成"垃圾",等待 Merge 清理。

读取流程(Get)

  1. 在内存索引中查找 Key,获取 (file_id, offset)
  2. 根据 file_id 定位到对应的数据文件
  3. 在 offset 处读取数据,解码出 Value

删除流程(Delete)

  1. 写入一条类型为"删除"的 LogRecord(tombstone)
  2. 从内存索引中移除该 Key
Client Memory Index (KeyDir) Active File Older Files Disk Put update index Get (1) read (2) rotate

5. Merge 合并过程

由于 Bitcask 是追加写入的,覆盖写和删除都不会真正移除旧数据,磁盘上会积累大量"垃圾"数据。Merge(合并)就是用来回收这些空间的。

Merge 的流程:

  1. 遍历所有旧数据文件中的每条记录
  2. 对每条记录,检查内存索引 —— 如果该 Key 的最新位置指向当前记录,说明它是有效数据
  3. 将有效数据写入新的数据文件
  4. 同时生成一个 Hint File(索引提示文件),记录每个 Key 在新文件中的位置
  5. Merge 完成后,删除旧的数据文件
Hint File 的作用
正常启动时,Bitcask 需要遍历所有数据文件来重建内存索引,这在数据量大时非常慢。Hint File 只包含 Key 和位置信息(不含 Value),文件体积远小于数据文件。启动时优先读取 Hint File,可以大幅加速索引重建。
Old File 1 Old File 2 Old File 3 filter valid Merge check index New Data File Hint File delete after merge

6. 启动恢复

当 Bitcask 实例启动(或崩溃后重启)时,需要重建内存索引。流程如下:

  1. 扫描数据目录,找到所有数据文件,按文件编号排序
  2. 如果存在 Hint File,优先从 Hint File 中加载索引(速度快)
  3. 对于没有 Hint File 覆盖的数据文件,逐条读取 LogRecord,根据记录内容重建索引
  4. 最后一个文件设为活跃文件,继续接受写入
崩溃安全性
由于是追加写入,即使写到一半崩溃,也只会丢失最后那条未完整写入的记录。之前已经写入的数据都是完整的。CRC32 校验和可以检测出损坏的记录,启动恢复时跳过即可。

7. Bitcask 的优势与局限

优势

• 写入极快(顺序追加)
• 读取可预测(一次磁盘 IO)
• 崩溃恢复简单可靠
• 实现简单,代码量小
• 备份简单(拷贝目录即可)

局限

• 所有 Key 必须放入内存
• 需要定期 Merge 回收空间
• 不支持范围查询(原始论文)
• 启动时需要重建索引(大数据集较慢)

关于范围查询
原始 Bitcask 论文使用哈希表作为内存索引,所以不支持范围查询。但在我们的实现中,会使用 BTree 等有序数据结构作为索引,从而支持按 Key 范围遍历。这是我们对论文的一个实用增强。

8. 与课程实现的对应关系

了解了论文的设计,我们来看看后续各章与论文概念的对应关系:

论文概念课程章节说明
文件 IO、KeyDir(索引)第 4 章内存和磁盘设计
Put / Get第 5 章数据读写流程
启动恢复第 6 章数据库启动流程
Delete(tombstone)第 7 章数据删除流程
Data File 管理第 8 章数据文件逻辑
LogRecord 编解码第 9 章编码格式实现
Close / Sync / Iterator第 10 章关闭、持久化、迭代器
原子写入第 11 章WriteBatch 事务
Merge + Hint File第 12 章数据清理与合并
KeyDir 优化(ART, B+Tree)第 13 章多种索引实现

本章小结

Bitcask 论文的核心设计可以归纳为:

  1. 追加写入:所有数据都追加到日志文件末尾,写入性能接近磁盘顺序写的极限。
  2. 内存索引:KeyDir 哈希表存储每个 Key 的磁盘位置,读取只需一次 IO。
  3. Merge 合并:定期清理过期数据,生成 Hint File 加速启动恢复。
  4. 崩溃安全:追加写入 + CRC 校验,天然具备崩溃恢复能力。

理解了这些概念,你就掌握了 Bitcask 的全貌。下一章我们来搭建开发环境,为编码做好准备。