带你走进神一样的ElasticSearch索引机制

2,280 阅读11分钟

文章摘要:相比于MySql索引,ElasticSearch索引采用完全不同的机制,在效率上有极大提升,同时为了节省存储空间,采用了压缩算法,在保证执行效率的前提下尽量减小空间的占用,本文将带你走进神一样的ElasticSearch索引机制。

一、简介

什么是Elasticsearch?

定义反应本质,我们先来看看维基百科对它的定义:Elasticsearch is a search engine based on the Lucene library. It provides a distributed, multitenant-capable full-text search engine with an HTTP web interface and schema-free JSON documents.

简单翻译一下:Elasticsearch是一个基于Lucene库的开源搜索引擎,它提供分布式的实时文件存储和搜索,可扩展性好,并且支持通过HTTP网络接口交互,数据以JSON格式展示。

从定义可以看到Elasticsearch是基于Lucene库,那么什么是Lucene呢?

Lucene是一个Java全文搜索引擎,完全用Java编写。Lucene不是一个完整的应用程序,而是一个代码库和API,可以很容易地用于向应用程序添加搜索功能。但是Lucene有自己的缺点:

  1. 需要集成
  2. 学习成本高
  3. 调用复杂

如果你是个开发人员,现在需要使用全文搜索引擎,需要预先调研学习Lucene,并且需要定制化开发将它集成到自己的框架中,使用过程中的调用也极其复杂,你还会去使用它吗?这时候很多人都不会接受这个成本。那么程序员就会开动聪明的脑袋瓜,有没有办法降低Lucene的使用成本呢?世界上就有那么一群人,在Lucene的基础之上开源了Elasticsearch和Solr这两个基于Lucene的开源搜索引擎。

Elasticsearch or Solr?看看大家都在用什么,google搜索趋势图如下:

趋势显示大概在2012年之后Elasticsearch的搜索次数明显多余Solr,从搜索热情也能看出大家使用Elasticsearch多于Solr,学习或者遇到问题搜索解决方案等大致能反应大家对于两者的选择。放一张

Elasticsearch和Solr的对比图:

为什么使用Elasticsearch?个人浅见:

  1. Elasticsearch支持更高性能、可伸缩性、分布式、云
  2. 简单易用的API和DevOps,更好的生态
  3. 可以更好地查询、分析、处理、聚合
  4. and so on

提到Elasticsearch(为了叙述方便,有些地方采用缩写ES),不得不提“倒排索引”。

二、倒排索引

ES设计的宗旨:一切设计都是为了提高搜索的性能

-另一层意思:为了提高搜索的性能,难免会牺牲某些其他方面,比如插入/更新

我们先来看一个最简单的倒排索引,如下图:

左边是关键词term列表,每个term对应包含它的文档id列表,但是这个效率明显不高,左边term列表的时间复杂度是O(n)。优化方法可以想到排序后用二分法查找,如下图:

这样的时间复杂度是O(logN),缺点就是需要对term列表进行排序,但是回想ES设计的宗旨,这都是值得的。排序后的term列表称为Term Dictionary,文档id列表称为Posting List。但是ES想尽可能地利用内存,因为内存操作时间远少于磁盘操作,那么目前的Term Dictionary还是太大了,无法放入内存,这只是一个Term Dictionary,事实上ES集群中会有许多个Term Dictionary,这些要全部放入内存显然是不现实的,所以ES设计了Term Index,在保证执行效率的同时,尽量缩减内存空间的占用。

Term Index大概长这样:

从数据结构上分类Term Index算是一个“Trie 树”,也就是我们常说的字典树(但不完全是,后面会详细说)。Term Index不会包含所有的term,只包含一些term前缀。通过Term Index可以快速地定位到 Term Dictionary 的某个 offset,然后从这个位置再往后顺序查找。

最终倒排索引的结构图如下:

