CHAPTER 02
Bitcask 论文详解
本章为理论基础,不涉及代码实现 | 论文:Bitcask — A Log-Structured Hash Table for Fast Key/Value Data(Riak, 2010)
1. 论文背景
Bitcask 最初由 Riak 团队在 2010 年提出,作为 Riak 分布式数据库的底层存储引擎之一。论文非常短(只有 6 页),但提出了一个极其精巧的设计。
论文开头提出了对一个 KV 存储引擎的期望:
- 读写低延迟
- 高吞吐量,尤其是随机写入场景
- 能处理远超内存容量的数据集
- 崩溃恢复友好,快速恢复且不丢数据
- 简单的备份和恢复策略
- 相对简单、易于理解的代码结构
Bitcask 的设计目标就是同时满足以上所有要求。让我们来看看它是怎么做到的。
2. 核心数据模型
Bitcask 的核心思路可以用一句话概括:
所有写入操作追加到日志文件,内存中维护一个 Key → 磁盘位置 的哈希索引。
这个设计带来了两个关键优势:
- 写入:永远是顺序追加(append-only),不需要随机写磁盘,写入速度接近磁盘顺序写的理论极限。
- 读取:先查内存索引拿到磁盘位置,然后一次精确的磁盘随机读,最多一次 IO。
磁盘上的数据文件
Bitcask 实例就是一个目录,目录中包含多个数据文件(data file)。在任意时刻,只有一个"活跃文件"(active file)接受写入,其余的都是"旧文件"(older file),只读不写。
当活跃文件的大小达到阈值时,它会被关闭变成旧文件,同时创建一个新的活跃文件。
日志记录格式
每条数据写入磁盘时,都编码成一条 LogRecord(日志记录),格式如下:
CRC32
4 字节
Type
1 字节
Key Size
变长
Value Size
变长
Key
实际数据
Value
实际数据
- CRC32:校验和,用于检测数据是否损坏
- Type:记录类型(正常写入 / 删除标记 / 事务结束标记)
- Key Size / Value Size:Key 和 Value 的长度
- Key / Value:实际的键值数据
为什么删除也是"写入"?
因为 Bitcask 是追加写入(append-only)的设计,不能直接在文件中"擦除"某条数据。删除操作本质上是写入一条特殊的标记记录(tombstone),表示"这个 Key 已被删除"。过期数据会在后续的 Merge 过程中被真正清理。
3. 内存索引(KeyDir)
论文中把内存索引称为 KeyDir。它是一个哈希表,存储每个 Key 对应的数据在磁盘上的位置信息:
KeyDir: Key → { file_id, offset, size }
file_id:数据所在的文件编号
offset:数据在该文件中的起始偏移量
size:数据记录的总长度
有了 KeyDir,读取流程非常简洁:
- 根据 Key 在 KeyDir 中查找,得到
(file_id, offset, size)
- 打开对应的数据文件,在
offset 处读取 size 字节
- 解码得到 Value,返回
KeyDir 必须全部存放在内存中,这是 Bitcask 的核心约束。这意味着所有 Key 的总量不能超过可用内存。Value 可以很大(因为它存在磁盘上),但 Key 的数量受限于内存。
4. 读写流程
写入流程(Put)
- 将 Key-Value 编码成 LogRecord
- 追加写入当前活跃文件
- 更新内存索引(KeyDir),将 Key 映射到新的磁盘位置
注意:如果是覆盖写(Key 已存在),旧数据不会被删除,只是内存索引指向了新位置。旧数据变成"垃圾",等待 Merge 清理。
读取流程(Get)
- 在内存索引中查找 Key,获取
(file_id, offset)
- 根据 file_id 定位到对应的数据文件
- 在 offset 处读取数据,解码出 Value
删除流程(Delete)
- 写入一条类型为"删除"的 LogRecord(tombstone)
- 从内存索引中移除该 Key
5. Merge 合并过程
由于 Bitcask 是追加写入的,覆盖写和删除都不会真正移除旧数据,磁盘上会积累大量"垃圾"数据。Merge(合并)就是用来回收这些空间的。
Merge 的流程:
- 遍历所有旧数据文件中的每条记录
- 对每条记录,检查内存索引 —— 如果该 Key 的最新位置指向当前记录,说明它是有效数据
- 将有效数据写入新的数据文件
- 同时生成一个 Hint File(索引提示文件),记录每个 Key 在新文件中的位置
- Merge 完成后,删除旧的数据文件
Hint File 的作用
正常启动时,Bitcask 需要遍历所有数据文件来重建内存索引,这在数据量大时非常慢。Hint File 只包含 Key 和位置信息(不含 Value),文件体积远小于数据文件。启动时优先读取 Hint File,可以大幅加速索引重建。
6. 启动恢复
当 Bitcask 实例启动(或崩溃后重启)时,需要重建内存索引。流程如下:
- 扫描数据目录,找到所有数据文件,按文件编号排序
- 如果存在 Hint File,优先从 Hint File 中加载索引(速度快)
- 对于没有 Hint File 覆盖的数据文件,逐条读取 LogRecord,根据记录内容重建索引
- 最后一个文件设为活跃文件,继续接受写入
崩溃安全性
由于是追加写入,即使写到一半崩溃,也只会丢失最后那条未完整写入的记录。之前已经写入的数据都是完整的。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 论文的核心设计可以归纳为:
- 追加写入:所有数据都追加到日志文件末尾,写入性能接近磁盘顺序写的极限。
- 内存索引:KeyDir 哈希表存储每个 Key 的磁盘位置,读取只需一次 IO。
- Merge 合并:定期清理过期数据,生成 Hint File 加速启动恢复。
- 崩溃安全:追加写入 + CRC 校验,天然具备崩溃恢复能力。
理解了这些概念,你就掌握了 Bitcask 的全貌。下一章我们来搭建开发环境,为编码做好准备。