gitbook/检索技术核心20讲/docs/233772.md
2022-09-03 22:05:03 +08:00

16 KiB
Raw Blame History

17 | 存储系统从检索技术角度剖析LevelDB的架构设计思想

你好,我是陈东。

LevelDB是由Google开源的存储系统的代表在工业界中被广泛地使用。它的性能非常突出官方公布的LevelDB的随机读性能可以达到6万条记录/秒。那这是怎么做到的呢这就和LevelDB的具体设计和实现有关了。

LevelDB是基于LSM树优化而来的存储系统。都做了哪些优化呢我们知道LSM树会将索引分为内存和磁盘两部分并在内存达到阈值时启动树合并。但是这里面存在着大量的细节问题。比如说数据在内存中如何高效检索数据是如何高效地从内存转移到磁盘的以及我们如何在磁盘中对数据进行组织管理还有数据是如何从磁盘中高效地检索出来的

其实这些问题也是很有代表性的工业级系统的实现问题。LevelDB针对这些问题使用了大量的检索技术进行优化设计。今天我们就一起来看看LevelDB究竟是怎么优化检索系统提高效率的。

如何利用读写分离设计将内存数据高效存储到磁盘?

首先对内存中索引的高效检索我们可以用很多检索技术如红黑树、跳表等这些数据结构会比B+树更高效。因此LevelDB对于LSM树的第一个改进就是使用跳表代替B+树来实现内存中的C0树。

解决了第一个问题。那接下来的问题就是内存数据要如何高效存储到磁盘。在第7讲中我们说过我们是将内存中的C0树和磁盘上的C1树归并来存储的。但如果内存中的数据一边被写入修改一边被写入磁盘我们在归并的时候就会遇到数据的一致性管理问题。一般来说这种情况是需要进行“加锁”处理的但“加锁”处理又会大幅度降低检索效率。

为此LevelDB做了读写分离的设计。它将内存中的数据分为两块一块叫作MemTable,它是可读可写的。另一块叫作Immutable MemTable,它是只读的。这两块数据的数据结构完全一样,都是跳表。那它们是怎么应用的呢?

具体来说就是当MemTable的存储数据达到上限时我们直接将它切换为只读的Immutable MemTable然后重新生成一个新的MemTable来支持新数据的写入和查询。这时将内存索引存储到磁盘的问题就变成了将Immutable MemTable写入磁盘的问题。而且由于Immutable MemTable是只读的因此它不需要加锁就可以高效地写入磁盘中。

好了数据的一致性管理问题解决了我们接着看C0树和C1树的归并。在原始LSM树的设计中内存索引写入磁盘时是直接和磁盘中的C1树进行归并的。但如果工程中也这么实现的话会有两个很严重的问题

  1. 合并代价很高因为C1树很大而C0树很小这会导致它们在合并时产生大量的磁盘IO
  2. 合并频率会很频繁由于C0树很小很容易被写满因此系统会频繁进行C0树和C1树的合并这样频繁合并会带来的大量磁盘IO这更是系统无法承受的。

那针对这两个问题LevelDB采用了延迟合并的设计来优化。具体来说就是先将Immutable MemTable顺序快速写入磁盘直接变成一个个SSTableSorted String Table文件之后再对这些SSTable文件进行合并。这样就避免了C0树和C1树昂贵的合并代价。至于SSTable文件是什么以及多个SSTable文件怎么合并我们一会儿再详细分析。

好了现在你已经知道了内存数据高效存储到磁盘上的具体方案了。那在这种方案下数据又是如何检索的呢在检索一个数据的时候我们会先在MemTable中查找如果查找不到再去Immutable MemTable中查找。如果Immutable MemTable也查询不到我们才会到磁盘中去查找。

因为磁盘中原有的C1树被多个较小的SSTable文件代替了。那现在我们要解决的问题就变成了如何快速提高磁盘中多个SSTable文件的检索效率。

SSTable的分层管理设计

我们知道SSTable文件是由Immutable MemTable将数据顺序导入生成的。尽管SSTable中的数据是有序的但是每个SSTable覆盖的数据范围都是没有规律的所以SSTable之间的数据很可能有重叠。

比如说第一个SSTable中的数据从1到1000第二个SSTable中的数据从500到1500。那么当我们要查询600这个数据时我们并不清楚应该在第一个SSTable中查找还是在第二个SSTable中查找。最差的情况是我们需要查询每一个SSTable这会带来非常巨大的磁盘访问开销。

