目录

LevelDB 的数据存储

LevelDB 简介

LevelDB is a fast key-value storage library written at Google that provides an ordered mapping from string keys to string values.

— From: google/leveldb

LevelDB是Google传奇工程师Jeff Dean和Sanjay Ghemawat开源的嵌入式KV存储引擎1

在它的基础之上,Facebook 开发出了另一个 NOSQL 存储引擎库 RocksDB,沿用了 LevelDB 的先进技术架构的同时还解决了 LevelDB 的一些短板。现代开源市场上有很多数据库都在使用 RocksDB 作为底层存储引擎,比如大名鼎鼎的 TiDB2

https://cdn.jsdelivr.net/gh/dfface/img0@master/2022/05-06-As73zR.png

设计

LevelDB的数据是存储在磁盘上的,采用**LSM-Tree(Log Structured-Merge Tree)**的结构实现。

LSM树写性能极高的原理,简单地来说就是尽量减少随机写的次数。对于每次写入操作,并不是直接将最新的数据驻留在磁盘中,而是将其拆分成(1)一次日志文件的顺序写(2)一次内存中的数据插入。leveldb正是实践了这种思想,将数据首先更新在内存中,当内存中的数据达到一定的阈值,将这部分数据真正刷新到磁盘文件中,因而获得了极高的写性能(顺序写60MB/s, 随机写45MB/s)3

LSM-Tree 的思路是将索引树结构拆成一大一小两颗树,较小的一个常驻内存,较大的一个持久化到磁盘,他们共同维护一个有序的key空间。写入操作会首先操作内存中的树,随着内存中树的不断变大,会触发与磁盘中树的归并操作,而归并操作本身仅有顺序写。

随着数据的不断写入,磁盘中的树会不断膨胀,为了避免每次参与归并操作的数据量过大,以及优化读操作的考虑,LevelDB将磁盘中的数据又拆分成多层,每一层的数据达到一定容量后会触发向下一层的归并操作,每一层的数据量比其上一层成倍增长。这也就是LevelDB的名称来源。

架构

  • Memtable:内存数据结构,跳表实现,新的数据会首先写入这里;memtable中,所有的数据按用户定义的排序方法排序之后按序存储,等到其存储内容的容量达到阈值时(默认为4MB),便将其转换成一个不可修改的memtable,与此同时创建一个新的memtable,供用户继续进行读写操作。memtable底层使用了一种跳表数据结构,这种数据结构效率可以比拟二叉查找树,绝大多数操作的时间复杂度为O(log n)。
  • **Immutable Memtable:**达到Memtable设置的容量上限后,Memtable会变为Immutable为之后向SST文件的归并做准备,顾名思义,Immutable Mumtable不再接受用户写入,同时会有新的Memtable生成;与Memtable的结构定义完全一样,区别只是immutable memtable是只读的。当一个immutable memtable被创建时,leveldb的后台压缩进程便会将利用其中的内容,创建一个sstable,持久化到磁盘文件中。
  • Log文件写Memtable前会先写Log文件,Log通过append的方式顺序写入。Log的存在使得机器宕机导致的内存数据丢失得以恢复;假设写入到内存的数据还未来得及持久化,leveldb进程发生了异常,抑或是宿主机器发生了宕机,会造成用户的写入发生丢失。因此leveldb在写内存之前会首先将所有的写操作写到日志文件中,也就是log文件。
  • **SST文件:**磁盘数据存储文件。分为Level 0到Level N多层,每一层包含多个SST文件;单层SST文件总量随层次增加成倍增长;文件内数据有序;其中Level0的SST文件由Immutable直接Dump产生,其他Level的SST文件由其上一层的文件和本层文件归并产生;SST文件在归并过程中顺序写生成,生成后仅可能在之后的归并中被删除,而不会有任何的修改操作。除了某些元数据文件,leveldb的数据主要都是通过sstable来进行存储。虽然在内存中,所有的数据都是按序排列的,但是当多个memetable数据持久化到磁盘后,对应的不同的sstable之间是存在交集的,在读操作时,需要对所有的sstable文件进行遍历,严重影响了读取效率。因此leveldb后台会“定期“整合这些sstable文件,该过程也称为compaction。随着compaction的进行,sstable文件在逻辑上被分成若干层,由内存数据直接dump出来的文件称为level 0层文件,后期整合而成的文件为level i 层文件,这也是leveldb这个名字的由来。
  • Manifest文件: Manifest文件中记录SST文件在不同Level的分布,单个SST文件的最大最小key,以及其他一些LevelDB需要的元信息。
  • Current文件: 从上面的介绍可以看出,LevelDB启动时的首要任务就是找到当前的Manifest,而Manifest可能有多个。Current文件简单的记录了当前Manifest的文件名,从而让这个过程变得非常简单。因为每次leveldb启动时,都会创建一个新的Manifest文件。因此数据目录可能会存在多个Manifest文件。Current则用来指出哪个Manifest文件才是我们关心的那个Manifest文件。当每次compaction压缩完成(或者换一种更容易理解的说法,当每次sstable文件有新增或者减少),leveldb都会创建一个新的version。versionEdit指代的是基于旧版本的基础上,变化的内容(例如新增或删除了某些sstable文件)。manifest文件就是用来记录这些versionEdit信息的

https://cdn.jsdelivr.net/gh/dfface/img0@master/2022/05-06-Iimneb.jpeg

读写

https://cdn.jsdelivr.net/gh/dfface/img0@master/2022/05-06-qe68Hs.jpeg

