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

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

图片来自 包图网

但是 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技术栈

同话题下的热门内容

戴尔易安信PowerProtect Cyber Recovery提供数据安全的保障如何使用构建块方法优化数据中心的电源供应龙芯中科:“拨云见日”、聚沙成塔,构建自主信息产业生态2021年顶级数据中心和技术预测分级戴尔科技 Dell EMC VxRail 超融合平台提供一站龙芯中科构筑自主生态长城 护航产业数字化转型戴尔科技 戴尔Latitude 7000系列 让笔记本"发烧"成为过去式戴尔科技 AI涵盖多种技术,优化资源提升生产效率

编辑推荐

Windows和Ubuntu系统如何远程连接Linux服务器解决Nginx服务返回500状态码问题什么仇什么怨?一程序员锁死服务器致公司损失百万?服务器UDIMM、LRDIMM、RDIMM三种内存如何区别超融合架构与传统三层架构的对比
我收藏的内容
点赞
收藏

51CTO技术栈视频号