【读】Bitcask - A Log-Structured Hash Table for Fast Key/Value Data

介绍

Bitcask 是一个单机 KV 存储引擎,项目起因于 Riak 分布式 KV 数据库需要一个能满足以下条件的单机 KV 存储引擎:

  • 低延迟的单条读写
  • 高吞吐,尤其是面对流式随机 KV 写入
  • 能支持远比内存大的数据量
  • 能从崩溃中快速恢复以及不丢失数据
  • 能轻松的备份和还原数据
  • 相对简单,易理解的代码结构和数据格式
  • 在高负载和大数据场景下系统的行为是可预期的
  • 软件的许可证要能轻易的适配 Riak 使用

作者看了一圈发现市面上还没有一款 KV 存储能全部满足这些条件,因此 Bitcask 就应运而生。

API

Bitcask 的接口非常精简:

接口 描述
bitcask:open(DirectoryName, Opts) -> BitCaskHandle | {error, any()} 在指定目录下以指定选项打开或新建一个 Bitcask 实例。支持的选项包括 read_write 或者 sync_on_put
  • read_write:可读可写
  • sync_on_put:每次写操作后刷盘
连接的进程需要有 DirectoryName 对应目录的读写权限,同时一个时刻只能有一个进程以 read_write 的方式打开 Bitcask 实例。
bitcask:open(DirectoryName) -> BitCaskHandle | {error, any()} 在指定目录下以只读模式打开或新建一个 Bitcask 实例。连接的进程需要有 DirectoryName 对应目录及其内部所有文件的读权限。
bitcask:get(BitCaskHandle, Key) -> not_found | {ok, Value} 获取指定键对应的值。
bitcask:put(BitCaskHandle, Key, Value) -> ok | {error, any()} 插入一个键值对。
bitcask:delete(BitCaskHandle, Key) -> ok | {error, any()} 删除指定键。
bitcask:list_keys(BitCaskHandle) -> [Key] | {error, any()} 返回所有的键。
bitcask:fold(BitCaskHandle, Fun, Acc0) -> Acc 对每一个键值对应用 Fun 函数,Fun 的函数签名为 F(K, V, Acc0) -> Acc。类似于 JavaScriptreduce
bitcask:merge(DirectoryName) -> ok | {error, any()} 合并目录下的数据文件以减少重复的键值对。同时生成 hintfile 辅助加速程序启动时间。
bitcask:sync(BitCaskHandle) -> ok 强制刷盘。
bitcask:close(BitCaskHandle) -> ok 关闭 Bitcask 实例的连接并刷盘。

存储

文件组织

Bitcask 的文件组织非常简单,任意时刻目录下最多只有一个 active data file 接收写操作,其余都是不可修改的历史数据文件。任意时刻 Bitcask 只允许一个进程以写模式建立连接,写入进程只会向 active data file 写入,当其大小超过指定阈值后就会关闭当前文件,然后新建一个 active data file

alt

数据格式

写入进程以追加写的方式写入 active data file,从而避免了随机写的磁盘寻址,其写入数据格式如下:

alt

  • crc:循环冗余校验码,验证数据完整性
  • tstamp:32位整型本地时间戳,仅内部使用,不对外暴露
  • ksz:键的长度
  • value_sz:值的长度
  • key:键的内容
  • value:值的内容

对于每一条记录,前面四个部分都是定长,以此为基址 base,则 basebase + ksz 就是键的内容,base + kszbase + ksz + value_sz 就是值的内容。

如果要删除指定的键,Bitcask 会再次追加写入一个键值对,只不过写入的值是一个特殊值,程序后续读到这条记录时比较值的内容就可以判断该条记录是否已被删除。所以,Bitcask 每个文件内容就是一行行的记录:

alt

读写

Bitcask 写入的同时会在内存中维护写入的键到数据文件的映射(keydir):
alt

其中 file_id 能够定位具体的数据文件,value_pos 是该条记录的值在文件中的起始偏移位置,那么 value_posvalue_pos + value_sz 就是值的内容。因为 Bitcask 每次写数据的长度是可知的,值在每条记录中的偏移量可知,写之前 active data file 文件总长也可知,所以 value_pos 也能够推算出来。

对于重复写入的键值对,磁盘上会存在同一个键的多条记录,但是 keydir 中始终只保留最新的映射。

读取时先根据键查询 keydir 得到数据文件的映射,然后根据 file_id 定位数据文件,最后根据 value_posvalue_sz 返回值的内容,整个读取只涉及一次磁盘寻址。另一方面,文件系统的 read-ahead 缓存会进一步减少磁盘的交互:

alt

数据合并

由于 Bitcask 追加写的特性,有两种类型的数据是冗余的:

  • 被标记删除的数据
  • 同一个键的旧版本的数据

所以,为了避免磁盘空间的浪费,需要额外的数据合并操作对磁盘上的数据瘦身。数据合并只处理只读的数据文件,遍历剔除掉已删除和旧版本的数据。另外,每一个合并后的数据文件同时额外有一个对应的 hint file

alt

hint file 也是行记录的文件,每一行存储了:

  • tstamp:时间戳
  • ksz:键的长度
  • value_sz:值的长度
  • value_pos:值在数据文件中的起始地址偏移量
  • key:键

当程序启动时,如果 hint file 存在,那么就可以直接扫描 hint file 构建 keydir 从而加速程序启动;反之,则要扫描数据文件。

性能数据

原文并没有给出非常正规的测试报告,仅列出了一些早期未优化的测试数据:

  • 读写延迟:毫秒内
  • 写吞吐:5000~6000次/秒
  • 内存占用:几百万的键在 1GB 内(keydir 需要)

总结

Bitcask 的整体设计思路非常简单,其设计目的也不是为了成为最快的 KV 存储,而是最适合 Riak 的存储引擎,在足够快的同时有着高质量、简洁的代码,设计和数据格式。

参考