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

简介: LevelDB是Google开源的高性能键值存储系统,基于LSM树优化,采用跳表、读写分离、SSTable分层与滚动合并等技术,结合BloomFilter、缓存机制与二分查找,显著提升读写效率,广泛应用于工业级系统中。(239字)

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 顺序快速写入磁盘,直接变成一个个 SSTable(Sorted 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 文件的大小)
相关文章
|
1天前
|
搜索推荐 算法 UED
15 | 最近邻检索(上):如何用局部敏感哈希快速过滤相似文章?
在搜索引擎与推荐系统中,相似文章去重至关重要。通过向量空间模型将文档转化为高维向量,利用SimHash等局部敏感哈希技术生成紧凑指纹,结合海明距离与抽屉原理分段索引,可高效检索近似重复内容,在百亿网页中快速过滤雷同结果,提升用户体验。该方法适用于文本、图像等多种对象的相似性检测。
|
1天前
|
存储 自然语言处理 搜索推荐
09 | 索引更新:刚发布的文章就能被搜到,这是怎么做到的
本文介绍了工业界倒排索引的高效更新机制。针对小规模内存索引,采用Double Buffer实现无锁读写;对于大规模索引,则使用“全量+增量”索引方案,结合删除列表处理删改操作,并通过完全重建、再合并或滚动合并等策略管理增量数据,提升检索效率与系统稳定性。
|
1天前
|
人工智能 安全 Serverless
阿里云 Serverless 计算 11 月产品动态
阿里云 Serverless 计算 11 月产品动态。
4.今日作业
今日作业:优化 updateWeaponSkinPrice 函数,新增价格变动日志功能,修改价格时在控制台输出详细日志,记录变更信息,提升调试与追踪效率。
|
1天前
|
存储 监控 NoSQL
07 | NoSQL 检索:为什么日志系统主要用 LSM 树而非 B+ 树?
B+树适用于关系型数据库,但在日志、监控等高频写入场景下性能受限。LSM树通过将数据分内存(C0树)和磁盘(C1树)两层,利用批量写入、WAL日志恢复与滚动合并机制,大幅提升写入效率,更适合写多读少的大数据应用。
|
1天前
|
自然语言处理 运维 负载均衡
10 | 索引拆分:大规模检索系统如何使用分布式技术加速检索?
在大规模检索系统中,分布式技术通过拆分倒排索引提升性能。常见方案有基于文档的水平拆分和基于关键词的垂直拆分。前者将文档集分割,各分片独立建索引,支持并行检索与高效扩展;后者按词典切分,减少请求复制但易引发负载不均。工业界多采用文档拆分,因其扩展性好、运维简单,且可通过副本机制避免热点问题,适合搜索引擎、推荐系统等场景。
|
1天前
|
机器学习/深度学习 搜索推荐 算法
20 | 推荐引擎:没有搜索词,「头条」怎么找到你感兴趣的文章?
本文深入解析了资讯类App推荐引擎的检索技术。通过“下拉刷新”这一简单操作,系统能在无搜索词情况下精准推荐内容,核心在于个性化召回机制。文章详细讲解了基于内容的召回与协同过滤(用户/物品)两大算法,揭示其如何通过用户行为数据、画像构建及向量相似度计算实现高效推荐,并探讨多策略混合与分层排序的工程实践,展现推荐系统的智能与灵活性。
|
1天前
|
存储 搜索推荐 定位技术
14 | 空间检索(下):「查找最近的加油站」和「查找附近的人」有何不同?
本文探讨了动态范围内查找“最近的k个”地理对象的高效检索方案。针对查询范围不固定的应用场景,如找最近加油站或医院,传统GeoHash分块检索效率低。文章提出利用四叉树、非满四叉树和前缀树优化:四叉树通过层次化空间划分支持快速范围扩展;非满四叉树动态分裂节点,提升稀疏数据下的存储利用率;前缀树则适用于GeoHash字符串编码的索引,实现高效路径匹配。进一步介绍了k-d树在高维空间的应用局限,并引出高维场景下的近邻检索挑战。
|
1天前
|
存储 NoSQL 定位技术
13 | 空间检索(上):如何用 Geohash 实现「查找附近的人」功能?
本文介绍了如何高效实现“查找附近的人”功能,针对大规模系统提出基于区域划分与Geohash编码的检索方案。通过将二维空间划分为带编号的区域,并利用一维编码(如Geohash)建立索引,可大幅提升查询效率。支持非精准与精准两种模式:前者直接查所在区域,后者结合邻近8区域扩大候选集以保证准确性。Geohash将经纬度转为字符串编码,便于存储与比较,广泛应用于Redis等系统。适用于社交、餐饮、出行等LBS场景。
|
1天前
|
存储 算法 关系型数据库
06丨数据库检索:如何使用 B+ 树对海量磁盘数据建立索引?
本节深入探讨磁盘环境下大规模数据检索的挑战与解决方案,重点解析B+树如何通过减少磁盘访问次数、实现索引与数据分离,支持高效查找、范围查询及动态调整,成为数据库索引的核心技术。