总体介绍

原文标题为Revisiting Log-Structured Merging for KV Stores in Hybrid Memory Systems

LSM-Tree一直有周期性写停滞和写放大的问题,在传统DRAM-SSD系统架构里解决这一问题还是十分困难的。而NVM(持久化内存)有希望取代SSD,为优化基于LSM-Tree的KV存储性能提供了新的机会。

过去也有一些工作使用NVM对基于LSM-Tree的KV存储性能进行优化,但是这些工作都忽略了一个问题:传统LSM-Tree的数据序列化和压缩速度太慢,因此可能会阻塞数据从DRAM到NVM的Flush。 如果当DRAM的MemTable累积了突发性的大量写入,就需要时间来对KV项进行序列化,再将数据装入LSM-Tree的L0层。因为LSM-Tree的数据序列化和压缩速度太慢,阻塞了数据从DRAM到NVM的Flush,所以存储系统就会出现写停滞。

论文的工作如下:

1、设计了多级skip list(又叫PMTable,替代LSM实现的SSTable),以充分利用NVM的字节可寻址性进行KV存储,并利用One-Piece Flushing(代替原LSM-Tree从immutable MemTable到L0的Flush)来降低跨DRAM和NVM的数据序列化/反序列化成本。

2、将Zero-Copy Compaction与Lazy-Copy Compaction相结合(替代LSM-Tree中的Compaction),以减少写停滞和写放大。

3、提出了并行Compaction方案(即不同层到下一层的Compaction可以同时进行),同时还成功协调了LSM-Tree的Flush和Compaction,从而进一步减少写停滞,提高查询性能。

4、基于LevelDB实现了MioDB,并开源了代码。

设计原则

论文主张在MioDB中采用以下几项设计原则,以充分发挥NVM的优势:

1、NVM内管理KV数据的数据结构(如LSM-Tree传统的SSTable)应与DRAM Buffer的结构(如LSM-Tree的MemTable)兼容,以尽量减少数据序列化/反序列化的成本。

2、数据的Flush操作(从immutable MemTable到L0层)应与L0L1的Compaction协调进行,以彻底消除间隔停滞。下面给出间隔停滞的定义,如果immutable MemTable仍在Flush,而MemTable已满,则在immutable MemTable中的剩余数据完全Flush到L0中的SSTable之前,不会响应新的写入请求。应用发起的写入会因此停滞一段时间,这里将观察到的这段停滞称为间隔停滞。

3、多级skip list之间的数据Compaction应足够快,且不应造成写放大问题。

4、写入性能优化不应影响KV存储的读取性能。

论文实现

MioDB Architecture

MioDB Architecture

如图所示,MioDB仍然使用基于DRAM的MemTable来缓存写入请求。不过,MioDB使用skip list(PMTable)取代了传统的SSTable,以管理NVM中的持久化数据(需要注意这里没有SSD,数据最后只存放在NVM里)。在这里,论文仍然使用树状结构来表述跨多级的PMTable Compaction。然而,MioDB的逻辑结构与LSM-Tree的完全不同。在NVM中,MioDB维护着一个由L0Ln1层的多个PMTable组成的Elastic Buffer,以及一个Data Repository。

在LSM-Tree中,每层中的SSTable数量有限,而MioDB则不同,它可以在每层中容纳无限的PMTable,而不会阻碍One-Piece Flush或跨层PMTable之间的Compaction。

论文同时提到MioDB支持将Data Repository建立在大容量SSD上,也支持在Data Repository里用多级SSTable来替代PMTable,并仍然利用LSM-Tree原有的方法进行Compaction。这是为了兼容之前已经实现并运行的应用程序。

One-Piece Flushing

当DRAM中的MemTable已满时,MioDB应该将其变为immutable MemTable,并将其Flush到NVM中。在以往的基于LSM-Tree的KV存储(包括之前几篇使用NVM进行优化的工作)中,Flush操作会对MemTable中的KV逐一序列化并复制到磁盘上的SSTable中。

论文提出了One-Piece Flushing技术,以高效地将immutable MemTable持久化到NVM中。在MioDB中,MioDB为DRAM里的MemTable和NVM里的PMTable分配了相同大小的连续内存空间。同时DRAM里的MemTable结构和NVM里的PMTable结构是兼容的。这样只需一次memcpy,就能将MemTable转换为PMTable,而无需进行数据定位和数据合并。

