You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

16 KiB

特别加餐 | 倒排检索加速(一):工业界如何利用跳表、哈希表、位图进行加速?

你好,我是陈东。欢迎来到检索专栏的第一次加餐时间。

很多同学在留言区提问,说基础篇讲了这么多检索的基础数据结构和算法,那它们在工业界的实际系统中是怎么应用的呢?真正的检索系统和算法又是什么样的呢?

为了帮助你把这些基础的知识,更好地和实际应用结合。我特别准备了两篇加餐,来和你一起聊一聊,这些看似简单的基础技术是怎样在工业界的实际系统中发挥重要作用的。

在许多大型系统中倒排索引是最常用的检索技术搜索引擎、广告引擎、推荐引擎等都是基于倒排索引技术实现的。而在倒排索引的检索过程中两个posting list求交集是一个最重要、最耗时的操作。

所以,今天我们就先来看一看,倒排索引在求交集的过程中,是如何借助跳表、哈希表和位图,这些基础数据结构进行加速的。

跳表法加速倒排索引

第5讲中我们讲过倒排索引中的posting list一般是用链表来实现的。当两个posting list A和B需要合并求交集时如果我们用归并法来合并的话时间代价是O(m+n)。其中m为posting list A的长度n为posting list B的长度。

那对于这个归并过程,工业界是如何优化的呢?接下来,我们就通过一个例子来看一下。

假设posting list A中的元素为<1,2,3,4,5,6,7,8……1000>这1000个元素是按照从1到1000的顺序递增的。而posting list B中的元素只有<1,500,1000>3个。那按照我们之前讲过的归并方法它们的合并过程就是在找到相同元素1以后还需要再遍历498次链表才能找到第二个相同元素500。

链表遍历,时间代价高

很显然为了找到一个元素遍历这么多次是很不划算的。那对于链表遍历我们可以怎么优化呢实际上在许多工业界的实践中比如搜索引擎还有Lucene和Elasticsearch等应用中都是使用跳表来实现posting list的。

在上面这个例子中我们可以将链表改为跳表。这样在posting list A中我们从第2个元素遍历到第500个元素只需要log(498)次的量级,会比链表快得多。

跳表查找,检索加速

这个时候你可能就会问了我们只能用B中的每一个元素去A中二分查找吗那在解答这个问题之前我们先来看下图这个例子。

相互二分查找

你会发现在寻找500这个公共元素的过程中我们是拿着链表B中的500作为key在链表A中进行跳表二分查找的。但是在查找1000这个公共元素的过程中我们是拿着链表A中的元素1000在链表B中进行跳表二分查找的。

我们把这种方法定义为相互二分查找。那啥叫相互二分查找呢?

你可以这么理解如果A中的当前元素小于B中的当前元素我们就以B中的当前元素为key在A中快速往前跳如果B中的当前元素小于A中的当前元素我们就以A中的当前元素为key在B中快速往前跳。这样一来整体的检索效率就提升了。

在实际的系统中如果posting list可以都存储在内存中并且变化不太频繁的话那我们还可以利用可变长数组来代替链表。

可变长数组的数组的长度可以随着数据的增加而增加。一种简单的可变长数组实现方案就是当数组被写满时我们直接重新申请一个2倍于原数组的新数组将原数组数据直接导入新数组中。这样我们就可以应对数据动态增长的场景。

那对于两个posting list求交集我们同样可以先使用可变长数组再利用相互二分查找进行归并。而且由于数组的数据在内存的物理空间中是紧凑的因此CPU还可以利用内存的局部性原理来提高检索效率。

哈希表法加速倒排索引

说到高效查询哈希表O(1)级别的查找能力令人印象深刻。那我们有没有能利用哈希表来加速的方法呢?别说,还真有。

哈希表加速的思路其实很简单,就是当两个集合要求交集时,如果一个集合特别大,另一个集合相对比较小,那我们就可以用哈希表来存储大集合。这样,我们就可以拿着小集合中的每一个元素去哈希表中对比:如果查找为存在,那查找的元素就是公共元素;否则,就放弃。

我们还是以前面说的posting list A和B为例来进一步解释一下这个过程。由于Posting list A有1000个元素而B中只有3个元素因此我们可以将posting list A中的元素提前存入哈希表。这样我们利用B中的3个元素来查询的时候每次查询代价都是O(1)。如果B有m个元素那查询代价就是O(m)。

