搜索引擎项目-引擎基本原理和LevelDB| 青训营笔记

453 阅读11分钟

这是我参与「第三届青训营 -后端场」笔记创作活动的第1篇笔记。

搜索引擎基本技术原理

搜索引擎的实现过程或许可以称为“信息检索”,在《信息检索导论》一书中,这个概念定义如下:

信息检索是从大规模非结构化数据(通常是文本)的集合(通常保存在计算机上)中找出满足用户信息需求的资料(通常是文档)的过程。

简单的搜索过程的理解如下,逐个遍历所有文档,再根据文档内容逐渐检索关键字信息。但是在海量数据的前提下,这个做法显然是行不通的,或者说检索消耗的时间是我们不能忍受的。

倒排索引

为了更高效的检索,我们需要提前做一些预处理并建立相应的索引加速搜索。在搜索引擎中,这样的索引数据结构就是倒排索引(Inverted Index,也有人称为反向索引)。

正向索引和倒排索引

正向索引:当用户发起查询时(假设查询为一个关键词),搜索引擎会扫描索引库中的所有文档,找出所有包含关键词的文档,这样依次从文档中去查找是否含有关键词的方法叫做正向索引。

image.png 为了增加效率,搜索引擎会把正向索引变为反向索引(倒排索引)即把“文档→单词”的形式变为“单词→文档”的形式。如下所示:

image.png

通过倒排索引,可以根据单词快速获取包含这个单词的文档列表。倒排索引主要由两个部分组成:“单词词典”和“倒排文件”。

  • 单词词典(Lexicon) :搜索引擎的通常索引单位是单词,单词词典是由文档集合中出现过的所有单词构成的字符串集合,单词词典内每条索引项记载单词本身的一些信息以及指向“倒排列表”的指针。

  • 倒排列表(PostingList): 倒排列表记载了出现过某个单词的所有文档的文档列表及单词在该文档中出现的位置信息,每条记录称为一个倒排项(Posting)。根据倒排列表,即可获知哪些文档包含某个单词。

  • 倒排文件(Inverted File): 所有单词的倒排列表往往顺序地存储在磁盘的某个文件里,这个文件即被称之为倒排文件,倒排文件是存储倒排索引的物理文件。

image.png

布尔查询

根据倒排索引,可以得到一个词对应的文档列表。因此,如果希望检索的结果包含三个关键词的信息,例如small、 wild 和 cat,需要对以上关键词的文档列表进行聚合归并,这样就能得到包含所有词的文档列表。

image.png

搜索引擎系统架构

参考链接:developer.aliyun.com/article/765…

搜索引擎整体架构图如下图所示,大致可以分为搜集预处理索引查询这四步,每一步的技术细节都很多。

image.png

levelDB介绍

根据关键词获取文档列表的时候应该尽可能的快,同时考虑到数据量规模问题,即如果数据量较大的话,一定还会存储到磁盘上,那就涉及到一个存取效率的问题。这个时候levelDB就派上用场了。

leveldb 是一个持久化的 key/value 存储,key 和 value 都是任意的字节数组(byte arrays),并且在存储时,key 值根据用户指定的 comparator 函数进行排序。同时,由于磁盘的随机写效率远远不及顺序写,leveldb将数据落盘的随机写都转换为顺序写。

levelDB架构示意图

image.png

为了保证数据完整性,levelDB每次的写入请求(包括修改和删除), 都会先写入bin log到磁盘,然后再进行数据修改。这个过程也就是WAL技术,即Write-Ahead Logging,关键点就是先写日志,再写磁盘。

数据首先会写入到内存中一个叫做memtable的结构中,其底层实现是一个跳表(SkipList)。

跳表(SkipList)

跳表(Skiplist)是一个特殊的链表,相比一般的链表,有更高的查找效率,可比拟二叉查找树,平均期望的查找、插入、删除时间复杂度都是O(logn)

从有序表的搜索说起

参考链接,考虑一个有序表:

image.png

