ElasticSearch为什么要用倒排索引?

原创
服务器 开发工具
一提到 ES,大家都会想到它的倒排索引,很多人都知道 ES 是因为倒排索引因此能够快速的进行查询。

【51CTO.com原创稿件】一提到 ES,大家都会想到它的倒排索引,很多人都知道 ES 是因为倒排索引因此能够快速的进行查询。

[[439972]]

图片来自 包图网

但是 ES 只有倒排索引吗?ES 存在正排索引吗?ES 的很多数据放置在内存中,它有什么优化策略吗?

倒排索引与正排索引

为了防止一些同学还不知到倒排索引,我们简述下正排索引与倒排索引。所谓正排索引很简单,就是和我们人脑的记忆更加贴合的一种数据结构。

比如记忆古诗,当别人问我们《静夜思》这首诗的时候,我们很容易就能够背出完整的诗句。

但是如果有人问我们哪一首诗里面包含有霜这个字的时候,我们就很难想到《静夜思》这首诗了。因为我们的大脑在记忆古诗的时候是建立了一个正排索引。

静夜思→窗前明月光,疑是地上霜,举头望明月,低头思故乡。

而倒排索引是与这样的数据结构相反的,有一个从古代流传至今的游戏,叫做《飞花令》,规则就是要能够说出含有“花”的诗句,谁能够说的多谁就获胜。

要能够在这样的游戏获胜,关键就是看谁能够在脑海中建立好关于花的倒排索引。比如:

综上,这就是倒排索引,但是 ES 的倒排索引还要更加复杂,为了进行评分计算,ES 会增加一些对该词项的统计信息。

Doc Values

①为什么需要 doc values 剖析?

在 ES 中的倒排索引如下,假设有 3 个文档,各自有一个字段那么,三个文档如下:

那么它按照 name 建立的倒排索引会如下图所示:

现在,假设我们要做这么一个查询:查询出 name 含有后 brown 的文档,并且按照 age 排序。

查询分析:因为我们有了 name 的倒排索引,我们直接看上述的倒排索引我们很快就可以知道命中的倒排索引是 Doc_1 和 Doc_2。之后我们要根据 age 进行排序,那么有什么方法可以做到呢?

先逆向思考,为了能够排序我们需要什么呢?很简单,我们需要知道待排序的文档的每一个文档的文档 id 及其对应的 age。

也就是说我们需要有doc_id→age这样的一个映射关系,那么问题就被转化了。

我们有什么方法可以得到这一个映射关系呢?有 3 个方法:

方法一:在上面的查询中,我们已经过滤出待排序的文档是 Doc_1 和 Doc_2,那么我们可以访问磁盘取回这两个文档的数据,这样我们就可以建立 doc_id→age 的映射关系了。

缺点:在示例中,我们只命中了两个文档,但是在真实的业务场景中,我们命中的文档就可能是非常非常多的。

比如我们的的查询文档如果是中国的 14 亿人口,按照性别过滤而后按照年龄排序,那么我们要取回的文档数量就将达到 7 亿个文档之多,这样要取回的数据量就太大了。

而且,可以想见,要取回命中的文档是属于随机 IO,这样的话此方案对于 IO,CPU,内存都有很大的压力,响应时间更是难以想象。

结合方法一的缺点,我们发现访问源数据是很不友好的,那么如果不访问源数据且要用现有的资源要怎么做呢?

方法二:读取已有的倒排索引,利用倒排索引来建立 doc_id→age 的映射关系。根据倒排索引的数据结构,我们的操作变成:遍历整个倒排索引的所有词项,从而建立完整的 doc_id→age 映射关系。

缺点:每次排序都需要遍历一遍倒排索引,当倒排索引的词项很少的时候还好,当词项很多的时候速度将会变慢。

而且每次根据不同的查询条件,我们建立的 doc_id→age 的映射关系都不同,需要我们查询一次遍历一次,建立一次映射关系。简而言之,缺点是:建立映射麻烦,可复用性不高。

在法二的基础上我们进一步剖析,我们希望在查询的时候能够更快速的获得 doc_id→age 的映射关系,且能够复用。