将posting list A中的元素提前存入哈希表

但是使用哈希表法加速倒排索引有一个前提就是我们要在查询发生之前就把posting list转为哈希表。这就需要我们提前分析好哪些posting list经常会被拿来求交集针对这一批posting list我们将它们提前存入哈希表。这样我们就能实现检索加速了。

这里还有一点需要你注意原始的posting list我们也要保留。这是为什么呢

我们假设有这样一种情况当我们要给两个posting list求交集时发现这两个posting list都已经转为哈希表了。这个时候由于哈希表没有遍历能力反而会导致我们无法合并这两个posting list。因此在哈希表法的最终改造中一个key后面会有两个指针一个指向posting list另一个指向哈希表如果哈希表存在

除此之外哈希表法还需要在有很多短posting list存在的前提下才能更好地发挥作用。这是因为哈希表法的查询代价是O(m)如果m的值很大那它的性能就不一定会比跳表法更优了。

位图法加速倒排索引

我们知道位图其实也可以看作是一种特殊的哈希所以除了哈希表我们还可以使用位图法来改造链表。如果我们使用位图法就需要将所有的posting list全部改造为位图这样才能使用位图的位运算来进行检索加速。那具体应该怎么做呢我们一起来看一下。

首先我们需要为每个key生成同样长度的位图表示所有的对象空间。然后如果一个元素出现在该posting list中我们就将位图中该元素对应的位置置为1。这样就完成了posting list的位图改造。

接下来,我们来看一下位图法的查询过程。

如果要查找posting list A和B的公共元素我们可以将A、B两个位图中对应的位置直接做and 位运算复习一下and位运算0 and 0 = 0 0 and 1 = 0 1 and 1 = 1。由于位图的长度是固定的因此两个位图的合并运算时间代价也是固定的。并且由于CPU执行位运算的效率非常快因此在位图总长度不是特别长的情况下位图法的检索效率还是非常高的。

位运算

和哈希表法一样位图法也有自己的局限性。我总结了以下3点你可以感受一下。

  1. 位图法仅适用于只存储ID的简单的posting list。如果posting list中需要存储复杂的对象就不适合用位图来表示posting list了。
  2. 位图法仅适用于posting list中元素稠密的场景。对于posting list中元素稀疏的场景使用位图的运算和存储开销反而会比使用链表更大。
  3. 位图法会占用大量的空间。尽管位图仅用1个bit就能表示元素是否存在但每个posting list都需要表示完整的对象空间。如果ID范围是用int32类型的数组表示的那一个位图的大小就约为512M字节。如果我们有1万个key每个key都存一个这样的位图那就需要5120G的空间了。这是非常可怕的空间开销啊

在很多成熟的工业界系统中为了解决位图的空间消耗问题我们经常会使用一种压缩位图的技术Roaring Bitmap来代替位图。在数据库、全文检索Lucene、数据分析Druid等系统中你都能看到Roaring Bitmap的身影。

升级版位图Roaring Bitmap

下面我们来学习一下Roaring Bitmap的设计思想。

首先Roaring Bitmap将一个32位的整数分为两部分一部分是高16位另一部分是低16位。对于高16位Roaring Bitmap将它存储到一个有序数组中这个有序数组中的每一个值都是一个“桶”而对于低16位Roaring Bitmap则将它存储在一个2^16的位图中将相应位置置为1。这样每个桶都会对应一个2^16的位图。

Roaring Bitmap 存储过程

接下来如果我们要确认一个元素是否在Roaring Bitmap中存在通过两次查找就能确认了。第一步是以高16位在有序数组中二分查找看对应的桶是否存在。如果存在第二步就是将桶中的位图取出拿着低16位在位图中查找判断相应位置是否为1。第一步查找由于是数组二分查找因此时间代价是Olog n第二步是位图查找因此时间代价是O(1)。

所以你看,这种将有序数组和位图用倒排索引结合起来的设计思路,是能够保证高效检索的。那它到底是怎么节省存储空间的呢?

我们来看一个极端的例子。

如果一个posting list中所有元素的高16位都是相同的那在有序数组部分我们只需要一个2个字节的桶每个桶都是一个short型的整数因此只有2个字节。如果数组提前分配好了2^16个桶那就需要128K字节的空间因此使用可变长数组更节省空间。在低16位部分因为位图长度是固定的都是2^16个bit那所占空间就是8K个字节。