leveldb的一次写入分为两部分:

  1. 将写操作写入日志;
  2. 将写操作应用到内存数据库中;

leveldb对外提供的写入接口有:(1)Put(2)Delete两种。这两种本质对应同一种操作,Delete操作同样会被转换成一个value为空的Put操作。

除此以外,leveldb还提供了一个批量处理的工具Batch,用户可以依据Batch来完成批量的数据库更新操作,且这些操作是原子性的。

无论是Put/Del操作,还是批量操作,底层都会为这些操作创建一个batch实例作为一个数据库操作的最小执行单元。

https://cdn.jsdelivr.net/gh/dfface/img0@master/2022/05-06-rRnMe0.jpeg

在batch中,每一条数据项都按照上图格式进行编码。每条数据项编码后的第一位是这条数据项的类型(更新还是删除),之后是数据项key的长度,数据项key的内容;若该数据项不是删除操作,则再加上value的长度,value的内容。

batch中会维护一个size值,用于表示其中包含的数据量的大小。该size值为所有数据项key与value长度的累加,以及每条数据项额外的8个字节。这8个字节用于存储一条数据项额外的一些信息。

当数据项从batch中写入到内存数据库中时,需要将一个key值的转换,即在leveldb内部,所有数据项的key是经过特殊编码的,这种格式称为internalKey。

https://cdn.jsdelivr.net/gh/dfface/img0@master/2022/05-06-RZgmUb.jpeg

internalkey在用户key的基础上,尾部追加了8个字节,用于存储(1)该操作对应的sequence number(2)该操作的类型。

其中,每一个操作都会被赋予一个sequence number。该计时器是在leveldb内部维护,每进行一次操作就做一个累加。由于在leveldb中,一次更新或者一次删除,采用的是append的方式,并非直接更新原数据。因此对应同样一个key,会有多个版本的数据记录,而最大的sequence number对应的数据记录就是最新的。

此外,leveldb的快照(snapshot)也是基于这个sequence number实现的,即每一个sequence number代表着数据库的一个版本。

leveldb中,在面对并发写入时,做了一个处理的优化。在同一个时刻,只允许一个写入操作将内容写入到日志文件以及内存数据库中。为了在写入进程较多的情况下,减少日志文件的小写入,增加整体的写入性能,leveldb将一些“小写入”合并成一个“大写入”。

https://cdn.jsdelivr.net/gh/dfface/img0@master/2022/05-06-yNDtRi.jpeg

  • 第一个写入操作获取到写入锁;

  • 在当前写操作的数据量未超过合并上限,且有其他写操作pending的情况下,将其他写操作的内容合并到自身;

  • 若本次写操作的数据量超过上限,或者无其他pending的写操作了,将所有内容统一写入日志文件,并写入到内存数据库中;

  • 通知每一个被合并的写操作最终的写入结果,释放或移交写锁;

  • 等待获取写锁或者被合并;

  • 若被合并,判断是否合并成功,若成功,则等待最终写入结果;反之,则表明获取锁的写操作已经oversize了,此时,该操作直接从上个占有锁的写操作中接过写锁进行写入;

  • 若未被合并,则继续等待写锁或者等待被合并;

leveldb的任意一个写操作(无论包含了多少次写),其原子性都是由日志文件实现的。一个写操作中所有的内容会以一个日志中的一条记录,作为最小单位写入。

考虑以下两种异常情况:

  1. 写日志未开始,或写日志完成一半,进程异常退出;
  2. 写日志完成,进程异常退出;

前者中可能存储一个写操作的部分写已经被记载到日志文件中,仍然有部分写未被记录,这种情况下,当数据库重新启动恢复时,读到这条日志记录时,发现数据异常,直接丢弃或退出,实现了写入的原子性保障。

后者,写日志已经完成,写入日志的数据未真正持久化,数据库启动恢复时通过redo日志实现数据写入,仍然保障了原子性。

leveldb提供给用户两种进行读取数据的接口:

  1. 直接通过Get接口读取数据;
  2. 首先创建一个snapshot,基于该snapshot调用Get接口读取数据;

两者的本质是一样的,只不过第一种调用方式默认地以当前数据库的状态创建了一个snapshot,并基于此snapshot进行读取。

读者可能不了解snapshot(快照)到底是什么?简单地来说,就是数据库在某一个时刻的状态。基于一个快照进行数据的读取,读到的内容不会因为后续数据的更改而改变

两种方式本质都是基于快照进行读取的。

快照代表着数据库某一个时刻的状态,在leveldb中,作者巧妙地用一个整型数来代表一个数据库状态。

在leveldb中,用户对同一个key的若干次修改(包括删除)是以维护多条数据项的方式进行存储的(直至进行compaction时才会合并成同一条记录),每条数据项都会被赋予一个序列号,代表这条数据项的新旧状态。一条数据项的序列号越大,表示其中代表的内容为最新值。

因此,每一个序列号,其实就代表着leveldb的一个状态。换句话说,每一个序列号都可以作为一个状态快照。

当用户主动或者被动地创建一个快照时,leveldb会以当前最新的序列号对其赋值。例如图中用户在序列号为98的时刻创建了一个快照,并且基于该快照读取key为“name”的数据时,即便此刻用户将"name"的值修改为"dog",再删除,用户读取到的内容仍然是“cat”。

https://cdn.jsdelivr.net/gh/dfface/img0@master/2022/05-06-WgrSEL.jpeg

所以,利用快照能够保证数据库进行并发的读写操作。

在获取到一个快照之后,leveldb会为本次查询的key构建一个internalKey(格式如上文所述),其中internalKey的seq字段使用的便是快照对应的seq。通过这种方式可以过滤掉所有seq大于快照号的数据项