从该有序表中搜索元素 < 23, 43, 59 > ,需要比较的次数分别为 < 2, 4, 6 >,总共比较的次数为 2 + 4 + 6 = 12 次。有没有优化的算法吗? 链表是有序的,但不能使用二分查找。类似二叉搜索树,我们把一些节点提取出来,作为索引。得到如下结构:

image.png

提取出来作为一级索引,这样搜索的时候就可以减少比较次数了。我们还可以再从一级索引提取一些元素出来,作为二级索引,三级索引…

image.png

这里元素不多,体现不出优势,如果元素足够多,这种索引结构就能体现出优势来了。

跳表的结构

image.png

其中 -1 表示 INT_MIN, 链表的最小值,1 表示 INT_MAX,链表的最大值。 跳表具有如下性质:

  • 由很多层结构组成
  • 每一层都是一个有序的链表
  • 最底层(Level 1)的链表包含所有元素
  • 如果一个元素出现在 Level i 的链表中,则它在 Level i 之下的链表也都会出现。
  • 每个节点包含两个指针,一个指向同一链表中的下一个元素,一个指向下面一层的元素。

跳表检索

image.png 例子:查找元素 117

  1. 比较 21, 比 21 大,往后面找
  2. 比较 37, 比 37大,比链表最大值小,从 37 的下面一层开始找
  3. 比较 71, 比 71 大,比链表最大值小,从 71 的下面一层开始找
  4. 比较 85, 比 85 大,从后面找
  5. 比较 117, 等于 117, 找到了节点。

levelDB架构相关

Immutable MemTable本质上也是MemTable,ImmuTable是不可修改的意思:当MemTable中的内容超过阈值时,需要将其中的内容写到一个SSTable文件,ImmuTable MemTable就是这时候用的。

当一个MemTable在开始执行持久化之前,会先转化成ImmTable MemTable,可以认为是加上了不可修改的限制。另外,会再新建一个新的MemTable,用于维持服务。之后再将ImmuTable MemTable写入SSTable文件。

SSTable文件、mainfest文件

对于不更新的磁盘数据结构,并不需要使用B+ Tree。LevelDB使用了SSTable(Sorted String Table),意思是数据按照键的顺序存储在磁盘上。SSTable文件本质上是一个的key-value的序列表,并且其中的key是有序的。既然key是有序的,那就有最大值和最小值。我们把最大值和最小值记录下来,可以在查询的时候快速判断,这样我们可以知道要查询的key可能在哪个SSTable文件当中,从而加快查询效率。

写入SSTable后,之前的日志就可以被淘汰了,因为之前的数据已经持久化到磁盘上了。这就同时解决了上面的两个问题,恢复时间和数据库容量。

使用定期写入MemTable镜像的方式,解决了恢复和数据库容量的问题,并且数据库具有持久化的功能,在满足这些条件的情况下,数据库依然有很高的写性能,符合LSM Tree的写多的场景。

但是,这又引入了一个新的问题:读变慢了。以前读的时候,只需要读取MemTable,现在还需要读取SSTable。随着SSTable不断地写入,SSTable会越来越多,当查找一个键时,可能需要读取多个SSTable,这就涉及了多次随机读,读取效率会很低。

如果把多个SSTable合并成一个大的SSTable,那么查找时,就不需要读取多个小的SSTable,而只需要读取一个大的SSTable,磁盘IO就会减少。

Compaction

参考链接 将多个小SSTable合并成一个大SSTable,可以解决查找的效率问题。但是,如果不断地有新的小SSTable进来,这些小SSTable都需要和这个大的SSTable进行合并,不管多大的SSTable来合并,都需要读取所有的磁盘数据,并且写入所有的磁盘数据。