对于 doc_id→age 的映射关系,我们是一定要建立的,既然这一步必不可少,那么我们可不可以对这个步骤进行分解呢?

即分解成在文档被插入(官方文献中,文档被插入描述成文档被索引,笔者看多了官方文献,其实习惯描述成被索引,但这里还是说成被插入以免被误解)的时候,与倒排索引一起被创建。

方法三:在文档被插入的时候就建立 doc_id→age 的映射关系,需要排序和聚合的时候,我们只要直接读取就可以了。如上分析,引出了 ES 的 doc values,江湖人称正排索引。

②Doc values 深入认识

经过一层层啰嗦的剖析,我们终于引出了 doc values,那么我们就来更加深入的认识 doc values。

(1)生成时机:在文档被插入的时候与倒排索引同期生成。

(2)数据结构:doc values 其实就是倒排索引的转置,大概结构如下:

(3)存储位置:磁盘。

(4)在什么粒度上会生成 doc values:基于每一个 segment(ES 的索引数据在每一个分片内有又被分成了一个有一个的 segment,每一个 segment 最大存放 2^31-1 个文档)独立生成,且和倒排索引,以及 segment 一样是不可变的(为什么不可变,以及不可变如何应对文档变更是一个很长很长的故事,敬请期待)。

(5)默认开启,所以不需要我们操心,但是如果我们很明确一个字段是不会被用于排序和聚合的,我们可以在创建它的时候就关闭 doc values 以节省资源。

(6)使用方式:读取回内存。

(7)不适应 text 类型字段。此处插入 doc_id 的含义哈,文档是存放在 segment,一个 segment 是 doc 的数组内的,doc_id 指的是每一个文档在 segment 内的 index,而不是很多人以为的 _id。

那么,到此为止了吗?No,还有你意想不到的东西。针对特点 5 和特点 6,ES 为了让查询更快速,且更少的占用资源,防止 ES 节点因 OOM 问题而见马克思,做了一些其他的努力。

看上面的第 5 点,读取 doc values 的数据放置在内存,这个内存是应用内存还是系统内存呢?

答案是系统内存,因为可以充分利用操作系统的虚存技术,也就是说 doc values 放置的内存并不受 JVM 管理。

当系统内存充足的时候,会都放置在系统内存,当系统内存不足的时候利用操作系统的虚存技术建立与 doc values 文件的映射关系,只读取部分 doc values 的数据在内存中,根据内存淘汰策略进行读入和淘汰。

也由此引出 ES 官方关于 ES 节点内存分配策略的一个方案:

另外,为了读写更加的快速,有没有办法使得 doc values 占用的内存更小呢?这里就要体现 ES 的众多数据压缩手段之一了。

看上面的 doc values 的数据示例,我们发现在示例中对应的词项是数字,最小的数字是 100,最大的是 4200。

为了存放下这些数字,我们需要给每一个数字分配多大内存空间呢?为了装下 4200,因为 2^12<4200<2^13,所以我们需要为每一个词项至少分配 13 bit 的空间,示例中总共 7 个 doc,至少需要 7*13=91 bit。

有没有办法,针对这种情况,ES 的压缩方式是:发现这些数字具有一个最大公约数 100,于是把这些数字都除以他们的最大公约数。

结果如下:

这样之后数据范围就变成了 1-42,为了存放 42,我们需要 2^5<42<2^6。

也就是说我们存放每一个数字只需要 6bit。最终存放 7 个数字需要 6*7=42bit,压缩了一倍。

这就是 ES 对于 doc values 的数据压缩方式之一,总共的对于数字的压缩方式有:

接着产生另一个问题:上面介绍的压缩方式都是针对数字的,但是我的词项要是字符串文本怎么办?我们把字符串转换成数字不就行了?

看官网的解释:

FieldData

行文至此,让我们再回忆下 doc values 的特点 6:不适用于 text 数据类型。

那么,text 类型这种文本字段要是要排序,或者是要聚合,要咋整呢?于是有了一个新的东西:fielddata。

Fielddata 的数据结构可以理解为 text 类型字段的正排索引结构,它解决了 doc values 不支持多值字符串的问题。