https://cdn.jsdelivr.net/gh/dfface/img0@master/2022/05-06-vCZJRz.jpeg

  1. 在memory db中查找指定的key,若搜索到符合条件的数据项,结束查找;
  2. 在冻结的memory db中查找指定的key,若搜索到符合条件的数据项,结束查找;
  3. 按低层至高层的顺序在level i层的sstable文件中查找指定的key,若搜索到符合条件的数据项,结束查找,否则返回Not Found错误,表示数据库中不存在指定的数据;

在memory db或者sstable的查找过程中,需要根据指定的序列号拼接一个internalKey,查找用户key一致,且seq号不大于指定seq的数据。

日志

https://cdn.jsdelivr.net/gh/dfface/img0@master/2022/05-06-trfhgP.jpeg

在leveldb中,有两个memory db,以及对应的两份日志文件。其中一个memory db是可读写的,当这个db的数据量超过预定的上限时,便会转换成一个不可写的memory db,与此同时,与之对应的日志文件也变成一份frozen log。

而新生成的immutable memory db则会由后台的minor compaction进程将其转换成一个sstable文件进行持久化,持久化完成,与之对应的frozen log被删除

https://cdn.jsdelivr.net/gh/dfface/img0@master/2022/05-06-RIxfHM.jpeg

为了增加读取效率,日志文件中按照block进行划分,每个block的大小为32KiB。每个block中包含了若干个完整的chunk。

一条日志记录包含一个或多个chunk。每个chunk包含了一个7字节大小的header,前4字节是该chunk的校验码,紧接的2字节是该chunk数据的长度,以及最后一个字节是该chunk的类型。其中checksum校验的范围包括chunk的类型以及随后的data数据。

chunk共有四种类型:full,first,middle,last。一条日志记录若只包含一个chunk,则该chunk的类型为full。若一条日志记录包含多个chunk,则这些chunk的第一个类型为first, 最后一个类型为last,中间包含大于等于0个middle类型的chunk。

由于一个block的大小为32KiB,因此当一条日志文件过大时,会将第一部分数据写在第一个block中,且类型为first,若剩余的数据仍然超过一个block的大小,则第二部分数据写在第二个block中,类型为middle,最后剩余的数据写在最后一个block中,类型为last

日志的内容为写入的batch编码后的信息

https://leveldb-handbook.readthedocs.io/zh/latest/_images/journal_write.jpeg

日志写入流程较为简单,在leveldb内部,实现了一个journal的writer。首先调用Next函数获取一个singleWriter,这个singleWriter的作用就是写入一条journal记录

singleWriter开始写入时,标志着第一个chunk开始写入。在写入的过程中,不断判断writer中buffer的大小,若超过32KiB,将chunk开始到现在做为一个完整的chunk,为其计算header之后将整个chunk写入文件。与此同时reset buffer,开始新的chunk的写入。

若一条journal记录较大,则可能会分成几个chunk存储在若干个block中。

https://cdn.jsdelivr.net/gh/dfface/img0@master/2022/05-06-L594AU.jpeg

日志读取也较为简单。为了避免频繁的IO读取,每次从文件中读取数据时,按block(32KiB)进行块读取。

每次读取一条日志记录,reader调用Next函数返回一个singleReader。singleReader每次调用Read函数就返回一个chunk的数据。每次读取一个chunk,都会检查这批数据的校验码、数据类型、数据长度等信息是否正确,若不正确,且用户要求严格的正确性,则返回错误,否则丢弃整个chunk的数据。

循环调用singleReader的read函数,直至读取到一个类型为Last的chunk,表示整条日志记录都读取完毕,返回。

内存数据库:跳表

leveldb中内存数据库用来维护有序的key-value对,其底层是利用跳表实现,绝大多数操作(读/写)的时间复杂度均为O(log n)有着与平衡树相媲美的操作效率,但是从实现的角度来说简单许多

跳表(SkipList)是由William Pugh提出的。他在论文《Skip lists: a probabilistic alternative to balanced trees》中详细地介绍了有关跳表结构、插入删除操作的细节。

这种数据结构是利用概率均衡技术,加快简化插入、删除操作,且保证绝大大多操作均拥有O(log n)的良好效率。

Skip lists are a data structure that can be used in place of balanced trees. Skip lists use probabilistic balancing rather than strictly enforced balancing and as a result the algorithms for insertion and deletion in skip lists are much simpler and significantly faster than equivalent algorithms for balanced trees.

— 《Skip lists: a probabilistic alternative to balanced trees》

平衡树(以红黑树为代表)是一种非常复杂的数据结构,为了维持树结构的平衡,获取稳定的查询效率,平衡树每次插入可能会涉及到较为复杂的节点旋转等操作。作者设计跳表的目的就是借助概率平衡,来构建一个快速且简单的数据结构,取代平衡树。

作者从链表讲起,一步步引出了跳表这种结构的由来。

https://cdn.jsdelivr.net/gh/dfface/img0@master/2022/05-06-F6FNE2.jpeg

图a中,所有元素按序排列,被存储在一个链表中,则一次查询之多需要比较N个链表节点;

图b中,每隔2个链表节点,新增一个额外的指针,该指针指向间距为2的下一个节点,如此以来,借助这些额外的指针,一次查询至多只需要⌈n/2⌉ + 1次比较;

图c中,在图b的基础上,每隔4个链表节点,新增一个额外的指针,指向间距为4的下一个节点,一次查询至多需要⌈n/4⌉ + 2次比较;