因此对于SSTable文件我们需要将它整理一下将SSTable文件中存的数据进行重新划分让每个SSTable的覆盖范围不重叠。这样我们就能将SSTable按照覆盖范围来排序了。并且由于每个SSTable覆盖范围不重叠当我们需要查找数据的时候我们只需要通过二分查找的方式找到对应的一个SSTable文件就可以在这个SSTable中完成查询了。

但是要让所有SSTable文件的覆盖范围不重叠不是一个很简单的事情。为什么这么说呢我们看一下这个处理过程。系统在最开始时只会生成一个SSTable文件这时候我们不需要进行任何处理当系统生成第二个SSTable的时候为了保证覆盖范围不重合我们需要将这两个SSTable用多路归并的方式处理生成新的SSTable文件。

那为了方便查询我们要保证每个SSTable文件不要太大。因此LevelDB还控制了每个SSTable文件的容量上限不超过2M。这样一来两个SSTable合并就会生成1个到2个新的SSTable。

这时新的SSTable文件之间的覆盖范围就不重合了。当系统再新增一个SSTable时我们还用之前的处理方式来计算这个新的SSTable的覆盖范围然后和已经排好序的SSTable比较找出覆盖范围有重合的所有SSTable进行多路归并。这种多个SSTable进行多路归并生成新的多个SSTable的过程也叫作Compaction。

随着SSTable文件的增多多路归并的对象也会增多。那么最差的情况会是什么呢最差的情况是所有的SSTable都要进行多路归并。这几乎是一个不可能被接受的时间消耗系统的读写性能都会受到很严重的影响。

那我们该怎么降低多路归并涉及的SSTable个数呢第9讲我们提到过对于少量索引数据和大规模索引数据的合并我们可以采用滚动合并法来避免大量数据的无效复制。因此LevelDB也采用了这个方法将SSTable进行分层管理然后逐层滚动合并。这就是LevelDB的分层思想也是LevelDB的命名原因。接下来我们就一起来看看LevelDB具体是怎么设计的。

首先,**从Immutable MemTable转成的SSTable会被放在Level 0 层。**Level 0 层最多可以放4个SSTable文件。当Level 0层满了以后我们就要将它们进行多路归并生成新的有序的多个SSTable文件这一层有序的SSTable文件就是Level 1 层。

接下来如果Level 0 层又存入了新的4个SSTable文件那么就需要和Level 1层中相关的SSTable进行多路归并了。但前面我们也分析过如果Level 1中的SSTable数量很多那么在大规模的文件合并时磁盘IO代价会非常大。因此LevelDB的解决方案就是给Level 1中的SSTable文件的总容量设定一个上限默认设置为10M这样多路归并时就有了一个代价上限。

当Level 1层的SSTable文件总容量达到了上限之后我们就需要选择一个SSTable的文件将它并入下一层为保证一层中每个SSTable文件都有机会并入下一层我们选择SSTable文件的逻辑是轮流选择。也就是说第一次我们选择了文件A下一次就选择文件A后的一个文件下一层会将容量上限翻10倍这样就能容纳更多的SSTable了。依此类推如果下一层也存满了我们就在该层中选择一个SSTable继续并入下一层。这就是LevelDB的分层设计了。

尽管LevelDB通过限制每层的文件总容量大小能保证做多路归并时会有一个开销上限。但是层数越大容量上限就越大那发生在下层的多路归并依然会造成大量的磁盘IO开销。这该怎么办呢

对于这个问题LevelDB是通过加入一个限制条件解决的。在多路归并生成第n层的SSTable文件时LevelDB会判断生成的SSTable和第n+1层的重合覆盖度如果重合覆盖度超过了10个文件就结束这个SSTable的生成继续生成下一个SSTable文件。

通过这个限制,LevelDB就保证了第n层的任何一个SSTable要和第n+1层做多路归并时最多不会有超过10个SSTable参与,从而保证了归并性能。

如何查找对应的SSTable文件

在理解了这样的架构之后,我们再来看看当我们想在磁盘中查找一个元素时,具体是怎么操作的。

首先我们会在Level 0 层中进行查找。由于Level 0层的SSTable没有做过多路归并处理它们的覆盖范围是有重合的。因此我们需要检查Level 0层中所有符合条件的SSTable在其中查找对应的元素。如果Level 0没有查到那么就下沉一层继续查找。

而从Level 1开始每一层的SSTable都做过了处理这能保证覆盖范围不重合的。因此对于同一层中的SSTable我们可以使用二分查找算法快速定位唯一的一个SSTable文件。如果查到了就返回对应的SSTable文件如果没有查到就继续沉入下一层直到查到了或查询结束。

