目录
前言:
什么是倒排索引?
FST原理简析
如何联合索引查询?
Elasticsearch 是通过 Lucene 的倒排索引技术实现比关系型数据库更快的过滤。特别是它对多条件的过滤支持非常好,比如年龄在 18 和 30 之间,性别为女性这样的组合查询。但是其比关系型数据库的 b-tree 索引快在哪里?到底为什么快呢?
笼统的来说,b-tree 索引是为写入优化的索引结构。当我们不需要支持快速的更新的时候,可以用预先排序等方式换取更小的存储空间,更快的检索速度等好处,其代价就是更新慢。
什么是B-Tree索引?
为了可以更清晰的表述,首先创建一个索引,并存储若干数据,创建的索引名称为student,它有5个字段,分别是id,name,age,gender,address. 下面是创建索引和存储数据的DSL语句:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 |
|
将student索引转换成关系型数据库的一张表的话,大概是下面这个样子:
下面介绍几个在倒排索引中比较重要的名词:
Term(单词):一段文本经过分析器分析以后就会输出一串单词,这一个一个的就叫做Term.
Term Dictionary(单词字典):顾名思义,它里面维护的是Term,可以理解为Term的集合. Elasticsearch为了能快速找到某个term,将所有的term排个序,二分法查找term,logN的查找效率,就像通过字典查找一样。
Term Index(单词索引):为了更快的找到某个单词,我们为单词建立索引.
Posting List(倒排列表):倒排列表记录了出现过某个单词的所有文档的文档列表及单词在该文档中出现的位置信息,每条记录称为一个倒排项(Posting)。根据倒排列表,即可获知哪些文档包含某个单词
实际的倒排列表中并不只是存了文档ID那么简单,还有一些其它的信息,比如:词频(Term出现的次数)、偏移量(offset)等。
如果将倒排索引类比成现代汉语词典的话,那么Term就相当于词语,Term Dictionary相当于汉语词典本身,Term Index相当于词典的目录索引。
每个文档都有一个ID,如果插入的时候没有指定ID的话,ElasticSearch会自动生成一个。
上面的例子,ElasticSearch建立的索引大致如下:
ElasticSearch分别为上面的每个字段都建立了一个倒排索引。比如,在上面“樱木花道”、“男”、18这些都是Term,而[1,2]就是Posting List。Posting List就是一个数组,存储了所有符合某个Term的文档ID.
只要知道文档ID,就能快速找到文档,可是要怎样通过我们给定的关键词快速找到这个Term呢?答案当然是:建立索引,也就是上文中所提到的Term Index(单词索引)
在倒排索引中,通过Term Index可以找到Term在Term Dictionary中的位置,进而找到Posting List,有了倒排列表就可以根据ID找到文档了
其实我们前面分了三步,我们可以把Term Index 和 Term Dictionary看成一步,就是找Term。因此,可以这样理解倒排索引:通过单词找到对应的倒排列表,根据倒排列表中的倒排项进而可以找到文档记录。
接下来我们详细的介绍一下Term Index :
B-Tree通过减少磁盘寻道次数来提高查询性能,Elasticsearch也是采用同样的思路,直接通过内存查找term,不读磁盘,但是如果term太多,term dictionary也会很大,放内存不现实,于是有了Term Index,就像字典里的索引页一样,A开头的有哪些Term,分别在哪页,可以理解Term Index是一棵树,如下图所示:
注意:这棵树不会包含所有的term,它包含的是term的一些前缀
通过term index可以快速地定位到term dictionary的某个offset,然后从这个位置再往后顺序查找。查找Term →Term Index → Term Dictionary → Posting List在ElasticSearch的过程如下图所示:
所以Term Index 不需要存下所有的Term,而仅仅是他们的一些前缀与Term Dictionary的block之间的映射关系,再结合FST(Finite State Transducers)的压缩技术,可以使Term Index缓存到内存中。从Term Index查到对应的Term Dictionary的block位置之后,再去磁盘上找Term,大大减少了磁盘随机读的次数。
现在我们可以回答“为什么 Elasticsearch/Lucene 检索可以比 Mysql 快了。Mysql 只有 Term Dictionary 这一层,是以 b-tree 排序的方式存储在磁盘上的。检索一个 term 需要若干次的 random access 的磁盘操作。而 Lucene 在 Term Dictionary 的基础上添加了 Term Index 来加速检索,Term Index 以树的形式缓存在内存中。从 Term Index 查到对应的 Term Dictionary 的 block 位置之后,再去磁盘上找 Term,大大减少了磁盘的 Random Access 次数。
FST有两个优点:
1)空间占用小。通过对词典中单词前缀和后缀的重复利用,压缩了存储空间;
2)查询速度快。O(len(str))的查询时间复杂度。
下面简单描述下FST的构造过程。我们对“cat”、 “deep”、 “do”、 “dog” 、“dogs”这5个单词进行插入构建FST(注:必须已排序)
1)插入“cat”
插入cat,每个字母形成一条边,其中t边指向终点。
2)插入“deep”
与前一个单词“cat”进行最大前缀匹配,发现没有匹配则直接插入,P边指向终点。
3)插入“do”
与前一个单词“deep”进行最大前缀匹配,发现是d,则在d边后增加新边o,o边指向终点。
4)插入“dog”
与前一个单词“do”进行最大前缀匹配,发现是do,则在o边后增加新边g,g边指向终点。
5)插入“dogs”
与前一个单词“dog”进行最大前缀匹配,发现是dog,则在g后增加新边s,s边指向终点。
最终我们得到了如上一个有向无环图。利用该结构可以很方便的进行查询。
如给定一个term “dog”,我们可以通过上述结构很方便的查询存不存在,甚至我们在构建过程中可以将单词与某一数字、单词进行关联,从而实现key-value的映射。
所以给定查询过滤条件 age=18 的过程就是先从 term index 找到 18 在 term dictionary 的大概位置,然后再从 term dictionary 里精确地找到 18 这个 term,然后得到一个 posting list 或者一个指向 posting list 位置的指针。然后再查询 gender= 女 的过程也是类似的。最后得出 age=18 AND gender= 女 就是把两个 posting list 做一个“与”的合并。这个理论上的“与”合并的操作可不容易。对于 mysql 来说,如果你给 age 和 gender 两个字段都建立了索引,查询的时候只会选择其中最 selective 的来用,然后另外一个条件是在遍历行的过程中在内存中计算之后过滤掉。 那么要如何才能联合使用两个索引呢?有两种办法:
1)使用 skip list 数据结构。同时遍历 gender 和 age 的 posting list,互相 skip。
2)使用 bitset 数据结构,对 gender 和 age 两个 filter 分别求出 bitset,对两个 bitset 做 AN 操作。
Elasticsearch 支持以上两种的联合索引方式,如果查询的 filter 缓存到了内存中(以 bitset 的形式),那么合并就是两个 bitset 的 AND。如果查询的 filter 没有缓存,那么就用 skip list 的方式去遍历两个 on disk 的 posting list。
如何利用Skip List合并
以上是三个 posting list。我们现在需要把它们用 AND 的关系合并,得出 posting list 的交集。首先选择最短的 posting list,然后从小到大遍历。遍历的过程可以跳过一些元素,比如我们遍历到绿色的 13 的时候,就可以跳过蓝色的 3 了,因为 3 比 13 要小。
最后得出的交集是 [13,98],所需的时间比完整遍历三个 posting list 要快得多。但是前提是每个 list 需要指出 Advance 这个操作,快速移动指向的位置。什么样的 list 可以这样 Advance 往前做蛙跳?skip list:
从概念上来说,对于一个很长的 posting list,比如:
[1,3,13,101,105,108,255,256,257]
我们可以把这个 list 分成三个 block:
[1,3,13] [101,105,108] [255,256,257]
然后可以构建出 skip list 的第二层:
[1,101,255]
1,101,255 分别指向自己对应的 block。这样就可以很快地跨 block 的移动指向位置了。
Lucene 自然会对这个 block 再次进行压缩。其压缩方式叫做 Frame Of Reference 编码。
比如一个词对应的文档id列表为[73, 300, 302, 332,343, 372] ,id列表首先要从小到大排好序;第一步增量编码就是从第二个数开始每个数存储与前一个id的差值,即300-73=227,302-300=2。。。,一直到最后一个数;第二步就是将这些差值放到不同的区块,Lucene使用256个区块,下面示例为了方便展示使用了3个区块,即每3个数一组;第三步位压缩,计算每组3个数中最大的那个数需要占用bit位数,比如30、11、29中最大数30最小需要5个bit位存储,这样11、29也用5个bit位存储,这样才占用15个bit,不到2个字节,压缩效果很好,如下面原理图所示:
考虑到频繁出现的 term(所谓 low cardinality 的值),比如 gender 里的男或者女。如果有 1 百万个文档,那么性别为男的 posting list 里就会有 50 万个 int 值。用 Frame of Reference 编码进行压缩可以极大减少磁盘占用。这个优化对于减少索引尺寸有非常重要的意义。当然 mysql b-tree 里也有一个类似的 posting list 的东西,是未经过这样压缩的。因为这个 Frame of Reference 的编码是有解压缩成本的。利用 skip list,除了跳过了遍历的成本,也跳过了解压缩这些压缩过的 block 的过程,从而节省了 cpu。
如何利用bitset合并
Bitset 是一种很直观的数据结构,对应 posting list 如:
[1,3,4,7,10]
对应的 bitset 就是:
[1,0,1,1,0,0,1,0,0,1]
每个文档按照文档 id 排序对应其中的一个 bit。Bitset 自身就有压缩的特点,其用一个 byte 就可以代表 8 个文档。所以 100 万个文档只需要 12.5 万个 byte。但是考虑到文档可能有数十亿之多,在内存里保存 bitset 仍然是很奢侈的事情。而且对于个每一个 filter 都要消耗一个 bitset,比如 age=18 缓存起来的话是一个 bitset,18<=age<25 是另外一个 filter 缓存起来也要一个 bitset。
所以秘诀就在于需要有一个数据结构:
Lucene 使用的这个数据结构叫做 Roaring Bitmap。其压缩的思路其实很简单。与其保存 100 个 0,占用 100 个 bit。还不如保存 0 一次,然后声明这个 0 重复了 100 遍。