作者推论,若每隔2^ i个节点,新增一个辅助指针,最终一次节点的查询效率为O(log n)。但是这样不断地新增指针,使得一次插入、删除操作将会变得非常复杂。

一个拥有k个指针的结点称为一个k层结点(level k node)。按照上面的逻辑,50%的结点为1层节点,25%的结点为2层节点,12.5%的结点为3层节点。若保证每层节点的分布如上述概率所示,则仍然能够相同的查询效率。图e便是一个示例。

维护这些辅助指针将会带来较大的复杂度,因此作者将每一层中,每个节点的辅助指针指向该层中下一个节点。故在插入删除操作时,只需跟操作链表一样,修改相关的前后两个节点的内容即可完成,作者将这种数据结构称为跳表。

跳跃列表是按层建造的。底层是一个普通的有序链表。每个更高层都充当下面链表的"快速通道",这里在层 i 中的元素按某个固定的概率 p (通常为0.5或0.25)出现在层 i+1 中。平均起来,每个元素都在 1/(1-p) 个列表中出现,而最高层的元素(通常是在跳跃列表前端的一个特殊的头元素)在 O(log1/p n) 个列表中出现。

查找

https://cdn.jsdelivr.net/gh/dfface/img0@master/2022/05-06-v6DzPL.jpeg

例如图中,需要查找一个值为17的链表节点,查找的过程为:

  • 首先根据跳表的高度选取最高层的头节点;
  • 若跳表中的节点内容小于查找节点的内容,则取该层的下一个节点继续比较;
  • 若跳表中的节点内容等于查找节点的内容,则直接返回;
  • 若跳表中的节点内容大于查找节点的内容,且层高不为0,则降低层高,且从前一个节点开始,重新查找低一层中的节点信息;若层高为0,则返回当前节点,该节点的key大于所查找节点的key。

综合来说,就是利用稀疏的高层节点,快速定位到所需要查找节点的大致位置,再利用密集的底层节点,具体比较节点的内容

插入

插入操作借助于查找操作实现。

https://cdn.jsdelivr.net/gh/dfface/img0@master/2022/05-07-o8Kwkt.jpeg

  • 在查找的过程中,不断记录每一层前任节点,如图中红色圆圈所表示的;
  • 为新插入的节点随机产生层高(随机产生层高的算法较为简单,依赖最高层数和概率值p,可见下文中的代码实现);
  • 在合适的位置插入新节点(例如图中节点12与节点19之间),并依据查找时记录的前任节点信息,在每一层中,以链表插入的方式,将该节点插入到每一层的链接中。

删除

跳表的删除操作较为简单,依赖查找过程找到该节点在整个跳表中的位置后,以链表删除的方式,在每一层中,删除该节点的信息。

leveldb如何利用跳表来构建一个高效的内存数据库?键值编码 + 键值比较 + 数据组织

首先介绍一下内存数据库的键值编码规则。由于内存数据库本质是一个kv集合,且所有的数据项都是依据key值排序的,因此键值的编码规则尤为关键。

内存数据库中,key称为internalKey,其由三部分组成:

https://cdn.jsdelivr.net/gh/dfface/img0@master/2022/05-07-mnKGTU.jpeg

  • 用户定义的key:这个key值也就是原生的key值;
  • 序列号:leveldb中,每一次写操作都有一个sequence number,标志着写入操作的先后顺序。由于在leveldb中,可能会有多条相同key的数据项同时存储在数据库中,因此需要有一个序列号来标识这些数据项的新旧情况。序列号最大的数据项为最新值;
  • 类型:标志本条数据项的类型,为更新还是删除;

内存数据库中所有的数据项都是按照键值比较规则进行排序的。这个比较规则可以由用户自己定制,也可以使用系统默认的。在这里介绍一下系统默认的比较规则。

默认的比较规则:

  • 首先按照字典序比较用户定义的key(ukey),若用户定义key值大,整个internalKey就大;
  • 若用户定义的key相同,则序列号大的internalKey值就小;

通过这样的比较规则,则所有的数据项首先按照用户key进行升序排列;当用户key一致时,按照序列号进行降序排列,这样可以保证首先读到序列号大的数据项

type DB struct {
    cmp comparer.BasicComparer
    rnd *rand.Rand

    mu     sync.RWMutex
    kvData []byte
    // Node data:
    // [0]         : KV offset
    // [1]         : Key length
    // [2]         : Value length
    // [3]         : Height
    // [3..height] : Next nodes
    nodeData  []int
    prevNode  [tMaxHeight]int
    maxHeight int
    n         int
    kvSize    int
}

kvData用来存储每一条数据项的key-value数据,nodeData用来存储每个跳表节点的链接信息。每个跳表节点占用一段连续的存储空间,每一个字节分别用来存储特定的跳表节点信息。

  • 第一个字节用来存储本节点key-value数据在kvData中对应的偏移量;
  • 第二个字节用来存储本节点key值长度;
  • 第三个字节用来存储本节点value值长度;
  • 第四个字节用来存储本节点的层高;
  • 第五个字节开始,用来存储每一层对应的下一个节点的索引值;

sstable

leveldb是典型的LSM树(Log Structured-Merge Tree)实现,即一次leveldb的写入过程并不是直接将数据持久化到磁盘文件中,而是将写操作首先写入日志文件中,其次将写操作应用在memtable上。

当leveldb达到checkpoint点(memtable中的数据量超过了预设的阈值),会将当前memtable冻结成一个不可更改的内存数据库(immutable memory db),并且创建一个新的memtable供系统继续使用。