同样都是32位的整数这样的空间消耗相比于我们在位图法中计算的512M字节来说大大地节省了空间

你会发现,相比于位图法,这种设计方案就是通过,将不存在的桶的位图空间全部省去这样的方式,来节省存储空间的。而代价就是将高16位的查找从位图的O(1)的查找转为有序数组的log(n)查找。

那每个桶对应的位图空间,我们是否还能优化呢?

前面我们说过当位图中的元素太稀疏时其实我们还不如使用链表。这个时候链表的计算更快速存储空间也更节省。Roaring Bitmap就基于这个思路对低16位的位图部分进行了优化如果一个桶中存储的数据少于4096个我们就不使用位图而是直接使用short型的有序数组存储数据。同时我们使用可变长数组机制让数组的初始化长度是4随着元素的增加再逐步调整数组长度上限是4096。这样一来存储空间就会低于8K也就小于使用位图所占用的存储空间了。

总结来说一个桶对应的存储容器有两种分别是数组容器和位图容器其实还有一种压缩的runContainer它是对连续元素通过只记录初始元素和后续个数。由于它不是基础类型需要手动调用runOptimize()函数才会启用,这里就不展开说了)。那在实际应用的过程中,数组容器和位图容器是如何转换的呢?这里有三种情况。

第一种,在一个桶中刚插入数据时,因为数据量少,所以我们就默认使用数组容器

第二种随着数据插入桶中的数据不断增多当数组容器中的元素个数大于4096个时就从数组容器转为位图容器

第三种随着数据的删除如果位图容器中的元素个数小于4096个就退化回数组容器

这个过程是不是很熟悉?没错,这很像第3中的Hashmap的处理方法。

使用数组容器和位图容器并可以相互转换

好了前面我们说了这么多Roaring Bitmap的压缩位图空间的设计思路。下面我们回到两个集合A和B快速求交集的例子中一起来看一看Roaring Bitmap是怎么做的。假设这里有Roaring Bitmap表示的两个集合A和B那我们求它们交集的过程可以分为2步。

第1步比较高16位的所有桶也就是对这两个有序数组求交集所有相同的桶会被留下来。

第2步对相同的桶里面的元素求交集。这个时候会出现3种情况分别是位图和位图求交集、数组和数组求交集、位图和数组求交集。

其中,位图和位图求交集,我们可以直接使用位运算;数组和数组求交集,我们可以使用相互二分查找(类似跳表法);位图和数组求交集,我们可以通过遍历数组,在位图中查找数组中的每个元素是否存在(类似哈希表法)。这些方法我们前面都讲过了,那知道了方法,具体怎么操作就是很容易的事情了,你可以再自己尝试一下。

重点回顾

好了,今天的内容讲完了。我们来总结一下,你要掌握的重点内容。

在工业界,我们会利用跳表法、哈希表法和位图法,对倒排索引进行检索加速。

其中跳表法是将实现倒排索引中的posting list的链表改为了跳表并且使用相互二分查找来提升检索效率哈希表法就是在有很多短posting list存在的前提下将大的posting list转为哈希表减少查询的时间代价位图法是在位图总长度不是特别长的情况下将所有的posting list都转为位图它们进行合并运算的时间代价由位图的长度决定。

并且我们还介绍了位图的升级版本Roaring Bitmap。很有趣的是你会发现Roaring Bitmap求交集过程的设计实现本身就是跳表法、哈希表法和位图法的一个综合应用案例。

最后呢,我还想再多说两句。实际上,我写这篇文章就是想告诉你,基础的数据结构和算法组合在一起,就能提供更强大的检索能力,而且这也是大量的工程系统中广泛使用的设计方案。因此,深入理解每一种基础数据结构和算法的特点和适用场景,并且能将它们灵活应用,这能帮助你更好地学习和理解复杂的数据结构和算法,以及更好地学会如何设计复杂的高性能检索系统。

课堂讨论

最后,我们还是来看一道讨论题。

在Roaring Bitmap的求交集过程中有位图和位图求交集、数组和数组求交集、位图和数组求交集这3种场景。那它们求交集以后的结果我们是应该用位图来存储还是用数组来存储呢

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