一文了解LSM-Tree

1,126 阅读18分钟

我正在参加「掘金·启航计划」

什么是LSM-Tree

SSTables的结构

想要了解LSM-Tree(Log Structured Merge Tree,日志结构合并树),我们得先了解SSTables。SSTables即排序字符串表(Sorted String Table),也是LSM-Tree里的核心数据结构。它的概念来自Google的Bigtable论文,大概的意思就是,SSTable是一种可持久化,有序且不可变键值存储结构,key和value都可以是任意的字节数组,并且给提供了按照指定key查找和指定范围的key区间迭代遍历功能。

如下图所示,该图表示一个个日志结构存储数据段文件:为了避免最终用完磁盘空间,才将日志分为特定大小的段,对旧段文件进行压缩和合并,在新的日志文件中只保留每个键的最近更新。这种情况下,由于旧段的内容不会被修改,因此合并的段可以放入一个新的文件,旧段的合并和压缩都可以在后台线程完成,请求时仍然使用旧段处理,而完成合并后,就能直接删除旧段,使用新的合并段。

img

日志中稍后的值优先于日志中较早的相同键的值

怎么保证最近更新?

  • 每个段都包含在一段时间内写入数据库的所有值。这意味着一个输入段中的所有值必须比另一个段中的所有值更新(假设我们总是合并相邻的段)。当多个段包含相同的键时,我们可以保留最近段的值,并丢弃旧段中的值

合并几个SSTable段,只保留每个键的最新值。此处每个段的键都是唯一的,是经过了一个压缩的过程

img

我们对这些段文件要求键值对的序列按键来排序,且保证每个键在同一个段文件中只出现一次(在压缩过程中已经保证了,只保留最近更新),此外,SSTable具有内存索引。在SSTable的结构中,包含了若干的键值对,称为block,在其末尾,存储了一组元数据block记录数据block的描述信息,比如索引、BloomFilter、压缩、统计等信息,其中索引记录了这些数据block的键的偏移量

img

An SSTable provides a persistent, ordered immutable map from keys to values, where both keys and values are arbitrary byte strings. Operations are provided to look up the value associated with a specified key, and to iterate over all key/value pairs in a specified key range. Internally, each SSTable contains a sequence of blocks (typically each block is 64KB in size, but this is configurable). A block index (stored at the end of the SSTable) is used to locate blocks; the index is loaded into memory when the SSTable is opened. A lookup can be performed with a single disk seek: we first find the appropriate block by performing a binary search in the in-memory index, and then reading the appropriate block from disk. Optionally, an SSTable can be completely mapped into memory, which allows us to perform lookups and scans without touching disk.

在文件中查找一个特定的键不需要保存内存中所有键的索引,如下图所示,如果要找键handiwork,但不知道段文件中该key的确切偏移量,但是你知道handbaghandsome的偏移,而且由于key有序的特性,你还知道handiwork在其二者之间。因此可以跳到handbag的位置开始扫描,直到扫描至handsome。索引记录了一些键的偏移量,虽然可能会很稀疏,但是由于扫描文件的速度很快,每几千个字节段文件有一个key索引就可以了。

由于Read请求需要扫描请求范围内的多个键值对,因此可以将这些记录分组到块中,写入磁盘前对其压缩,如下图灰色部分,索引的每个条目都指向压缩块的开始处,即节省了磁盘空间,也减少了I/O带宽使用

如果每个key,value都是等长的(容易获取记录分界点),甚至可以直接在段文件上二分查找,避免了使用内存索引

img

在下文LSM-Tree的结构中可以看到SSTable一般是分level的,level级数越小,表示处于该level的SSTable越老,最大级数由系统设定。当某个 level下的文件数超过一定值后,就会将这个level下的一个SSTable文件和更高一级的SSTable文件合并,由于SSTable是有序的,合并过程相当于一次多路归并排序,速度较快。Leveled-N模型有着减小写放大作用。

LSM-Tree的概念和结构

大致了解了SSTables,下面我们来看看LSM-Tree

LSM-Tree是一种分层、有序、针对块存储设备特点设计的数据存储结构。它的核心思想在于将写入推迟(Defer)并转换为批量(Batch)写,首先将大量写入缓存在内存,当积攒到一定程度后,将他们批量写入文件中,这要一次I/O可以进行多条数据的写入,充分利用每一次I/O,从而实现高效地顺序写入数据。顺序写入的速度比随机写入的速度快很多,即追加内容,非就地更新,类似普通的日志写入方式以append的模式追加,不覆盖旧条目。