immutable memory db会在后台进行一次minor compaction,即将内存数据库中的数据持久化到磁盘文件中。

leveldb(或者说LSM树)设计Minor Compaction的目的是为了:

  1. 有效地降低内存的使用率;
  2. 避免日志文件过大,系统恢复时间过长;

当memory db的数据被持久化到文件中时,leveldb将以一定规则进行文件组织,这种文件格式成为sstable

物理结构

为了提高整体的读写效率,一个sstable文件按照固定大小进行块划分,默认每个块的大小为4KiB。每个Block中,除了存储数据以外,还会存储两个额外的辅助字段:

  1. 压缩类型
  2. CRC校验码

https://cdn.jsdelivr.net/gh/dfface/img0@master/2022/05-07-QZduzZ.jpeg

压缩类型说明了Block中存储的数据是否进行了数据压缩,若是,采用了哪种算法进行压缩。leveldb中默认采用Snappy算法进行压缩。

CRC校验码是循环冗余校验校验码,校验范围包括数据以及压缩类型。

逻辑结构

在逻辑上,根据功能不同,leveldb在逻辑上又将sstable分为:

  1. data block: 用来存储key value数据对;
  2. filter block: 用来存储一些过滤器相关的数据(布隆过滤器),但是若用户不指定leveldb使用过滤器,leveldb在该block中不会存储任何内容;
  3. meta Index block: 用来存储filter block的索引信息(索引信息指在该sstable文件中的偏移量以及数据长度);
  4. index block:index block中用来存储每个data block的索引信息;
  5. footer: 用来存储meta index block及index block的索引信息;

https://cdn.jsdelivr.net/gh/dfface/img0@master/2022/05-07-w82kYj.jpeg

Data Block

第一部分用来存储keyvalue数据。由于sstable中所有的keyvalue对都是严格按序存储的,为了节省存储空间,leveldb并不会为每一对keyvalue对都存储完整的key值,而是存储与上一个key非共享的部分,避免了key重复内容的存储。

每间隔若干个keyvalue对,将为该条记录重新存储一个完整的key。重复该过程(默认间隔值为16),每个重新存储完整key的点称之为Restart point。

https://leveldb-handbook.readthedocs.io/zh/latest/_images/datablock.jpeg

由于每个Restart point存储的都是完整的key值,因此在sstable中进行数据查找时,可以首先利用restart point点的数据进行键值比较,以便于快速定位目标数据所在的区域;

当确定目标数据所在区域时,再依次对区间内所有数据项逐项比较key值,进行细粒度地查找;

每个数据项的格式如下图所示:

https://cdn.jsdelivr.net/gh/dfface/img0@master/2022/05-07-uZTDX2.jpeg

一个entry分为5部分内容:

  1. 与前一条记录key共享部分的长度;
  2. 与前一条记录key不共享部分的长度;
  3. value长度;
  4. 与前一条记录key非共享的内容;
  5. value内容;

Filter Block

为了加快sstable中数据查询的效率,在直接查询datablock中的内容之前,leveldb首先根据filter block中的过滤数据判断指定的datablock中是否有需要查询的数据,若判断不存在,则无需对这个datablock进行数据查找。

filter block存储的是data block数据的一些过滤信息。这些过滤数据一般指代布隆过滤器的数据,用于加快查询的速度。

https://cdn.jsdelivr.net/gh/dfface/img0@master/2022/05-07-z4eXq9.jpeg

meta index block用来存储filter block在整个sstable中的索引信息。

与meta index block类似,index block用来存储所有data block的相关索引信息。

indexblock包含若干条记录,每一条记录代表一个data block的索引信息。

一条索引包括以下内容:

  1. data block i 中最大的key值;
  2. 该data block起始地址在sstable中的偏移量;
  3. 该data block的大小;

https://cdn.jsdelivr.net/gh/dfface/img0@master/2022/05-07-HmHaGw.jpeg

footer大小固定,为48字节,用来存储meta index block与index block在sstable中的索引信息,另外尾部还会存储一个magic word,内容为:“http://code.google.com/p/leveldb/"字符串sha1哈希的前8个字节。

写操作

sstable的写操作通常发生在:

  • memory db将内容持久化到磁盘文件中时,会创建一个sstable进行写入;
  • leveldb后台进行文件compaction时,会将若干个sstable文件的内容重新组织,输出到若干个新的sstable文件中;

读操作

读操作作为写操作的逆过程。

下图为在一个sstable中查找某个数据项的流程图:

https://cdn.jsdelivr.net/gh/dfface/img0@master/2022/05-07-4rBZh4.jpeg

大致流程为:

  1. 首先判断“文件句柄”cache中是否有指定sstable文件的文件句柄,若存在,则直接使用cache中的句柄;否则打开该sstable文件,按规则读取该文件的元数据,将新打开的句柄存储至cache中;
  2. 利用sstable中的index block进行快速的数据项位置定位,得到该数据项有可能存在的两个data block;
  3. 利用index block中的索引信息,首先打开第一个可能的data block;
  4. 利用filter block中的过滤信息,判断指定的数据项是否存在于该data block中,若存在,则创建一个迭代器对data block中的数据进行迭代遍历,寻找数据项;若不存在,则结束该data block的查找;
  5. 若在第一个data block中找到了目标数据,则返回结果;若未查找成功,则打开第二个data block,重复步骤4;
  6. 若在第二个data block中找到了目标数据,则返回结果;若未查找成功,则返回Not Found错误信息;

在leveldb中,使用cache来缓存两类数据:

  • sstable文件句柄及其元数据;
  • data block中的数据;