另外它还有其他的不同:

  • 内存管理和生成,常驻内存:fielddata 与 doc values 不同,它的生成和管理都是在内存中生成的,且一般情况下不会被释放,因为构建它的代价十分高昂,所以我们使它常驻。
  • 更占内存:对 text 字段的数据进行分析和生成 fielddata 的过程会产生很多的词项,会占用很多的内存
  • 懒加载:一个配置开启了 fielddata:true 的字段的在第一次被聚合之前,是不会生成 fielddata 的。
  • 全加载:这里有一个令人惊讶的地方。假设你的查询是高度选择性和只返回命中的 100 个结果。大多数人认为 fielddata 只加载 100 个文档。
  • 实际情况是,fielddata 会加载索引中(针对该特定字段的) 所有的 文档,而不管查询的特异性。逻辑是这样:如果查询会访问文档 X、Y 和 Z,那很有可能会在下一个查询中访问其他文档。
  • 与 doc values,倒排索引一样基于 segment 建立而不是基于整个索引建立。
  • 默认关闭,开启的话需要手动开启,使用 fielddata:true。

针对上面的前两个特点,引申出如下问题:

  • 生成慢
  • 占空间

问题 1 解决:对于生成慢,会导致这么一个问题:首次查询使用到某一个字段的 fielddata 的时候速度会很慢,如果针对这点是不能忍受的,可以对该字段的 fielddata 进行预加载。

只需要在字段的 mappings 下添加如下即可:

问题 2 解决:除了做数据压缩,为了放置我们的 ES 因为加载了太多的 fielddata 而 OOM 崩溃。

我们需要对 fielddata 的数据做一些限制:

  • indices.fielddata.cache.size:限制 fielddata 使用空间,控制为 fielddata 分配的堆空间大小,当超过 fielddata 占用的内存大小超过这个限度就会触发对 fielddata 的内存回收,回收策略 LRU。

可以是百分比 20% 或者是具体值 5gb;有了这个设置,最久未使用(LRU)的 fielddata 会被回收为新数据腾出空间。

  • indices.breaker.fielddata.limit:fielddata 内存使用断路器,断路器默认设置堆的 60% 作为 fielddata 大小的上限。超过这个上线会触发一个异常。一个异常好过当我们内存不足的时候出现 OOM 导致节点崩溃
  • indices.breaker.request.limit:request 断路器估算需要完成其他请求部分的结构大小,例如创建一个聚合桶,默认限制是堆内存的 40%。
  • indices.breaker.total.limit:total 揉合 request 和 fielddata 断路器保证两者组合起来不会使用超过堆内存的 70%。

注意:indices.fielddata.cache.size 和 indices.breaker.fielddata.limit 之间的关系非常重要。

如果断路器的限制低于缓存大小,没有数据会被回收。为了能正常工作,断路器的限制 必须 要比缓存大小要高。

Fielddata 的过滤:除此之外,还有一个方案可以减少 fielddata 的数据大小,那就是数据过滤,把没有必要放入 fieldata 的数据过滤掉。

比如我们对 100W 首歌曲进行按照标签 group 并取前 10,那么大概摇滚,嘻哈,流行之类的会排在前面,同时也会存在一些标签,比如“时长超过 20min”,这样的小众标签是几乎不会被查询到和聚合到的。

那么就可以省掉这部分数据,不加载入 fielddata,甚至可以说很多数据可能符合正态分布,只有一小部分数据是经常被用来聚合的,其他的很多数据关联的文档特别少。

过滤方式如下:

  • fielddata 关键字允许我们配置 fielddata 处理该字段的方式。
  • frequency 过滤器允许我们基于项频率过滤加载 fielddata。
  • 只加载那些至少在本段文档中出现 1% 的项。
  • 忽略任何文档个数小于 500 的段。太小的段关键词所占的比例失衡。
  1. PUT /music/_mapping/song 
  2.   "properties": { 
  3.     "tag": { 
  4.       "type""string"
  5.       "fielddata": {   (1) 
  6.         "filter": { 
  7.           "frequency": {   (2) 
  8.             "min":              0.01,  (3)  
  9.             "min_segment_size": 500  (4) 
  10.           } 
  11.         } 
  12.       } 
  13.     } 
  14.   } 

全局序列号

