原文地址: https://github.com/basho/bitcask/blob/develop/doc/bitcask-intro.pdf
Bitcask的起源与Riak分布式数据库的历史息息相关。在Riak的键/值集群中,每个节点都使用可插拔的本地存储;几乎所有k/v型的东西都可以作为每个主机的存储引擎。这种可插拔性使得Riak的进展可以并行化,这样就可以在不影响其他代码库的情况下对存储引擎进行改进和测试。
许多这样的本地键/值存储已经存在,包括但不限于 Berkeley DB、Tokyo Cabinet 和 Innostore。 在评估此类存储引擎时,我们寻求的目标有很多,包括:
- 读取或写入的每项延迟低
- 高吞吐量,尤其是在写入随机项目的传入流时
- 能够处理比 RAM 大得多的数据集而不会降级
- 崩溃友好性,无论是在快速恢复还是在不丢失数据方面
- 易于备份和恢复
- 相对简单、易于理解(因此可支持)的代码结构和数据格式
- 重访问负载或大容量下的可预测行为
- 允许在 Riak 中轻松默认使用的协议
实现其中一些很容易。 实现所有这些就没那么简单了。
就上述所有目标而言,没有一个可用的本地键/值
存储系统(包括但不限于作者编写的系统)是理想的。 我们正在与 Eric Brewer 讨论这个问题,当时他对哈希表日志合并有一个关键的见解:这样做可能会比 LSM 树更快或更快。
这促使我们以全新的视角探索 1980 年代和 90 年代首次开发的日志结构文件系统中使用的一些技术。 这种探索导致了 Bitcask 的开发,这是一个很好地满足上述所有目标的存储系统。 虽然 Bitcask 最初是为了在 Riak 下使用而开发的,但它被构建为通用的,也可以作为其他应用程序的本地键/值存储。
我们最终采用的模型在概念上非常简单。 Bitcask 实例是一个目录,我们强制只有一个操作系统进程会在给定时间打开该 Bitcask 进行写入。 您可以将该进程有效地视为”数据库服务器”。 在任何时刻,该目录中都有一个文件处于”活动”状态,供服务器写入。 当该文件达到大小阈值时,它将被关闭并创建一个新的活动文件。 一旦文件被关闭,无论是有意的还是由于服务器退出,它都被认为是不可变的并且永远不会被打开以再次写入。
活动文件仅通过追加写入,这意味着顺序写入不需要磁盘搜索。 为每个键/值条目编写的格式很简单:
每次写入时,都会将一个新条目附加到活动文件中。 请注意,删除只是写入一个特殊的逻辑删除值,它将在下一次合并时被删除。 因此,Bitcask 数据文件只不过是这些条目的线性序列:
追加完成后,一个名为 “keydir” 的内存结构被更新。 keydir 只是一个哈希表,它将 Bitcask 中的每个键映射到一个固定大小的结构,给出该键的最近写入条目的文件、偏移量和大小。
发生写入时,keydir 会自动更新为最新数据的位置。 旧数据仍然存在于磁盘上,但任何新读取都将使用 keydir 中可用的最新版本。 正如我们稍后将看到的,合并过程最终会删除旧值。
读取一个值很简单,只需要一次磁盘寻道。 我们在 keydir 中查找密钥,然后使用从该查找返回的文件 ID、位置和大小从那里读取数据。 在许多情况下,操作系统的文件系统预读缓存使这个操作比预期的要快得多。
随着时间的推移,这个简单的模型可能会占用大量空间,因为我们只是写出新值而没有触及旧值。 我们称之为”合并”的压缩过程解决了这个问题。 合并过程遍历 Bitcask 中的所有非活动(即不可变)文件,并生成一组数据文件作为输出,仅包含每个当前密钥的”实时”或最新版本。
完成后,我们还在每个数据文件旁边创建一个”提示文件”。 这些本质上类似于数据文件,但它们包含相应数据文件中值的位置和大小而不是值。
当一个 Bitcask 被一个 Erlang 进程打开时,它会检查在同一 VM 中是否已经有另一个 Erlang 进程正在使用该 Bitcask。 如果是这样,它将与该进程共享 keydir。 如果没有,它会扫描目录中的所有数据文件以构建一个新的 keydir。 对于任何具有提示文件的数据文件,将扫描该文件以加快启动时间。
这些基本操作是Bitcask系统的本质。很明显,我们并没有试图在这篇文档中揭示操作的每一个细节;我们的目标是帮助你理解Bitcask的一般机制。我们可能需要对一些我们忽略的领域做一些补充说明:
- 我们提到我们依赖操作系统的文件系统缓存来提高读取性能。 我们也讨论过添加一个 bitcask 内部读取缓存,但考虑到我们现在免费获得了多少便利,尚不清楚是否值得这样做。
- 我们将很快针对各种类似于 API 的本地存储系统提供基准测试。 然而,我们对 Bitcask 的最初目标不是成为最快的存储引擎,而是获得 “足够” 的速度以及代码、设计和文件格式的高质量和简单性。 也就是说,在我们最初的简单基准测试中,我们发现 Bitcask 在许多情况下轻松胜过其他快速存储系统。
- 一些最难的实现细节也是大多数局外人最不感兴趣的,所以我们没有在这个简短的文档中包含(例如)内部 keydir 锁定方案的描述。
- Bitcask 不执行任何数据压缩,因为这样做的成本/收益非常依赖于应用程序。
让我们看看我们出发时的目标:
- 每个项目的读取或写入的低延迟 Bitcask很快速。我们计划很快做更彻底的基准测试,但在我们的早期测试中,典型的中位延迟为亚毫秒(以及相当充分的更高百分比),我们相信它可以满足我们的速度目标。
- 高吞吐量,特别是在写入随机项目流的时候 在早期的测试中,我们在一台有慢速磁盘的笔记本电脑上看到了每秒5000-6000次的写入量。
- 处理比RAM大得多的数据集的能力,而不至于退化 上述测试在有关系统上使用了超过10×RAM的数据集,并且在这一点上没有显示出行为改变的迹象。鉴于Bitcask的设计,这与我们的预期一致。
- 崩溃友好性,在快速恢复和不丢失数据方面都是如此 由于数据文件和提交日志在Bitcask中是一样的,恢复是很简单的,不需要 “重放”。提示文件可以用来使启动过程变得快速。
- 易于备份和恢复 由于文件在翻转后是不可改变的,所以备份可以轻松地使用操作者喜欢的任何系统级机制。恢复只需要将数据文件放在所需的目录中。
- 一个相对简单的、可理解的(从而可支持的)代码结构和数据格式 Bitcask概念简单,代码干净,数据文件非常容易理解和管理。我们对支持一个建立在Bitcask之上的系统感到非常舒服。
- 在繁重的访问负荷或大量的访问量下,可预测的行为
在繁重的访问负载下,我们已经看到Bittask表现良好。到目前为止,它只看到了两位数的数据量,但我们很快就会用更多的数据来测试它。Bitcask的形状是这样的,我们不指望它在更大的容量下有什么不同的表现,但有一个可以预见的例外,那就是keydir结构会随着键的数量增加而少量增长,必须完全放在RAM中。这个限制在实践中是很小的,因为即使有几百万个钥匙,目前的实现也只用了不到1GB的内存。
总之,考虑到这组特定的目标,Bitcask比其他任何可用的东西更适合我们的需要。
API非常简单:
API | 描述 |
---|---|
open(DirectoryName, Opts) → BitCaskHandle | {error, any()} | 打开一个新的或现有的 Bitcask 数据存储,并增加选项。有效的选项包括读写(如果这个进程将要进行修改,而不仅仅只读)和写入时同步(如果写入希望在每次写操作后同步写文件)。该目录必须是该进程可读可写的,而且每次只能有一个进程用读写方式打开一个 Bitcask |
open(DirectoryName) → BitCaskHandle | {error, any()} | 打开一个新的或现有的 Bitcask 数据存储,进行只读访问。该目录和其中的所有文件必须是可以被这个进程读取的 |
get(BitCaskHandle, Key) → not_found | {ok, Value} | 从 Bitcask 数据存储中按键检索一个值 |
put(BitCaskHandle, Key, Value) → ok | {error, any()} | 存储一个键值对 |
delete(BitCaskHandle, Key) → ok | {error, any()} | 删除一个键值对 |
list_keys(BitCaskHandle) → [Key] | {error, any()} | 展示 Bitcask 中所有的键 |
fold(BitCaskHandle,Fun,Acc0) → Acc | 聚合 Bitcask 数据存储中的所有K/V对。我们希望Fun的形式是。F(K,V,Acc0) → Acc. |
merge(DirectoryName) → ok | {error, any()} | 将 Bitcask 数据存储中的几个数据文件合并成一个更紧凑的形式。同时,产生 hintfiles ,以加快启动速度 |
sync(BitCaskHandle) → ok | 强制任何写入的内容同步到磁盘 |
close(BitCaskHandle) → ok | 关闭一个 Bitcask 数据存储,并将所有等待的写入(如果有的话)冲到磁盘 |
正如你所看到的,Bitcask使用起来相当简单。享受吧!