因此在打开文件之前,首先判断能够在cache中命中sstable的文件句柄,避免重复读取的开销。

sstable中存在多个data block,倘若依次进行“遍历”显然是不可取的。但是由于一个sstable中所有的数据项都是按序排列的,因此可以利用有序性已经index block中维护的索引信息快速定位目标数据项可能存在的data block。

https://cdn.jsdelivr.net/gh/dfface/img0@master/2022/05-07-JtNmjd.jpeg

index block是由一系列的键值对组成,每一个键值对表示一个data block的索引信息。

键值对的key为该data block中数据项key的最大值,value为该data block的索引信息(offset, length)。

因此若需要查找目标数据项,仅仅需要依次比较index block中的这些索引信息,倘若目标数据项的key大于某个data block中最大的key值,则该data block中必然不存在目标数据项。故通过这个步骤的优化,可以直接确定目标数据项落在哪个data block的范围区间内

https://cdn.jsdelivr.net/gh/dfface/img0@master/2022/05-07-ECoEm3.jpeg

若sstable存有每一个data block的过滤数据,则可以利用这些过滤数据对data block中的内容进行判断,“确定”目标数据是否存在于data block中。

过滤的原理(布隆过滤器)为:

  • 若过滤数据显示目标数据不存在于data block中,则目标数据一定不存在于data block中;
  • 若过滤数据显示目标数据存在于data block中,则目标数据可能存在于data block中;

利用过滤数据可以过滤掉部分data block,避免发生无谓的查找。

在data block中查找目标数据项是一个简单的迭代遍历过程。虽然data block中所有数据项都是按序排序的,但是作者并没有采用“二分查找”来提高查找的效率,而是使用了更大的查找单元进行快速定位

与index block的查找类似,data block中,以16条记录为一个查找单元,若entry 1的key小于目标数据项的key,则下一条比较的是entry 17。

因此查找的过程中,利用更大的查找单元快速定位目标数据项可能存在于哪个区间内,之后依次比较判断其是否存在与data block中。

https://cdn.jsdelivr.net/gh/dfface/img0@master/2022/05-07-fSTHlo.jpeg

可以看到,sstable很多文件格式设计(例如restart point, index block,filter block,max key)在查找的过程中,都极大地提升了整体的查找效率。

特点

  1. 只读性:sstable文件为compaction的结果原子性的产生,在其余时间是只读的。

  2. 完整性:一个sstable文件,其辅助数据:索引数据和过滤数据,都直接存储于同一个文件中。当读取是需要使用这些辅助数据时,无须额外的磁盘读取;当sstable文件需要删除时,无须额外的数据删除。简要地说,辅助数据随着文件一起创建和销毁

  3. 并发访问友好性:由于sstable文件具有只读性,因此不存在同一个文件的读写冲突。leveldb采用引用计数维护每个文件的引用情况,当一个文件的计数值大于0时,对此文件的删除动作会等到该文件被释放时才进行,因此实现了无锁情况下的并发访问。

  4. Cache一致性:sstable文件为只读的,因此cache中的数据永远于sstable文件中的数据保持一致。

缓存系统

缓存对于一个数据库读性能的影响十分巨大,倘若leveldb的每一次读取都会发生一次磁盘的IO,那么其整体效率将会非常低下。

Leveldb中使用了一种基于LRUCache的缓存机制,用于缓存:

  • 已打开的sstable文件对象和相关元数据;
  • sstable中的dataBlock的内容;

使得在发生读取热数据时,尽量在cache中命中,避免IO读取。

缓存结构

leveldb中使用的cache是一种LRUcache,其结构由两部分内容组成:

  • Hash table:用来存储数据;
  • LRU:用来维护数据项的新旧信息;

https://cdn.jsdelivr.net/gh/dfface/img0@master/2022/05-07-zc0yaG.jpeg

其中Hash table是基于Yujie Liu等人的论文《Dynamic-Sized Nonblocking Hash Table》实现的,用来存储数据。由于hash表一般需要保证插入、删除、查找等操作的时间复杂度为 O(1)。

当hash表的数据量增大时,为了保证这些操作仍然保有较为理想的操作效率,需要对hash表进行resize,即改变hash表中bucket的个数,对所有的数据进行重散列。

基于该文章实现的hash table可以实现resize的过程中不阻塞其他并发的读写请求

LRU中则根据Least Recently Used原则进行数据新旧信息的维护,当整个cache中存储的数据容量达到上限时,便会根据LRU算法自动删除最旧的数据,使得整个cache的存储容量保持一个常量。

Dynamic-sized NonBlocking Hash table

在hash表进行resize的过程中,保持Lock-Free是一件非常困难的事。

一个hash表通常由若干个bucket组成,每一个bucket中会存储若干条被散列至此的数据项。当hash表进行resize时,需要将“旧”桶中的数据读出,并且重新散列至另外一个“新”桶中。假设这个过程不是一个原子操作,那么会导致此刻其他的读、写请求的结果发生异常,甚至导致数据丢失的情况发生。

因此,liu等人提出了一个新颖的概念:一个bucket的数据是可以冻结的

这个特点极大地简化了hash表在resize过程中在不同bucket之间转移数据的复杂度。

散列

https://cdn.jsdelivr.net/gh/dfface/img0@master/2022/05-07-NitI6O.jpeg

该哈希表的散列与普通的哈希表一致,都是借助散列函数,将用户需要查找、更改的数据散列到某一个哈希桶中,并在哈希桶中进行操作。