其中Term Index在内存中存储,Term Dictionary和Posting List在磁盘中。一次搜索的步骤就是:

  1. 搜索Term Index树找到对应Term Dictionary中的offset,因为匹配到的可能只是一个前缀,需要再顺序往后找,直到匹配
  2. 通过Term Dictionary找到对应的Posting List

ES针对倒排索引做了一个类似两级索引的结构,牺牲了一定的插入/更新性能带来了搜索性能的提升。ES针对还额外做了两点优化:

  1. Term Dictionary 在磁盘上面是分 block 保存的,一个 block 内部利用公共前缀压缩,比如都是 Ab 开头的单词就可以把 Ab 省去
  2. Term Index 在内存中是以 FST(finite state transducers)的数据结构保存的

什么是FST呢?这就是为什么上文说到Term Index并不完全是字典树,就是因为它是FST类型。

假设我们现在要将mop, moth, pop, star, stop, top(term index里的term前缀)映射到序号:0,1,2,3,4,5(term dictionary的block位置)。最简单的做法就是定义个Map<string, integer>,大家找到自己的位置对应入座就好了,但从内存占用少的角度想想,有没有更优的办法呢?答案就是FST。

比如查询moth,就把m,o,t,h路径上的权重相加得到1即为offset值。有兴趣的同学可以参考这篇文章:

www.shenyanchao.cn/blog/2018/1…

FST 有两个优点:

  1. 空间占用小:通过对词典中单词前缀和后缀的重复利用,压缩了存储空间
  2. 查询速度快:O(len(str)) 的查询时间复杂度

这种方式也会导致查找时需要更多的CPU资源,但是总得来说,放进内存的收益>CPU的资源消耗。

本章小节:倒排索引是二级索引,先通过Term Index(FST数据结构)定位到offset,然后再到Term Dictionary顺序往后查询到具体的term,最后定位到具体的Posting List中的docId的集合。其中Term Index通过压缩技术,用计算换取空间,压缩后放入内存中,减少了查询时间,Term Dictionary和Posting List还是放在磁盘中。

痛点:docId存储空间太大

三、压缩技巧

  • 痛点:Postings List 如果不进行压缩,会非常占用磁盘空间

Posting List 不是已经只存储文档 id 了吗?还需要压缩?如果 posting list 有百万个 doc id 的情况?千万个?考虑到这种情况压缩就显得很有必要。

由于整型数字 integer 可以被高效压缩的特质,integer 是最适合放在 postings list 作为文档的唯一标识的,ES 会对这些存入的文档进行处理,转化成一个唯一的整型 id,类似数组下标,ES分Segment保证每个Segment最多存2^32-1(数据来自官网)个文档ID。

首先,Elasticsearch要求posting list是有序的(为了提高搜索的性能,再任性的要求也得满足)

增量压缩,默认第一个元素前边有个0,后一个减前一个的增量构成新的列表,比如73-0=73,300-73=227,事实上ES做到更加精细,称为FOR。

  • FOR(Frame of Reference)

以6个文档id为例,从6*4bytes(int)=24bytes压缩到7bytes

Step 1:Delta-encode 增量编码

我们只需要记录元素与元素之间的增量,于是数组变成了[73,227,2,30,11,29]

Step 2:Split into blocks 分隔成块

为了方便演示,我们假设每个块是3个(实际256)文档ID: [73,227,2],[30,11,29]

Step 3:Bit packing 按需分配空间

对于第一个块,[73,227,2],最大元素是227,需要8 bits,那么给这个块每个元素,都分配8 bits的空间。 但是对于第二个块,[30,11,29],最大的元素才30,只需要5 bits,那么给每个元素只分配5 bits的空间足够。

  • filter cache

过滤器缓存(filter cache)是一种流行的技术,可以加快常用过滤器的执行速度。它是一个简单的缓存,将(过滤器、分段)对映射到它们匹配的文档 ID 列表。

Option 1: integer array

如果有一个包含 100M个 文档的段,以及一个匹配大多数文档的过滤器,则在该段上缓存单个过滤器需要大约 100M *4bytes=400MB 的内存。需要更节省内存的方案!

