甩出11张图-让我们来构想(实现)一个倒排索引

1,722 阅读12分钟

甩出11张图-让我们来构想(实现)一个倒排索引

数据检索系列文章

倒排索引的简介

在介绍倒排索引之前,先看看传统b+tree索引是如何存储数据的,每次新增数据的时候,b+tree就会往自身节点上添加上新增数据的key值,如果节点达到了分裂的条件,那么还会将一个节点分裂成两个节点。

想一个场景,如果对用户的性别建立b+tree索引,性别只有男女之分,这样存在b+tree里是不是会存很多重复的key,如果用户数据量很大,我们想筛选出性别是男性的用户,是不是要遍历大量的数据。

而这种选择性不高的数据,用倒排索引来做就很合适,倒排索引在面对那么多用户数据的情况下,只会存两个key值,分别是男女,然后key对应的value值就是用户id的集合,能一下就找到特定性别的用户。

那么倒排索引究竟长什么样呢?

对性别建立倒排索引

还是拿刚才那个场景举例,用于查询的key,构成倒排索引的词典,而每个词(key)都对应一个倒排列表,例如男性的key 对应的倒排列表 就包含了所有性别为男性的uid,而女性的key对应的则是另一个倒排列表,这个列表的uid全为女性。

那么这样的索引结构,我们究竟应该如何来实现呢。为了简单起见,先来构建一个在内存上能用的倒排索引。

第一版 实现一个内存上的倒排索引

当我们在搜索特定key对应的uid集合时,目的是找到容纳uid集合的倒排列表,要找到倒排列表就必须找到与之对应的在词典中的key值,所以我们先来看看如果你来实现一个词典的结构,你会怎么做。

如何实现词典与倒排列表

词典不外乎就是在一堆key值中找到某个特定的key值,我们可以直接采用hash结构嘛。直接将词典中的每个key设置为hashmap的key,倒排列表设置为hashmap的value值,这样不就行了吗。

而关于倒排列表的实现是不是可以简单的用一个集合来表示呢?这样我在查男性或者女性的时候直接取出对应的集合,这样不就实现了吗?

当然,这是最简单的版本,试想下其他场景,比如对用户标签建立倒排索引,一个用户可以打上多个标签,现在需要找出标签同时为标签A和标签B的用户,应该怎么查询。 集合版本倒排列表

如图,我们的倒排列表是集合,首先是不是可以找到标签A和标签B各自的倒排列表,集合无序但是可以遍历,两个for循环遍历两个倒排列表便可以求出其中即属于标签A又属于标签B的用户了。假设两个倒排列表的长度分别是m,n,那么这样的时间复杂度将会是O(m*n)。

能不能优化呢?可以。

链表版本倒排列表

如上图所示,如果将倒排列表设计成有序链表,那么是不是可以用归并排序的方式来遍历两个列表,这样时间复杂度是O(m+n),链表查询是快了,但是我们在往倒排列表中插入数据时,还得判断数据是不是已经存在链表中,这样就得遍历整个链表,能不能优化这个过程呢?可以。

我们遍历无非就是要找到这个数据是不是已经在倒排列表中了,如果在的话,我就不插入链表了。

那么我们在最初插入链表时,除了将数据插入链表,还要再将数据插入到一个hash或者集合里,等到后续再插入数据的时候,首先查看map里是否存在相同数据,相同则不进行接下来的插入工作,这样是不是可以优化掉一部分链表的无效遍历了。并且如果将倒排链表设计成双向链表,在删除的时候也可以先查看hash结构查找到需要删除的节点,然后直接根据节点的前后指针,便可以在O(1)的时间复杂度完成删除操作。 如图:

链表+hash 倒排列表

好了,说了这么多,关于内存上如何实现一个倒排索引,我觉得大家完全可以发挥自己的想象,在不同的场景下选择不同的数据结构进行组装,便能很轻松的实现一个基于内存的倒排索引。接下来,我们来看看,当倒排索引越来越大,大到内存放不下的时候,我们又该怎么做。

第二版 实现一个磁盘上的倒排索引

既然是数据存储,必然倒排索引会有内存不能完全放下的一天,这个时候,想想看,有没有什么办法能在磁盘上很好的表示一个倒排索引结构?

如何实现词典

内存上可以用hashmap来存储词典的key值,但是我们应该如何将词典存在磁盘上呢,还记得之前提到过的b+tree(看了还不懂b+tree本质就来打我)吗,它能很好的应对磁盘随机读的情况,正好可以拿来应用到词典对key值的查询上。

那么如何来实现倒排列表呢? 既然存在磁盘上,那是不是也可以用b+tree存储呢?其实也是可以的,不过这样的设计会导致读取倒排列表不会按文档id递增读取,并且由于倒排列表不是递增,那么在多条件查询时,将不能用多路归并的方法进行文档id的合并,提高了查询时间复杂度。

既然这样,那我们就将数据结构设计成顺序的好了,还记得前面实现内存上的倒排列表时采用的什么数据结构吗,有序链表。那么是不是可以直接将数据顺序写入到磁盘就行了,比如按文档id是int类型且占8个字节计算,那么每次读取按8字节的步长就可以读取每一个文档id了。我们将磁盘想像成一个超大容量的数组。

我们的倒排索引将会变成这样。

顺序存储倒排列表

词典中的key值指向key在磁盘上的开头位置。

可以看出,如果按固定步长进行磁盘存储,其实存储的是一个有序数组的结构.这样可以很好利用磁盘顺序读写的高效性特点。

