携手创作,共同成长!这是我参与「掘金日新计划 · 8 月更文挑战」的第9天,点击查看活动详情
开篇
高性能的 Key Value 存储在今天的互联网技术圈里几乎是一个人人都需要接触,了解的技术话题。很多时候我们不一定需要关系型数据库对于事务的支持,只需要一个简单的 KV 结构即可,顶多在此之上封装出来 List,Set 等丰富的数据结构。
如果可以,我们当然希望复用 redis 的能力,毕竟其高性能在业界已经反复验证,但无奈内存太贵。在大数据时代,业务场景往往需要我们的 KV 存储支撑至少达到几十 TB 的数据,用 redis 来存会带来昂贵的成本,而且很多时候我们对低延时的要求并没有那么高。
这些年来,大厂基于一些成熟的 B+树 或 LSM树 来封装自己的 KV 存储引擎越来越常见。两种模型各有利弊,这里直接引用 nutsdb 作者佳军老师的观点:
基于B+ tree的模型相对后者成熟。一般使用覆盖页的方式和WAL(预写日志)来作崩溃恢复。而LSM tree的模型他是先写log文件,然后在写入MemTable内存中,当一定的时候写回SSTable,文件会越来越多,于是他一般作法是在后台进行合并和压缩操作。 一般来说,基于B+ tree的模型写性能不如LSM tree的模型。而在读性能上比LSM tree的模型要来得好。当然LSM tree的模型也可以优化,比如引入BloomFilter。
简单说,LSM树 这一类模型大都利用了顺序写日志的思想,写入方面可以大幅度提升性能,但是对于 range 这一类范围 scan 就不好支持,此外还需要处理冗余日志的删除合并。B+ 树虽然写入上差一些,但是读的时候性能更好,也支持范围 scan。
今天我们选择了一种 LSM Tree 的变形 Bitcask,参照官方论文来学习一下它的设计思路和原理。
Bitcask
在设计之处,Bitcask 是为了支持 Riak (以 Erlang 编写的一个高度可扩展的分布式数据存储)而设计的存储引擎。官方提出的设计目标如下:
- 读写都能低延时;
- 随机写入也能高吞吐;
- 支持比内存 RAM 大的多的数据存储;
- 宕机后可以快速恢复,且不丢数据;
- 便于备份和恢复;
- 简单,易于理解的代码和数据结构;
- 高负载下的行为可以预测。
从定义上看,作者把 bitcask 归为【A Log-Structured Hash Table】,这与 LSM Tree 的定义很像【The Log-Structured Merge-Tree】。
Log-Structure 本身并不稀奇,顺序写日志,合并清理冗余日志,能有效提升写性能是普遍应用的思想,核心在于这个 Hash Table,到底做了什么,又怎样支撑起官方的目标的。
下面我们来结合架构设计详细看看。
架构设计
实例
一个 Bitcask 实例就是一个目录,要求在任何时候都只能有一个系统进程往目录中写入数据。这个进程就类似一个数据库 server 的作用
active vs older
- 实例目录内的文件分为两类:active 和 older;
- 在任意时刻,实例目录中只会有一个 active 文件,如果把写入数据的进程叫做server,你可以理解为只有这一个 active 文件会承接来自 server 的写入请求;
- 当 active 文件过大,超过配置的 threshold 后,bitcask 就会把 active 文件关闭,并创建一个新的 active 文件,而被关闭的文件就变成了 older,或者说 immutable 的文件,此后永远不会重新接受写入请求;
- active 文件的写入只有【顺序写】,这样避免了磁盘寻址,提高了写入性能。
Entry 格式
一个 Entry 就代表了一个键值对的数据,格式如下:
- crc:Cyclic Redundancy Check / 循环冗余校验,是一种根据文件数据产生简短固定位数校验码的一种信道编码技术,主要用来检测或校验数据传输或者保存后可能出现的错误;
- tstamp:32位整形时间戳;
- ksz:key 的大小;
- value_sz: value 的大小;
- 最后两部分就是 key 和 value 的值。
文件内容
前面有提过,bitcask 只允许一个系统进程来顺序写入一个active文件,其实我们正常增删改操作里面,不仅仅 insert 是用插入一条新记录,甚至包括 delete 都是通过插入一条新的日志来实现的。
所以,一个 bitcask 实例中的文件内容看起来是这个样子:
Hash Table
重点来了!顺序写日志不新鲜,bitcask 的特色就在于这个 hash table。
当一次写请求过来,顺序写日志到 active file 结束后,对于调用方来说要怎么感知到呢?
bitcask 在内存里维护了一个 keydir 的结构,本质就是一个哈希表。它维护的 key 就是业务语义的 key,但value就是重点了,它是一个固定大小的结构,记录了 key 在哪个文件(file_id),位置(value_pos),最近一次更新的 entry 大小(value_sz)。
当接收到一个写请求时,写完日志,keydir 中对应 key 的 value 就会被更新为最新的 entry 数据。旧数据依然存在,但所有后续进来的读请求都会走 keydir 中最新的数据版本。
那老的数据怎么办呢?这里 bitcask 采用的是异步通过 merge 流程来清理。
读请求处理
发挥 keydir 作用的地方来了,我们既然在 keydir 里维护了 key 对应的 entry 地址,就可以做到用一次内存读取就知道我们要的 value 在磁盘上的位置。
- 从 keydir 中找到 key,从这里我们知道目标在哪个文件,什么offset,多大;
- 根据第一步的信息,到磁盘上捞出来目标值返回。
(注:这里因为有文件系统的 read-ahead cache,其实读磁盘速度比想象的要快)
这里需要注意一下,虽然我们写是走的 active file,但这只是代表了当前能接受写入的文件,实际读请求很有可能走到 old file 上(比如一个key长期不更新),并不代表 old 都是冗余的。
清理旧数据
回忆一下,来写请求了我们就顺序写日志,然后更新内存,来读请求了就直接从内存找地址,然后一次读磁盘返回数据。这样的模型存在什么问题呢?
直觉上可以想到:
- 一个是内存能放多少 key 呢?这会决定 bitcask 能支持的最大键值对数量,毕竟是一个嵌入式的存储。
- 另一个在于怎么清理 old/immutable 文件,由于我们提供的只是个 KV 存储,不需要 MVCC 这类语义,保留最新的数据版本即可。目前的模型会产生大量无用的历史记录。
这一节我们来看看 bitcask 是怎么做 merge 的。一般 LSM Tree 这个操作会叫做 compaction,压缩,其实我觉得后者更好。只是命名,大家不用太过于纠结。
merge 流程如下:
- 遍历当前实例中的所有 old 文件,比如 10个;
- 经过一些算法,计算出冗余的历史 entry,将其丢弃,产出一组新的 data file,这组文件只包含所有 key 最新的版本(由于经过合并,这里文件数量会小很多,比如3个),这组文件我们称之为 merge data file;
- 针对每个 merge data file,都生成一个 hint file,作为一个目录/索引,只包含 value 的位置,大小等,不包含实际 value值。
这个 hint file 是用来干什么呢?
其实就是以空间换时间。既然我们实际的数据是放在磁盘上,靠内存里的 keyDir 来索引。那么就意味着构建 keyDir 的过程非常关键,如果有了 hint file,我们构建就会快很多,相当于将索引物化了一份。
冷启动
回忆一下,在实例目录下,我们有一个 active file 和一堆 old file,如果是被 merge 过的,还会有与其匹配的 hint file(里面没data,只是索引)。
冷启动时其实做的很简单,扫描实例这个目录下的所有 data file 来构建内存中的 keyDir 这个哈希表,如果发现某个 data file 有对应的 hint file,就直接使用,无需复杂计算。
额外的说明
- bitcask 是基于操作系统的文件系统来做cache的,并没有在 bitcask 内部做复杂的缓存机制(作者觉得收益不一定明显);
- bitcask 并不追求【最快】,对初创团队来说,代码的简洁和可阅读性也很重要,只要【足够快】即可;
- bitcask 并不会对数据进行压缩,因为由此而来的收益是非常依赖业务场景的;
The tests mentioned above used a dataset of more than 10×RAM on the system in question, and showed no sign of changed behavior at that point. This is consistent with our expectations given the design of Bitcask.
官方提出的这一点可以作为参考,性能上看,至少 10倍 RAM 的数据是没问题的,没有出现显著衰退。
目标 vs 设计
我们来看看 bitcask 这一套设计是如何满足一开始提出的目标的,这里我们先忽略性能的部分,感兴趣的同学可以直接看官方的 benchmark 结果,我们这里重在看机制:
- 宕机后可以快速恢复,且不丢数据:
回想一下 MySQL 是怎么做的?因为是 WAL,我们有 redo log 就可以用来恢复。但 Bitcask 不同,它本身就没有拆分 commit log 和数据本身,在Bitcask这里他们是一个东西。所以,只要写日志成功,就算ok了,已经写了磁盘,即便重启也能找到。只是需要时间来重建内存里的 keyDir 罢了。
- 便于备份和恢复:
我们只有一个文件接收写请求,其他的全是 immutable的,所以想做恢复或者备份,无非是把 data file 拷贝到一个新的目录,非常简单。
- 简单,易于理解的代码和数据结构;
这一点不用多说,从笔者经验看,这可能是上生产环境的最简单的存储模型,可读性还是很强的。业界基于 bitcask 进行扩展实现的存储引擎也有不少,感兴趣的同学推荐大家看看 nutsdb 和 rosedb 学习一下。
总结
bitcask 运用了顺序日志写 + 内存索引来实现存储引擎,整体思路很直接,非常适合大家练手。写入操作变成了一次磁盘IO,而且是顺序写,这也保障了高性能。读方面依赖文件系统的缓存来加速访问,需要一次随机IO。
使用中一个痛点在于没法支持 range scan,这也是 B+树模型的优势。nutsdb 就是基于 bitcask + B+树来进行扩展。感兴趣的同学可以自己操练一下,细化相关思路的细节。比如 merge 操作的时机,这个很有意思,当写入和merge并行的时候,两个顺序IO并发起来,反倒成了随机IO,性能会下降。官方的论文并没有涉及相关的细节,大家可以思考一下怎么解决。
总的来说,Bitcask 作为一个直观,可理解的简单模型,值得学习。