由于一个哈希桶的容量是有限的(一般不大于32个数据),因此在哈希桶中进行插入、查找的时间复杂度可以视为是常量的。

扩大

https://cdn.jsdelivr.net/gh/dfface/img0@master/2022/05-07-XWU4BG.jpeg

当cache中维护的数据量太大时,会发生哈希表扩张的情况。以下两种情况是为“cache中维护的数据量过大”:

  • 整个cache中,数据项(node)的个数超过预定的阈值(默认初始状态下哈希桶的个数为16个,每个桶中可存储32个数据项,即总量的阈值为哈希桶个数乘以每个桶的容量上限);
  • 当cache中出现了数据不平衡的情况。当某些桶的数据量超过了32个数据,即被视作数据发生散列不平衡。当这种不平衡累积值超过预定的阈值(128)个时,就需要进行扩张;

一次扩张的过程为:

  1. 计算新哈希表的哈希桶个数(扩大一倍);
  2. 创建一个空的哈希表,并将旧的哈希表(主要为所有哈希桶构成的数组)转换一个“过渡期”的哈希表,表中的每个哈希桶都被“冻结”;
  3. 后台利用“过渡期”哈希表中的“被冻结”的哈希桶信息对新的哈希表进行内容构建;

值得注意的是,在完成新的哈希表构建的整个过程中,哈希表并不是拒绝服务的,所有的读写操作仍然可以进行

哈希表扩张过程中,最小的封锁粒度为哈希桶级别

当有新的读写请求发生时,若被散列之后得到的哈希桶仍然未构建完成,则“主动”进行构建,并将构建后的哈希桶填入新的哈希表中。后台进程构建到该桶时,发现已经被构建了,则无需重复构建。

因此如上图所示,哈希表扩张结束,哈希桶的个数增加了一倍,于此同时仍然可以对外提供读写服务,仅仅需要哈希桶级别的封锁粒度就可以保证所有操作的一致性跟原子性。

构建哈希桶

当哈希表扩张时,构建一个新的哈希桶其实就是将一个旧哈希桶中的数据拆分成两个新的哈希桶

拆分的规则很简单。由于一次散列的过程为:

  1. 利用散列函数对数据项的key值进行计算
  2. 将第一步得到的结果取哈希桶个数的余,得到哈希桶的ID

因此拆分时仅需要将数据项key的散列值对新的哈希桶个数取余即可。

缩小

当哈希表中数据项的个数少于哈希桶的个数时,需要进行收缩。收缩时,哈希桶的个数变为原先的一半,2个旧哈希桶的内容被合并成一个新的哈希桶,过程与扩张类似,在这里不展开详述。

“Dynamic-Sized Nonblocking Hash Tables”, by Yujie Liu, Kunlong Zhang, and Michael Spear. ACM Symposium on Principles of Distributed Computing, Jul 2014.

LRU

除了利用哈希表来存储数据以外,leveldb还利用LRU来管理数据

一个LRUNode除了维护一些链表中前后节点信息以外,还存储了一个哈希表中数据项的指针,通过该指针,当某个节点由于LRU策略被驱逐时,从哈希表中“安全的”删除数据内容。

布隆过滤器

Bloom Filter是一种空间效率很高的随机数据结构,它利用位数组很简洁地表示一个集合,并能判断一个元素是否属于这个集合。Bloom Filter的这种高效是有一定代价的:在判断一个元素是否属于某个集合时,有可能会把不属于这个集合的元素误认为属于这个集合(false positive)。因此,Bloom Filter不适合那些“零错误”的应用场合。而在能容忍低错误率的应用场合下,Bloom Filter通过极少的错误换取了存储空间的极大节省

leveldb中利用布隆过滤器判断指定的key值是否存在于sstable中若过滤器表示不存在,则该key一定不存在,由此加快了查找的效率。

bloom过滤器底层是一个位数组,初始时每一位都是0。

https://cdn.jsdelivr.net/gh/dfface/img0@master/2022/05-07-dnCkKy.jpg

当插入值x后,分别利用k个哈希函数,利用x的值进行散列,并将散列得到的值与bloom过滤器的容量进行取余,将取余结果所代表的那一位值置为1。

https://cdn.jsdelivr.net/gh/dfface/img0@master/2022/05-07-sKObr0.jpg

一次查找过程与一次插入过程类似,同样利用k个哈希函数对所需要查找的值进行散列,只有散列得到的每一个位的值均为1,才表示该值“有可能”真正存在;反之若有任意一位的值为0,则表示该值一定不存在。例如y1一定不存在;而y2可能存在。