追加日志看起来很浪费,为什么不更新文件,以新值覆盖旧值?

  • 顺序写入速度比随机写入得多:追加和分段合并是顺序写入的操作,通常比随机写入快得多。某种程度上顺序写入在基于闪存的固态硬盘(SSD)上也是优选

    • 当然,较于随机写的B树,LSM树的写入速度会更快,而B树的读取速度更快,LSM树的读取速度比较慢。
  • 易于处理并发和崩溃恢复:段文件(SSTable)是附加的或不可变的,不必担心在覆盖值的时候发生崩溃情况导致将包含旧值和一部分新值保留在一起

  • 合并旧段可以避免数据文件随时间的推移而分散的问题

    • 这里指的是随着使用时间变长,不断随机写入导致的磁盘碎片的问题

img

LSM-Tree一般由两个或两个以上存储数据的结构组成,这些存储数据的结构也被称为组件(一般是多组件,只有C0在内存中,其余都在磁盘中),这里举个最简单的只有俩组件的例子,一个称为C0树,常驻内存中,可以是任何方便键值查找的数据结构,如AVL等结构,另一个称为C1树,常驻硬盘中,结构与B-Tree相似。C1在初始时为空,当内存C0的大小到一定程度的时候就要进行rolling merge,C0会将部分内容dump到C1中,将数据从小到大(从左到右)依次追加写到C1的一个multi-page block叶节点buffer中,如果buffer满了就将其写到硬盘,以此类推,直到C0扫描到最右,C1首次产生。当然,C0并不将所有的条目都拿来rolling merge, 由于C0存储在内存之中,所以C0可以保留最近插入或最常访问的那些数据,以提高访问速率并降低I/O操作的次数,C1中经常被访问的结点也将会被缓存在C0中

LSM在merge的时候如何把即将merge的数据定位到C1已经写入磁盘中的数据?

  • LSM在merge时可以从根结点开始逐级往下选取与C0的新数据最接近的数据,更加复杂的办法还可以考虑每次C0往C1 merge的数据的位置的频率

当存在以下情况时,C1目录节点会被强制刷盘

  • 包含目录节点的multi-page block缓存满了,只有该multi-page block会被刷盘

  • 根节点分裂,增加了C1的深度,所有multi-page block被刷盘

  • checkpoint被执行,所有multi-page block刷盘

  • rolling merge:可以想象为拥有一个概念上的游标,在C0和C1树的等值k-v之间缓慢穿梭移动,将C0索引数据取出放在C1树上

    • 当增长的C0树第一次到阙值
    • 最靠左的一系列条目会以高效批量形式从C0树中删除
    • 然后被按key递增顺序重组到C1树,C1树会被完全填满
    • 连续的C1树的叶节点会按从左到右顺序,首先放置到常驻内存的multi-page block内的若干初始页上
    • 直到该multi-page block被填满
    • 该multi-page block被刷盘,称为C1树叶节点层的第一部分,直接常驻硬盘
    • 随着连续的叶节点不断添加的过程,C1树的目录节点会在内存缓存中被创建(为了高效利用内存和硬盘,这些上层目录节点会被存放在单独的页(或multi-page block)缓存中,还有分隔点索引M,将访问精确匹配导向某个下一层级的单页节点而不是multi-page block。因此可以在rolling merge中使用multi-page block,索引精确匹配时访问单页节点)
  • multi-page block:不同于B-Tree,LSM-Tree的延时写(数据可以积攒)可以有效的利用multi-page block,在rolling merge的过程中,一次从C1中读出多个连续pages,与C0进行merge,然后一次向C1写回这些连续pages,这样有效利用单次I/O完成多个pages的读写(B-Tree在此场景下无法利用multi-page的优势)

  • batch:同样因为延迟写,LSM-Tree可以在rolling Merge中,通过一次I/O批量向C1写入C0多条数据,那么这多条数据就均摊了这一次I/O,减少磁盘的I/O开销

multi-page block及其结点结构

img

rolling merge

img