One-Piece Flushing可以在后台执行,不会阻塞读/写操作。当一个MemTable已满时,MioDB会将其变为immutable MemTable,并创建一个新的MemTable来进行写操作。后台线程会将immutable MemTable从DRAM Flush到NVM。在此期间,DRAM中这个原先的immutable MemTable仍可响应读取请求,直到Flush结束后这个immutable MemTable占用的内存空间才会被回收。

Zero-Copy Compaction

Zero-Copy Compaction

MioDB利用NVM的字节可寻址特性和skip list的数据结构,为Elastic Buffer设计了一种Zero-Copy Compaction方案。各个PMTable里的Compaction只需更新skip list中的指针,而无需移动数据节点,因此大大降低了写放大和Compaction的成本。

这里把某一级中已经存在的PMTable称为oldtable,把新插入的PMTable称为newtable。在Elastic Buffer中,每次选择每一级中最旧的两个PMTable执行Compaction操作,然后将合并后的PMTable插入下一级。图(a)展示了oldtable和newtable的初始状态。PMTable中的数据节点按Key升序排序。用Seq表示KV对的序列号。为简单起见,把具有Key x和Seq y的数据节点称为节点Nxy。序列号越大,表示数据越新。具有相同Key的数据节点按Seq从大到小排序。在Zero-Copy Compaction过程中,会遍历newtable,并将节点逐个插入oldtable,最后将newtable占用的内存空间回收。

首先先看下图(a)里Nb6节点,其前驱Head节点表示共有3层PMTable,Nb6节点里的两个方块代表有2层PMTable里存在key为b。Nb6里有一个指针指向Nd7代表在一层PMTable里,同时存在key b和key d。Nb6里有另一个指针指向Nd5代表在另一层PMTable里,同时存在key b和key d,但是这个key d对应的Nd5Nd7更旧。

再讨论下唯一数据节点和重复数据节点。看图(a),newtable一行,Nd7里有一个指针指向Nd5,这代表有一层PMTable里,存在2个版本的key为d的KV项,这样Nd7Nd5就被认为是重复数据节点,相反的,我们也可以得出唯一数据节点的定义。

需要注意的是,Zero-Copy Compaction将每一层的newtable(实际上就是来自于上一层的oldtable)合并到该层的oldtable里,也把每一层最旧的PMTable(实际上也是oldtable)视为下一层的newtable,合并到下一层的oldtable里。

图(b)展示了如何将唯一数据节点(即key在一层的PMTable里只有一种版本)合并到oldtable中。以Nb6为例。首先,使用插入标记(Insertion mark,是一个指针变量)指向Nb6的地址,然后从newtable中删除Nb6。插入标记使得Nb6在Compaction过程中仍然可读。其次,Nb6的前驱节点中的指针应指向Nb6的后继节点。由于Nb6的高度为2,而每一层的后继节点分别为Nd7Nd5,因此前驱节点只需更改这两层的指针即可。最后,根据Key和Seq遍历oldtable,找到一个Key大于Nb6的节点。这个节点是Nc1。将Nb6作为Nc1的前驱节点,然后将Nb6成功插入oldtable。

图(c)展示了重复数据节点是如何合并到oldtable中的。如果同一层里一个key在两个PMTable里有不同版本的节点(即Seq不同),称该节点为重复节点。在图(b)里上述节点Nb6被执行Compaction后,继续遍历newtable并尝试处理Nd7。由于Nd5Nd7的Key相同,但Nd5时间更早(Seq更小代表更旧),因此应直接将Nd5从newtable中删除。

首先使用插入标记指向Nd7的地址,然后Nd7的前驱节点中的指针应指向Nd5的后继节点。然而,由于Nd5没有后继节点,newtable中head节点的指针被设置为空。接下来,根据Key和Seq将Nd7插入oldtable。遍历oldtable,找到一个Key大于Nd7的节点,或者其Key与Nd7相同但Seq小于Nd7的节点。在例子中,这个节点就是Nd4。因此,Nd7被插入到Nd4的前面。然后,在oldtable中找到Nd7之后的潜在冗余节点。由于Nd4Nd3Nd7更早,这里只需将Nd7中的指针设置为NULL,即可删除这些节点。最后,Nd7被合并到(下一层PMTable里的)新的PMTable中,较早的节点Nd5Nd4Nd3和newtable的head节点在逻辑上被删除(即被标记为可回收)。此时,newtable为空,并标记为可回收。被标记为可回收的PMTable占用的内存资源将在Ln1层的Lazy-Copy Compaction后被懒释放(即Lazy-Freeing,这里的Lazy指的是不立即释放,只有在Ln1层的Lazy-Copy Compaction后才释放)。

