由于内存的易失性,所以不少信息需要保存于硬盘中。同时由于内存及硬盘的速度的差异,需要有很多方式来折衷硬盘和内存的特性。比如对于硬盘的随机写及fsync等操作,我们要尽量避免,所以现代很多存储引擎基本都是使用顺序写的日志作为内存数据的恢复手段,rocksdb也不例外。
为了防止崩溃/掉电等原因引起的内存数据丢失,所以我们在写入数据之前需要先顺序写一份日志,该日志称之为WAL(Write Ahead Logging)日志。同时在rocksdb中,为了恢复磁盘上数据的一致性状态,又引入了Version概念,通过记录每一次的Version变更,形成了MANIFEST日志。
MANIFEST保存着每次的版本修改记录VersionEdit,而VersionEdit的操作对象则是Version。一个较老版本的Version,通过一次次的VersionEdit,可以恢复成最新的Version。Version表示了磁盘中SST文件的组成与元数据。VersionSet包含了目前所有被引用的Version的集合。
每次Immutable memtable落盘后,就会修改一次磁盘的SST文件列表,这时候会产生一次VersionEdit同时通过此VersionEdit来生成一个新版本。该VersionEdit会写入到一个日志中,该日志存储着最近的所有VersionEdit,该文件就被称之为Manifest log。当此文件被写满之后,则会生成一个新的文件,同时将一个全量的VersionEdit写入,之后写入后续的新增的VersionEdit,用于在重启后恢复所有管理的SST文件列表。在生成了新文件并且写入完毕之后,会修改一个叫CURRENT的文件,该文件存储着最新的Manifest log文件名,这是为了防止在之前步骤完成之前导致的数据异常,起到原子新增文件的功能。
当一个操作通过迭代起迭代数据的时候,会引用当前最新的Version,该Version会存在于VersionSet中即使经过了Compact后该文件被移除了,亦或者是新增了新的SST后产生了新的Version,都不会影响当前迭代起所可见的数据范围。当迭代起迭代结束之后,则会对解除该Version引用,当Version没有任何操作引用时,同时其又不是最新的版本,则可以移除该Version,同时对其包含的所有SST解除引用。相似的,当某个SST文件没有被任何的Version引用的时候,也会被清理。下面引用一下rocksdb的官方文档的示例:
假设当前的Version有以下三个SST文件:
v1={f1, f2, f3} (current)
files on disk: f1, f2, f3
这时候创建了一个迭代器引用了该版本:
v1={f1, f2, f3} (current, used by iterator1)
files on disk: f1, f2, f3
接下来触发了Immutable memtable的flush操作,产生了一个新的SST,这也表示了产生了一个新的Version v2:
v2={f1, f2, f3, f4} (current)
v1={f1, f2, f3} (used by iterator1)
files on disk: f1, f2, f3, f4
现在compaction被触发,f2,f3,f4被整合为新的文件f5,创建了一个新的Version v3:
v3={f1, f5} (current)
v2={f1, f2, f3, f4}
v1={f1, f2, f3} (used by iterator1)
files on disk: f1, f2, f3, f4, f5
这里v2没有被任何操作引用,同时它也不是最新版本的Version,所以它会被清理,由于只有v2引用着f4,所以f4也会被清理:
v3={f1, f5} (current)
v1={f1, f2, f3} (used by iterator1)
files on disk: f1, f2, f3, f5
这时候最开始的迭代器被销毁,那么v1会被清理,同时由于f2,f3只被v1引用,该两个文件也会被清理。清理之后的SST文件列表如下:
v3={f1, f5} (current)
files on disk: f1, f5
由此可见,rocksdb通过VersionSet来追踪所有SST文件的引用,判断是否需要清理SST文件列表,同时保留还被使用着的已经被Compaction的SST文件。
我们在上面提到了,当Manifest log超过一定尺寸的时候会切换到下一个文件,同时写入一个全量的Version。我们来具体看下其实现,该实现由函数VersionSet::WriteSnapshot
完成,它的逻辑也非常简单。由于日志产生了切割,所以日志的开头必须能有能恢复当前版本状态的一个全量Version或者是能恢复全量Version的VersionEdit列表。
在其实现中,它遍历所有的Column family,写入其数据。RocksDB相对于LevelDB,新增了Column family概念,所有的CF共享WAL,但是SST等文件数据是分开的。我们看下写入何种数据:
第一个VersioEdit:
第二个VersionEdit:
这样就完成了全量Version的写入,通过上面的分析我们也可以看出,全量数据主要由多个CF的VersionEdit列表组成,每个CF有2个VersionEdit,主要包含CF的元数据和CF的文件列表信息。
接下来我们继续看一下文件列表包含哪些信息,添加文件信息是通过函数VersionEdit::AddFile
函数,我们看下它的函数签名:
void AddFile(int level, uint64_t file, uint32_t file_path_id,
uint64_t file_size, const InternalKey& smallest,
const InternalKey& largest, const SequenceNumber& smallest_seqno,
const SequenceNumber& largest_seqno,
bool marked_for_compaction)
所以很简单,主要包含了层级,尺寸,序号以及每个SST文件最大以及最小的key的信息。
我们继续看下恢复流程中Manifest log恢复部分,重启后的rocksdb如何借助Manifest log来恢复内存中的SST文件列表信息。该实现主要在函数VersionSet::Recover
函数中,我们这里来简单的梳理下其流程。
首先,会读取CURRENT文件来获取当前使用的manifest log。然后会为每一个Column Family(CF)分配一个BaseReferenceVersionBuilder来通过VersionEdit来创建最新的Version。首先default的CF是系统默认的CF,所以我们需要首先创建它,我们通过CreateColumnFamily函数来创建它。
我们在上面提到过,一个CF可以引用很多的Version,所以在CF结构内,有一个Version组成的双向链表,代表着当前引用的Version信息。初始化CF的时候,我们需要创建一个DummyVersion作为双向链表插入的辅助值,它的prev指向双向链表的尾节点,而next指向头指针。初始情况下,DummyVersion的prev和next都指向了自己。
当向一个CF添加一个版本的时候,通过VersionSet::AppendVersion
函数来执行。它主要步骤如下:
接着,我们接可以用之前CURRENT指定的Manifest log中读取record来不断的将VersionEdit应用到对应CF中形成最新的Version了。为了提交效率,降低内存消耗,VersionEdit的Apply操作使用了一个名为BaseReferenceVersionBuilder的类来实现。
当所有的VersionEdit Apply完毕之后,则将所有CF对应的VersionBuilder内的数据取出,创建相应的Version对象,然后将此Version对象添加到CF中作为当前最新的Version即可。