在上面的LSM-Tree结构图中,我们还看到了WAL和MemTable,以及Immutable MemTable。实际上,LSM-Tree的内存结构可以由一个MemTable和一个或多个Immutable MemTable组成。

  • MemTable往往是一个跳表组织的有序的数据结构(也可以是有序数组或红黑树等二叉搜索树),即支持高效的动态插入数据对数据进行排序,也支持高效的对数据进行精确查找和范围查找

  • Immutable Memtable是内存中只读的MemTable,由于内存是有限的,通常会设置一个阙值,当MemTable占有内存到阙值后就会转换为Immutable MemTable,与MemTable的区别在于它是只读的。它的存在是为了避免将MemTable中的内容序列化到磁盘中时会阻塞写操作

  • WAL结构与其余数据库的一致,是一个只能在尾部以append only追加记录的日志结构文件,用于系统崩溃重启时重放操作,使得MemTable与Immutable MemTable未持久化到磁盘中的数据不会丢失。严格来说,WAL并不是LSM-Tree数据结构的一部分,但是实际中,WAL却是数据库中不可缺少的一部分。每当内存数据写到SSTable时,相应的WAL日志就可以被丢弃

应用场景与优劣

应用

LSM-Tree是基于硬盘的数据结构,与B-Tree相比,能显著的减少硬盘磁盘臂的开销,并在较长时间内提供对文件的高速插入/删除,但在查询需要快速响应的时候性能不佳。通常LSM-Tree适用于索引插入比检索更频繁的应用系统,如日志系统,推荐系统,或者no sql数据库等,较为出名的有Lucene search engine、Google Big Table、LevelDB、ScyllaDB、RocksDB等等。

在大量应用服务上线的今天,每天都会产生大量的日志,需要存储下来便于服务监控、数据分析、排查定位问题和链路追踪等。在推荐系统中,也要每时每刻记录用户的行为信息,用于训练线上运行的推荐模型,且用户和内容的一些动态特征信息每天也要频繁更新,方便提供个性化服务。

优点

LSM-Tree的主要优势在于能推迟写回硬盘的时间,进而达到批量地插入数据的目的

  1. 减少写放大

写放大:在数据库声明中写入数据库导致对磁盘的多次写入。在写入繁重的应用程序中,性能瓶颈可能就是数据库写入磁盘的速度:存储引擎写入次数越多,可用磁盘带宽内每次写入次数越少

B-Tree必须至少两次写入每一段数据,即使一页中只有几个字节发生了变化,也需要一次编写整个页面的开销,而LSM-Tree的延时写能够充分利用每一次I/O ,一次I/O将多条数据写入,减少了写入磁盘的次数,有研究表明,LSM-Tree能够在可用I/O带宽内提供更多的读取和写入请求

  1. 比B树支持更高的写入吞吐量

顺序写入紧凑的SSTable文件而不是必须覆盖树中的几个页面,其中顺序写入比随机写入快得多

  1. 可以被压缩得更好

B-Tree存储引由于分割会留下一些未使用的磁盘空间,而LSM-Tree不是面向页面的,并且会定期重写SSTables以去除碎片,所以具有较低的存储开销

缺点

LSM-Tree的缺点主要在于空间放大和读放大,以及压缩过程有时会干扰正在进行的读写操作:如果一项数据更新多次,这项数据可能会存储在多个不同的SSTable中,甚至一项数据不同部分的最新数据内容存储在不同SSTable中(数据部分更新),导致读操作繁杂。一项数据在磁盘中存储了多份副本,老的副本是过时无用的,导致数据实际占用的存储空间比有效数据需要的大,即空间放大。在查询某个具体数据的时候,需要按新到老的顺序查找SSTable,直到找到所需的数据。如果目标数据在最底层Level-N的SSTable中,则要读取和查找所有的SSTable,即读放大问题

  • 磁盘资源有限,很容易发生请求需要等待。而磁盘完成昂贵的压缩操作,对吞吐量和平均响应时间的影响很小,但是某些时候会对日志结构化存储引擎的查询响应时间有时会很长。

  • 高写入吞吐量:磁盘的有限写入带宽需要在初始写入和在后台运行的压缩线程之间共享,写入空数据库时可以使用全磁盘带宽初始写入,但是数据库越大,需要的带宽就越大。如果写入吞吐量很高,并且压缩没有仔细配置,压缩可能会跟不上写入速率,需要明确的监控来检测。