Option 2: bitmap

Posting List如:[1,3,4,7,10]对应的bitset就是:[1,0,1,1,0,0,1,0,0,1],同样100M的文档,只需要 100M 位 = 12.5MB。 bitmap 有个硬伤,就是不管你有多少个匹配文档,你占用的空间都是一样的,假设过滤器只 有4个匹配文档,在该例中仍然需要100M位=12.5MB空间。不划算!

Option 3: roaring bitmaps

权衡利弊后,在 Lucene 5 中,切换到 Daniel Lemire 的咆哮位图。

为什么使用65536?16高位和16低位均衡,以及可以用short(2bytes)存储。

为什么它使用4096作为阈值?仅仅因为在一个块中超过这个数量的文档,位图变得比数组更节省内存。放一张对比图就可以理解:

本章小节:为了减少存储空间,对磁盘中的Postings List进行压缩,采用FOR方案对其进行压缩。针对频繁的filter查询,会进行filter cache,采用一种叫做roaring bitmaps(咆哮位图)的压缩技术。

四、联合索引

上面说了半天都是单field索引,如果多个field索引的联合查询,倒排索引如何满足快速查询的要求呢?

比如给定查询过滤条件 age=18 的过程就是先从 term index 找到 18 在 term dictionary 的大概位置,然后再从 term dictionary 里精确地找到 18 这个 term,然后得到一个 posting list 或者一个指向 posting list 位置的指针。然后再查询 gender= 女 的过程也是类似的。最后得出 age=18 AND gender= 女 就是把两个 posting list 做一个“与”的合并。

  1. 使用 skip list 数据结构。同时遍历 gender 和 age 的 posting list,互相 skip
  2. 使用 bitset 数据结构,对 gender 和 age 两个 filter 分别求出 bitset,对两个 bitset 做 AND 操作

Elasticsearch 支持以上两种的联合索引方式,如果查询的 filter 缓存到了内存中(以 bitset 的形式),那么合并就是两个 bitset 的 AND。如果查询的 filter 没有缓存,那么就用 skip list 的方式去遍历两个 on disk 的 posting list。

假设有下面三个posting list需要联合索引:

我们现在需要把它们用 AND 的关系合并,得出 posting list 的交集。首先选择最短的 posting list,然后从小到大遍历。遍历的过程可以跳过一些元素,比如我们遍历到绿色的 13 的时候,就可以跳过蓝色的 3 了,因为 3 比 13 要小。

步骤如下:

最后得出的交集是 [13,98],所需的时间比完整遍历三个 posting list 要快得多。但是前提是每个 list 需要指出 Advance 这个操作,快速移动指向的位置。什么样的 list 可以这样 Advance 往前做蛙跳?答案就是skip list:

将一个有序链表level0,挑出其中几个元素到level1及level2,每个level越往上,选出来的指针元素越少,查找时依次从高level往低查找,比如55,先找到level2的31,再找到level1的47,最后找到55,一共3次查找,查找效率和2叉树的效率相当,但也是用了一定的空间冗余来换取的。

本章小节:使用skip list互相skip和bitset与操作(适用于filter cache场景)两种方式进行联合查询,提升效率。

五、总结和思考

ES将磁盘里的东西尽量搬进内存,减少磁盘随机读取次数(同时也利用磁盘顺序读特性),结合各种奇技淫巧的压缩算法,用及其苛刻的态度使用内存。

对于使用Elasticsearch进行索引时需要注意:

  • 不需要索引的字段,一定要明确定义出来,因为默认是自动建索引的,"index": false
  • 对于text类型的字段,不需要analysis的也需要明确定义出来,因为默认也是会analysis的,可以使用keyword
  • 选择有规律的ID很重要,随机性太大的ID(比如java的UUID)不利于查询

放一张各种id的对比图:

参考:

www.cnblogs.com/dreamroute/…

www.elastic.co/cn/blog/fra…

blog.mikemccandless.com/2014/05/cho…