可以看到通过这样的一种架构设计我们就将SSTable进行了有序的管理使得查询操作可以快速被限定在有限的SSTable中从而达到了加速检索的目的。

SSTable文件中的检索加速

那在定位到了对应的SSTable文件后接下来我们该怎么查询指定的元素呢这个时候前面我们学过的一些检索技术现在就可以派上用场了。

首先LevelDB使用索引与数据分离的设计思想将SSTable分为数据存储区和数据索引区两大部分。

我们在读取SSTable文件时不需要将整个SSTable文件全部读入内存只需要先将数据索引区中的相关数据读入内存就可以了。这样就能大幅减少磁盘IO次数。

然后我们需要快速确定这个SSTable是否包含查询的元素。对于这种是否存在的状态查询我们可以使用前面讲过的BloomFilter技术进行高效检索。也就是说我们可以从数据索引区中读出BloomFilter的数据。这样我们就可以使用O(1)的时间代价在BloomFilter中查询。如果查询结果是不存在我们就跳过这个SSTable文件。而如果BloomFilter中查询的结果是存在我们就继续进行精确查找。

在进行精确查找时我们将数据索引区中的Index Block读出Index Block中的每条记录都记录了每个Data Block的最小分隔key、起始位置还有block的大小。由于所有的记录都是根据Key排好序的因此我们可以使用二分查找算法在Index Block中找到我们想查询的Key。

那最后一步就是将这个Key对应的Data block从SSTable文件中读出来这样我们就完成了数据的查找和读取。

利用缓存加速检索SSTable文件的过程

在加速检索SSTable文件的过程中你会发现每次对SSTable进行二分查找时我们都需要将Index Block和相应的Data Block分别从磁盘读入内存这样就会造成两次磁盘I/O操作。我们知道磁盘I/O操作在性能上和内存相比是非常慢的这也会影响数据的检索速度。那这个环节我们该如何优化呢常见的一种解决方案就是使用缓存。LevelDB具体是怎么做的呢

针对这两次读磁盘操作LevelDB分别设计了table cache和block cache两个缓存。其中block cache是配置可选的它是将最近使用的Data Block加载在内存中。而table cache则是将最近使用的SSTable的Index Block加载在内存中。这两个缓存都使用LRU机制进行替换管理。

那么当我们想读取一个SSTable的Index Block时首先要去table cache中查找。如果查到了就可以避免一次磁盘操作从而提高检索效率。同理如果接下来要读取对应的Data Block数据那么我们也先去block cache中查找。如果未命中我们才会去真正读磁盘。

这样一来我们就可以省去非常耗时的I/O操作从而加速相关的检索操作了。

重点回顾

好了今天我们学习了LevelDB提升检索效率的优化方案。下面我带你总结回顾一下今天的重点内容。

首先在内存中检索数据的环节LevelDB使用跳表代替B+树,提高了内存检索效率。

其次在将数据从内存写入磁盘的环节LevelDB先是使用了读写分离的设计增加了一个只读的Immutable MemTable结构避免了给内存索引加锁。然后LevelDB又采用了延迟合并设计来优化归并。具体来说就是它先快速将C0树落盘生成SSTable文件再使用其他异步进程对这些SSTable文件合并处理。

而在管理多个SSTable文件的环节LevelDB使用分层和滚动合并的设计来组织多个SSTable文件避免了C0树和C1树的合并带来的大量数据被复制的问题。

最后在磁盘中检索数据的环节因为SSTable文件是有序的所以我们通过多层二分查找的方式就能快速定位到需要查询的SSTable文件。接着在SSTable文件内查找元素时LevelDB先是使用索引与数据分离的设计减少磁盘IO又使用BloomFilter和二分查找来完成检索加速。加速检索的过程中LevelDB又使用缓存技术,将会被反复读取的数据缓存在内存中,从而避免了磁盘开销。

总的来说一个高性能的系统会综合使用多种检索技术。而LevelDB的实现就可以看作是我们之前学过的各种检索技术的落地实践。因此这一节的内容我建议你多看几遍这对我们之后的学习也会有非常大的帮助。

课堂讨论

  1. 当我们查询一个key时为什么在某一层的SSTable中查到了以后就可以直接返回不用再去下一层查找了呢如果下一层也有SSTable存储了这个key呢

  2. 为什么从Level 1层开始我们是限制SSTable的总容量大小而不是像在Level 0层一样限制SSTable的数量 提示SSTable的生成过程会受到约束无法保证每一个SSTable文件的大小

欢迎在留言区畅所欲言,说出你的思考过程和最终答案。如果有收获,也欢迎把这一讲分享给你的朋友。