论文提出,利用插入标记,可以支持Compaction与Query同时进行。

Lazy-Copy Compaction

虽然Elastic Buffer能有效处理突发写入,但各级PMTable中的冗余数据(例如同一个key的更旧版本不会被查询到,是冗余的)会消耗大量NVM空间。另一方面,高度数据冗余也会降低查询性能。提出了Lazy-Copy Compaction来收集各级PMTable中的垃圾(冗余的KV项),并使用一个Huge PMTable作为Data Repository来存储剩余数据。注意到,Data Repository在MioDB架构里也是Ln层。

Lazy-Copy Compaction的过程如下:

1、在Ln1里,从尾部到头部逐个遍历PMTable。对于PMTable中的每个KV对,如果Ln中没有这个key的旧值,就将该数据节点复制到一个新创建的节点,并将其插入到Ln中。需要注意的是,只有Ln1中这个key的最新的数据节点才会被复制,其他具有相同key的更旧节点会被直接删除。

2、如果Data Repository(Ln层)中存在这个key较旧的数据节点(即第一步的条件不成立),就地将其更新为Ln1中最新的节点。

3、如果第一步条件成立,就会将新创建的节点插入到Ln中。同时,Ln是一个huge PMTable,而在一个PMTable里,对于同一个key,Seq更大的数据节点(即时间更新的数据节点)会放在Seq更小的数据节点左边。由于Ln1中的数据比Ln中的数据新,从(第1步里的)插入位置开始遍历Data Repository,直接删除这个key的旧节点。

4、Ln1中的所有PMTable都Compact到Ln中后,就可以回收L0Ln1中为空(或标记为可回收)的PMTable的内存(Zero-Copy Compaction里提到的Lazy-Freeing)。

与Zero-Copy Compaction不同,Lazy-Copy Compaction里从Ln1Ln的Compaction需要物理复制Ln1中最新的KV对,并将其插入到Huge PMTable中,因此Lazy-Copy Compaction的成本高于Zero-Copy Compaction。不过,由于多级Elastic Buffer可以高效处理突发写入,因此不会造成写停滞。

Parallel Compaction

由于MioDB只会对单层中最旧的PMTable进行Compaction,因此不同层中的数据Compaction是独立的。因此,论文提出采用并行的Compaction来加快所有层级的数据的Compaction的速度。

尽管RocksDB已经提供了并行Compaction的简单实现,但它对减少写停滞的作用十分有限。在MioDB中,因为Elastic Buffer每层的容量都不受限制,所以Elastic Buffer可以保证无阻塞的Compaction。同时,由于MioDB总是在单层中选择两个最旧的PMTable进行Compaction(来自上一层Compaction的PMTable至少是第三旧的),因此每层的Compaction与其他层的Compaction完全无关。由于上面两个原因,可以使用线程并行执行每层的Compaction。对于L0Ln2之间的层,一旦单层中有两个或两个以上PMTable,就会立即进行Compaction。

Read Optimizations

在每个PMTable中,数据都是有序的。因此,PMTable内查询速度非常快。然而,即使在单层中,多个PMTable的键范围也经常重叠。在最糟糕的情况下,查询线程必须逐个检索所有PMTable。为了提高读取性能,论文提出了以下两种优化方案:

1、增加单个PMTable的容量,以利用PMTable内快速查询的优势。

2、使用Bloom过滤器缩小查询范围。

Crash Consistency

与LevelDB和NoveLSM等基于LSM-Tree的KV存储类似,MioDB利用预写日志(WAL)来保证写入DRAM Buffer里的数据的故障恢复。对一个个KV项,MioDB首先按顺序将它们添加到NVM中的持久化日志中,然后插入到DRAM里的MemTable中。

因为DRAM Buffer的日志(存储在NVM里)可用于保证崩溃后的一致性,当DRAM Buffer中的数据被Flush到NVM时,MioDB可以直接将数据复制到NVM,而不需要单独的日志。假设在Flush时崩溃了,只需要NVM里的日志就可以恢复之前写入到MemTable的数据,再次Flush到NVM里。在Zero-Copy Compaction期间,由于MioDB使用原子性写入来更新PMTable中的指针,并且不移动数据节点,因此MioDB PMTable在系统崩溃后仍能保证数据的一致性。