大的SSTable是按键顺序存储的,可以将大SSTable进行分割,分割成多个小SSTable,每个SSTable都包含一段键的范围,而每个SSTable键的范围是不重复的,并且是按顺序排列的。这时候物理上存在多个SSTable文件,但是逻辑上依然是有一个大的SSTable。在查找的时候只需要多做一步,根据每个小的SSTable包含键的范围,可以做一个二分搜索,就可以找到实际的键在哪个小SSTable文件里。这样依然只需要读取一个SSTable,但是却使用了多个小文件代替一个大文件。这样的好处就是当有新SSTable需要合并到这个逻辑上的大SSTable时,只需要找到和新SSTable的键范围有重合的小SSTable的物理文件进行合并,这样就可以降低合并需要读取的数据量。

这样磁盘文件实际上分为两部分:一部分是MemTable直接写入的SSTable,另一部分是逻辑上的大SSTable。这实际上就是LevelDB里面的Level 0和Level 1。Level 0文件的键范围可能有重叠(即新写入的MeMtable),而Level 1不会有重叠。而读取的时候,要读取Level 0和Level 1的数据,如果限制Level 0的文件数量,磁盘的读IO依然可以控制在一个常数范围内。

随着数据越写越多,Level 1越来越大,此时就算Level 0的SSTable很小,依然可能会和Level 1很多的SSTable文件重合,那么读写量(Level0合入Level1d的时候)依然很大。

这时候还需要改进,需要控制Level 1里所有文件的大小,那么多出的文件该怎么办呢?可以将它们再推到更高的一层Level 2中。Level 2里总文件大小设置为Level 1的10倍,在生成Level 1的SSTable时,控制大小使得最多与10个Level 2的SSTable重叠。如果需要将Level 1的一个SSTable合并到Level 2,需要读取的SSTable的数据量依然是一个常数级,是可控的。如果Level 2满了,可以将SSTable继续推向Level 3。因为每一Level的大小都是指数增长的,所以不需要几层,就可以放大量数据了。

这就是LevelDB名字的由来了,SSTable是分层存储的,Level 0的SSTable之间是重叠的,而Level 0以上的SSTable是有序不重叠的。SSTable在各Level之间移动的过程叫做Compaction,意思是让文件变得更加紧凑,易于查询。Level 0的SSTable会Compaction到Level 1,而Level 1的SSTable会Compaction到Level 2,随着Level的增大,每一层的文件总大小会以10倍增大,这样不但可以有大量的存储空间,而且每一次Compaction涉及的SSTable的数量都是可控的。Compaction实际上就是对输入的多个SSTable进行多路归并的过程。

随着Level的增多,读取更复杂了。要先读取MemTable,再读取Level 0的文件,Level 0可能有多个文件的键范围包括这个查找键,还需要读取Level 0以上的文件,每一Level最多有一个文件的键范围包括查找键。不过Level的数量有限,Level 0的文件数量也有限,所以需要读取的SSTable的数量依然是常数级,配合缓存、布隆过滤器等优化技术,可以提高读的性能。这是在读取性能和后台操作性能之间的折中,为了让写操作成为顺序写,而做的牺牲。

磁盘上有多个SSTable,需要知道每个SSTable属于哪一层,每一个SSTable的键范围,这些信息都存储在内存中。但是如果数据库重启了,就丢失了这些元信息,所以需要将它们持久化到磁盘。

MANIFEST

对于当前数据有哪些SSTable,这些SSTable属于哪一层,每一个SSTable的键范围和文件大小等信息,需要持久化到磁盘上,下一次打开数据库的时候,就可以从磁盘上读取到这些元数据,恢复内存里的数据结构,这个持久化数据就存储在MANIFEST文件中。

随着Compaction的进行, 元数据会改变,所以每次还需要将改变的元数据写到MANIFEST中。恢复元数据时,使用初始的元数据和各个改变恢复出最终的元数据。但是如果改变太多,MANIFEST太大,恢复就会太耗时,这时可以将当前的元数据写入到有一个新的MANIFEST中,而舍弃旧的MANIFEST。而CURRENT文件则存储了当前使用的MANIFEST文件是哪一个,写完MANIFEST后, 需要将CURRENT指向新的MANIFEST。