但是这样会面临倒排索引更新的问题,因为在磁盘上有序存储,如果要在这个倒排列表上新增一个文档id,那么要移动磁盘数据,这样的代价显然太大。

如果把磁盘上的数据结构变成一个有序链表呢,每次存储时按有序链表的节点进行存储,那么每个节点除了要包含文档id的8个字节,还要再包含指向下一个节点的位置。这样新增一个文档id,是不是可以往倒排列表所在的磁盘空间末尾新增一个节点的空间,然后让倒排节点的末尾节点指向新增的节点即可,不用移动磁盘数据。

这样我们的倒排索引在磁盘上就会变成下面这张图的样子,各个倒排链表之间是通过指针关联在了一起,而词典的key指向的是链表头部的元素在磁盘上的位置。

随机存储倒排列表

但是,又是一个但是,如果对这样的倒排列表频繁进行删除和更新会怎么样,之前在讲b+tree本质的时候,有说过由于节点的删除和更新,b+tree相邻父子节点之间只是位置相邻,在磁盘空间上可能并不相邻。

对于链表来说也是一样,频繁的插入和删除就会导致顺序的数据在磁盘上可能分布在不同磁道了,可以看到,同一个key的倒排列表在磁盘上并不相邻, 在读取倒排列表时,将会有过多的随机读产生,严重影响性能。

既然这样,能不能借鉴下LSM对索引合并的思路(剖析LSM索引原理),对索引的更新采取合并的策略,而不是原地更新的方式,比如,在新增文档时,会去构建增量的词典和倒排索引,这一部分索引暂时不可见,只有等到这部分索引和历史可见索引进行合并后,才会真正被搜索到。

如图所示,同一个颜色属于同一个倒排列表。

倒排列表全量合并

这样虽然牺牲了一点立即可见性,但由于合并时是写入到一个新的倒排文件里,将会是顺序写,同一个倒排列表基本是在相同的磁道上,这样读取倒排列表时便可以有很好的读取性能。

采用索引合并的策略是不是就没有其他问题呢?它同样面临索引合并的常见问题,一个小数量级的索引和大数量级的索引进行合并时,会产生很多无效合并。新的增量的倒排索引是比较少量的,而历史的索引数量级是比较庞大的,每次由于新增的倒排索引需要合并就要去遍历整个历史的索引文件显然是划不来的。

一个比较简单的解决办法就是,直接将大的索引文件拆分成很多个小段的索引文件,我们将这些由大索引分割的小索引文件叫做segment,每个segment是有它自己的词典和倒排列表

选择合并索引的策略也是选择大小相近的segment索引文件进行合并,每个小的索引文件里有各自的词典和倒排列表。这样查询的时候可能就麻烦点,需要查询多个小的索引文件,不过这样的查询完全可以采用多cpu同时并行查询进行加速。如下图所示,是将增量的倒排索引合并到存量索引的过程,合并时,只将segment2和segment3进行合并。

倒排列表部分合并

注意虽然将这种分割后的小词典和它的倒排列表的组合称作segment,但是segment在磁盘上并不一定是只用一个文件,我们完全可以对segment进行编号后,通过不同的后缀对不同类型的文件进行取名。如图所示:

在磁盘上的索引文件

假设 文件segment1.dc代表segment1的词典,segment1.lt文件代表segment1的倒排列表。.dc后缀表明这是一个词典文件,.lt后缀表明这是存储一个倒排列表的文件。

再回到起初 词典是怎么存储在磁盘上的问题上,我们采用了b+tree结构,但是将索引分成多个小的索引文件之后,我们要想查某个词对应的倒排列表,是不是要每个小的索引文件都要去用小索引文件他们各自的b+tree词典去查一遍存不存在这个词呢?这样即使是多cpu查询,但是大量的随机读会把查询瓶颈压在磁盘上。

词典存磁盘访问慢是因为词典无法存在内存里,词典无法存储在内存上的原因是词典过于大了,所以我们得想办法压缩词典的大小。

所以,有没有什么办法能压缩词典的大小呢?

我将直接揭晓答案,采用前缀树的数据结构能很有效的将词典的体积缩小到内存能够容纳的范围。前缀树的节点包含了词中的每个字母,并且表明了从根节点到此节点上的路径是否构成了一个词典中的词。如图:

前缀树

绿色的节点代表从根节点到该节点的路径上存在一个词,这颗前缀树能够代表的字母为get,go,good。比如英文字母只有26个,这样用前缀树组合形成的词语存储方式可以极大的省掉大量前缀相同的词的存储空间。

这样,每个小的索引文件(segment)便可以将词典对应的前缀树存储到内存中了。这下我们来完成倒排索引最后的架构图。

倒排索引成品

每一个segment索引文件都有自己的前缀树结构在内存中。

整个倒排索引的查询过程就变成了先查内存中的前缀树结构,找到特定的词所在segment.dl词典文件的位置,我们实现词典文件的存储采用b+tree的方式,然后通过查询词典文件,再找到具体的倒排列表的位置,我们采用有序链表顺序写入倒排列表的方式让词典中属于同一个词的倒排列表中的元素尽量相邻,提高倒排列表从磁盘读取的效率。

至此,我们实现了一个基于磁盘的倒排索引,但优化远远没有结束。例如,业界上在实现倒排列表时,为了极致的压缩存储空间,采用了一些压缩方案例如Frame of ,并且为了提高联合查询效率,将倒排链表设计成跳表,将倒排列表查询结果缓存起来等等。希望我的文章能抛砖引玉,引发大家更多的思考。

创作不易,如果觉得我的文章对你有帮助,关注一下,点个赞吧😄。