介绍完了 doc values 和 fielddata,接着我们要介绍一个在 ES 中采用的用于是的 doc values 和 fielddata 更快的手段;全局序列号。

什么是全局序列号?为什么需要全局序列号?有一个索引具有 10 亿的文档,每一个文档都有一个字段 status,这个 staus 只有 3 个枚举值 TOEXCURE,EXCUTING,FINISHED。

为了方便,我设置的 3 个枚举值都是 8 个字符。那么我们存储的时候要是直接存储这三个枚举值,就需要使用 8byte*10 亿的空间,而要是为他们使用序列号,0 1 2 来指代,就可以使用 1bit 存储每一个枚举值。

  1. TOEXCURE -> 0  
  2. EXCUTING -> 1 
  3. FINISHED  ->  2 

就只需要使用 1/8 byte*10 亿。压缩比例:只需要原本 1/64 的存储空间,真是一个小机灵。

但是,我们知道 fielddata 和 doc value 是基于每一个 segment 建立的,所以可能有的分段有 TOEXCUTE 和 FINISHED 两个枚举值,用 0 和 1 映射。

有的分段有 EXCUTING+FINISHED 两个枚举值,在这个分段也用 0 1 映射,这就可能出问题,我们用来代替他们的序列号就必须是全局唯一的,这个东西就叫做全局序列号。

如何获得全局序列号?方法如下:

方法 1:读取所有分段执行聚合操作返回所有的枚举值,然后生成全局序列号。缺点:对所有分段所有数据项的操作,耗时耗内存耗 CPU。

方法 2:通过 fielddata 和 doc values 来构建----ES 的方案正是如此。

特点:

  • 全局序列号的构建和刷新时机:新增或删除一个分段时,需要对全局序号进行重建,新增和删除 segment 需要重建因为可能有枚举值产生变化,
  • 重建需要读取每个分段的每个唯一项,基数越高(即存在更多的唯一项)这个过程会越长。
  • 和 fielddata 加载一样,全局序号默认也是延迟构建的。

总结

本文主要分析了 ES 为了能够应对排序,聚合的场景,对于未分析字段(非text)使用了 doc values,对于 text 文本字段采用了 fielddata。

着眼点主要在于为什么要使用这两个技术,以及使用了这两个技术需要做什么,会带来什么问题,ES 为了避免带来的问题做了哪一些权衡和优化。

在使用 ES 的时候我们只需要知道,为了更快的进行排序和聚合,对于为分析字段我们要开启 doc values(默认已开启),对于分析字段 text 类型字段我们要开启 fielddata。

但是我们更应该知其然而知其所以然。这样才能从知识的学习中感受到快乐。探索未知,认识世界,你我共勉。

作者:JackHu

简介:水滴健康基础架构资深技术专家

编辑:陶家龙

征稿:有投稿、寻求报道意向技术人请联络 editor@51cto.com

【51CTO原创稿件,合作站点转载请注明原文作者和出处为51CTO.com】

 

责任编辑:武晓燕 来源: 51CTO技术栈
相关推荐

2023-09-22 10:05:32

2020-12-01 11:34:14

Elasticsear

2020-04-07 16:12:56

Go编程语言开发

2009-01-09 23:06:41

服务器SCSI硬盘PC

2019-03-14 09:51:50

MySQL存储逻辑架构

2019-09-24 09:33:53

MySQLB+树InnoDB

2015-04-21 13:09:01

B+树MySQL索引结构

2024-01-02 17:28:12

芯片CPUAI计算

2024-07-02 13:27:38

2021-05-11 06:57:15

HBaseBATJ公司

2022-05-07 07:35:44

工具读写锁Java

2022-07-06 09:29:40

JMH性能测试

2016-01-12 16:58:31

C游戏

2024-04-03 09:23:31

ES索引分析器

2015-07-01 10:25:07

Docker开源项目容器

2017-08-07 08:15:31

搜索引擎倒排

2020-03-20 10:14:49

搜索引擎倒排索引

2018-05-14 11:07:48

服务器Linux系统

2011-02-22 09:50:21

2021-02-09 20:51:13

D 语言脚本编程语言
点赞
收藏

51CTO技术栈公众号