Bloom Filter在判断一个元素是否属于它表示的集合时会有一定的错误率(false positive rate4

首先,与布隆过滤器准确率有关的参数有:

  • 哈希函数的个数k;
  • 布隆过滤器位数组的容量m;
  • 布隆过滤器插入的数据数量n;

主要的数学结论有:

  1. 为了获得最优的准确率,当$k = ln2 * (m/n)$时,布隆过滤器获得最优的准确性;
  2. 在哈希函数的个数取到最优时,要让错误率不超过є,m至少需要取到最小值的1.44倍;

compaction

Compaction是leveldb最为复杂的过程之一,同样也是leveldb的性能瓶颈之一。其本质是一种内部数据重合整合的机制,同样也是一种平衡读写速率的有效手段.

leveldb是典型的LSM树实现,因此需要对内存中的数据进行持久化。一次内存数据的持久化过程,在leveldb中称为Minor Compaction

一次minor compaction的产出是一个0层的sstable文件,其中包含了所有的内存数据。但是若干个0层文件中是可能存在数据overlap的。

leveldb是一个写效率十分高的存储引擎,存储的过程非常简单,只需要一次顺序的文件写和一个时间复杂度为O(log n)的内存操作即可。

相比来说,leveldb的读操作就复杂不少。首先一到两次读操作需要进行一个复杂度为O(log n)的查询操作。若没有在内存中命中数据,则需要在按照数据的新旧程度在0层文件中依次进行查找遍历。由于0层文件中可能存在overlap,因此在最差情况下,可能需要遍历所有的文件。

假设leveldb中就是以这样的方式进行数据维护,那么随着运行时间的增长,0层的文件个数会越来越多,在最差的情况下,查询一个数据需要遍历所有的数据文件,这显然是不可接受的。因此leveldb设计了一个***Major Compaction***的过程,将0层中的文件合并为若干个没有数据重叠的1层文件。

有了minor compaction和major compaction,所有的数据在后台都会被规定的次序进行整合。但是一次major compaction的过程其本质是一个多路归并的过程,既有大量的磁盘读开销,也有大量的磁盘写开销,显然这是一个严重的性能瓶颈。

minor compaction

一次minor compaction非常简单,其本质就是将一个内存数据库中的所有数据持久化到一个磁盘文件中。

https://cdn.jsdelivr.net/gh/dfface/img0@master/2022/05-07-32Fs1p.jpeg

每次minor compaction结束后,都会生成一个新的sstable文件,也意味着Leveldb的版本状态发生了变化,会进行一个版本的更替

minor compaction是一个时效性要求非常高的过程,要求其在尽可能短的时间内完成,否则就会堵塞正常的写入操作,因此minor compaction的优先级高于major compaction。当进行minor compaction的时候有major compaction正在进行,则会首先暂停major compaction。

major compaction

相比于minor compaction,major compaction就会复杂地多。首先看一下一次major compaction的示意图。

https://cdn.jsdelivr.net/gh/dfface/img0@master/2022/05-07-hVOQtl.jpeg

0层中浅蓝色的三个sstable文件,加上1层中的绿色的sstable文件,四个文件进行了合并,输出成两个按序组织的新的1层sstable文件进行替换。

那么什么时候,会触发leveldb进行major compaction呢。总结地来说为以下三个条件:

  • 当0层文件数超过预定的上限(默认为4个);
  • 当level i层文件的总大小超过(10 ^ i) MB;
  • 当某个文件无效读取的次数过多;

版本控制

Leveldb每次新生成sstable文件,或者删除sstable文件,都会从一个版本升级成另外一个版本。

换句话说,每次sstable文件的更替对于leveldb来说是一个最小的操作单元,具有原子性。

版本控制对于leveldb来说至关重要,是保障数据正确性的重要机制。在本文中,将着重从版本数据的格式以及版本升级的过程进行展开。

manifest文件专用于记录版本信息。leveldb采用了增量式的存储方式,记录每一个版本相较于上一个版本的变化情况。

借助这个Manifest文件,leveldb启动时,可以根据一个初始的版本状态,不断地应用这些版本改动,使得系统的版本信息恢复到最近一次使用的状态。

https://cdn.jsdelivr.net/gh/dfface/img0@master/2022/05-07-khkoh7.jpeg

每当(1)完成一次major compaction整理内部数据或者(2)通过minor compaction或者重启阶段的日志重放新生成一个0层文件,都会触发leveldb进行一个版本升级。

https://cdn.jsdelivr.net/gh/dfface/img0@master/2022/05-07-xOzzfc.jpeg

数据库每次启动时,都会有一个recover的过程,简要地来说,就是利用Manifest信息重新构建一个最新的version。

https://cdn.jsdelivr.net/gh/dfface/img0@master/2022/05-07-Hp43wt.jpeg

由于每次启动,都会新建一个Manifest文件,因此leveldb当中可能会存在多个manifest文件。因此需要一个额外的current文件来指示当前系统使用的到底是哪个manifest文件。

leveldb中采用了MVCC来避免读写冲突。

leveldb采用了多版本并发控制的方法来解决读写冲突。具体体现在:

  1. sstable文件是只读的,每次compaction都只是对若干个sstable文件进行多路合并后创建新的文件,故不会影响在某个sstable文件读操作的正确性;
  2. sstable都是具有版本信息的,即每次compaction完成后,都会生成新版本的sstable,因此可以保障读写操作都可以针对于相应的版本文件进行,解决了读写冲突;
  3. compaction生成的文件只有等合并完成后才会写入数据库元数据,在此期间对读操作来说是透明的,不会污染正常的读操作;
  4. 采用引用计数来控制删除行为。当compaction完成后试图去删除某个sstable文件,会根据该文件的引用计数作适当的删除延迟,即引用计数不为0时,需要等待至该文件的计数为0才真正进行删除;

  1. 庖丁解LevelDB之概览 | CatKang的博客:这个博客网站主要关注数据库、存储等内容。但是写的不太清晰。 ↩︎

  2. 既生 Redis 何生 LevelDB ? - 知乎 (zhihu.com):这篇文章属实写的不行,内容上与Redis对比,没看出对准在哪里,评论差评较多。 ↩︎

  3. 基本概念 — leveldb-handbook 文档:写于2017年的一个比较丰富的介绍 levelDB 的中文文档,比较容易读懂。本文摘抄了大量其上的内容,可以说是全文复制了。反正我这个博客就是用来碎片学习记笔记的嘛。 ↩︎

  4. Bloom Filter概念和原理 jiaomeng的博客-CSDN博客_bloomfilter :写的很好。 ↩︎