当然,BigTable paper中有提到了几种优化方案

  1. 压缩(Compression):将多个SSTable合并为一个SStable,删除旧数据或标记为已删除的数据,降低空间放大,同时,减少SSTable,降低读放大。

  2. 布隆过滤器(BloomFilter):可以快速确定数据在不在SSTable中,避免了数据不存在时,遍历SSTable读取数据block内容带来的开销

  3. 缓存(Cache):因为SSTable不变,因此适合缓存到内存中,这样热点数据不需要访问磁盘(C0树其实是有缓存的)

  4. 合理的压缩策略:确定SSTables如何被压缩和合并的顺序和时间,常见的有大小分层(HBase),平坦压缩(LevelDB),混合压缩(RocksDB),先进先出等等

    • 在规模级别的调整中,更新和更小的SSTables先后被合并到更老且更大的SSTable中,而在水平压实中,关键范围会被拆分成更小的SSTable,而较旧的数据会被移动到单独的水平,使得压缩能够递增进行,并使用更少的磁盘空间
    • 单次写,频繁读场景选择平坦压缩策略
  5. 另有一文件存储SSTables的信息,包括level、最小key、最大key、压缩程度等等,即LevelDB的Manifest文件,在查找时先将Mainifest读到内存中判断,而无须将整个SSTable读入

实现及操作

构建LSM-Tree的整个流程中,如何让数据按键来排序呢?答案是在内存中来维护,因为在内存中维护总是比在磁盘中维护有序结构容易得多

  1. 写入数据时,将其添加到内存中的平衡树数据结构,即内存表MemTable
  2. 当内存表大于某个阈值(通常是几兆字节)的时候,将其作为SSTable文件写入磁盘中。新的SSTable则成为数据库的最新部分,当SSTable被写入磁盘时,会开一个新的MemTable继续写入数据
  3. 为了提供读取请求,会先在MemTable找到该关键字,然后在最近的磁盘段中,在下一个较旧的段中找到该关键字
  4. 有时会在后台运行合并和压缩来组合段文件并丢弃或覆盖旧值
  5. 同时需要在磁盘中保存一个单独的日志(WAL)来记录每个写入,防止数据库崩溃时,未写入磁盘的MemTable的数据丢失。该日志仅有的作用就是崩溃后恢复MemTable
  • 更新

更新即插入,在读取时总是从C0-Tree到Level0的SSTable到更老的SSTable,总是能读取到最新值

  • 删除

为了更高效地利用LSM-tree的插入优势,删除操作被设计为通过插入操作来执行。当要删除一个条目时,先在C0上找对应的索引是否存在,如果不在就建一个索引,在索引键值上设置删除条目,通知所有访问该索引的操作该条目已删除。后续滚动合并中,在较大CxTree中碰到与该索引键值相同的条目都将被删除。在C0查找该条目时,碰到该删除条目,会直接返回未找到。如果C0上找到该索引存在,则直接将删除条目覆盖该索引

崩溃恢复

在此之前,我们需要理解两个概念:empty blockfilling block

  • empty block:指那些在合并前已缓存、且包含旧的C1树节点的multi-page block,它们会被清空和移除
  • filling block:新的叶节点被写入与旧的multi-page block不同的已缓存的multi-page block,它们会被填满。填满后该multi-page block就会被写到磁盘的一个新的空闲位置

WAL中只需要记录数据插入的事务,即只包含被插入数据的行的号码以及插入的域LSM-Tree在记日志时设置checkpoint来恢复某一时刻的LSM-Tree,创建检查点的步骤如下:

  1. 将C0的内容写入硬盘中一个已知位置,对C0的条目插入操作可以再次开始,但是merge还得推迟
  2. 将硬盘中所有部件(C1-Tree,...,Cx-Tree)在内存中脏的缓存节点flush到硬盘中
  3. 向日志中写入一条特殊的checkpoint日志,而checkpoint日志中包括
    • T0时刻最后一个插入的索引行的日志序列号(LSN-0)
    • 硬盘中所有部件的根在硬盘里的地址
    • 各个部件的合并游标的位置
    • 当前用于动态分配新multi-page block的信息。在后面恢复过程中,硬盘存储的动态分配算法可以算出哪些multi-page block是能用的

写入checkpoint后,就可以进行恢复操作了。在重启或宕机的情况下找到这个checkpoint,将保存的部件C0和继续rolling merge所需的其他部件的缓冲block一起加载到内存中。然后,从LSN-0之后的第一个LSN开始的日志被读到内存,并把它们相关的索引条目加载到LSM-Tree中。处理到checkpoint时,包含所有索引信息的所有基于磁盘的部件的位置都记录在从根节点开始的部件目录中,可以在log中找到它们的位置。在恢复行的插入日志时,将新的条目放到C0中,此时rolling merge再次开始,并覆盖checkpoint以来写入的任何multi-page block直到最近插入的行都被写入索引,恢复操作才完成。

在恢复时进行各种磁盘写入操作会有一个大停顿,此时可以先在短时间内将C0部件的内容写到磁盘中,然后再其他写入磁盘的过程中恢复对C0部件的插入。

参考