转:https://server.51cto.com/article/694939.html

 

1 基本概念简介

  所谓正排索引很简单,就是和我们人脑的记忆更加贴合的一种数据结构。

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

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

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

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

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

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

 

2 正排索引doc values

2.1 为什么需要 doc values

2.1.1 引出问题

  假设有 3 个文档,各自有一个字段,三个文档如下

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

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

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

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

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

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

 

2.1.2 方法一(访问磁盘)

1)简介

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

  

2)缺点

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

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

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

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

 

2.1.3 方法二(访问倒排索引)

1)简介

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

 

2)缺点

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

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

  

2.1.4 方法三(提前建立好doc_id→age 的映射关系)

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

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

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

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

 

2.2. 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

 

2.3 doc values 的数据放置和内存分配方案

  针对上面特性5和特性6,ES 为了让查询更快速,且更少的占用资源,防止 ES 节点因 OOM 问题而见马克思,做了一些其他的努力

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

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

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

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

       

 

2.4 ES的数据压缩手段

2.4.1 简介

  另外,为了读写更加的快速,有没有办法使得 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 的数据压缩方式之一,

 

2.4.2  对于数字的压缩方式

 

 

2.4.3 对于字符串的压缩方式

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

  看官网的解释

  

 

3 FieldData

3.1 简介

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

  那么,text 类型这种文本字段要是要排序,或者是要聚合,要咋整呢

  于是有了一个新的东西:fielddata。

  Fielddata 的数据结构可以理解为 text 类型字段的正排索引结构

  它解决了 doc values 不支持多值字符串的问题。

 

3.2 fielddata的特性

  1)内存管理和生成-常驻内存

    fielddata 与 doc values 不同,它的生成和管理都是在内存中生成的,且一般情况下不会被释放,因为构建它的代价十分高昂,所以我们使它常驻

 

  2)更占内存

    对 text 字段的数据进行分析和生成 fielddata 的过程会产生很多的词项,会占用很多的内存

 

  3)懒加载

    一个配置开启了 fielddata:true 的字段的在第一次被聚合之前,是不会生成 fielddata 的

 

  4)全加载

    这里有一个令人惊讶的地方。假设你的查询是高度选择性和只返回命中的 100 个结果。大多数人认为 fielddata 只加载 100 个文档。

    实际情况是,fielddata 会加载索引中(针对该特定字段的) 所有的 文档,而不管查询的特异性。逻辑是这样:如果查询会访问文档 X、Y 和 Z,那很有可能会在下一个查询中访问其他文档

 

  5)基于segment 建立

    与 doc values,倒排索引一样基于 segment 建立而不是基于整个索引建立

 

  6)默认关闭

    开启的话需要手动开启,使用 fielddata:true

 

3.3 fielddata的问题

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

  1)生成慢

  2)占空间

 

3.4 生成慢的解决方案

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

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

  

 

3.5 占空间的解决方案

3.5.1 数据限制

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

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

  1)indices.fielddata.cache.size

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

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

 

  2)indices.breaker.fielddata.limit

    fielddata 内存使用断路器,断路器默认设置堆的 60% 作为 fielddata 大小的上限。超过这个上线会触发一个异常。一个异常好过当我们内存不足的时候出现 OOM 导致节点崩溃

 

  3)indices.breaker.request.limit

    request 断路器估算需要完成其他请求部分的结构大小,例如创建一个聚合桶,默认限制是堆内存的 40%

 

  4)indices.breaker.total.limit

    total 揉合 request 和 fielddata 断路器保证两者组合起来不会使用超过堆内存的 70%。

 

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

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

 

3.5.2 Fielddata 的过滤

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

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

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

过滤方式如下:

  1)fielddata 关键字允许我们配置 fielddata 处理该字段的方式。

  2)frequency 过滤器允许我们基于项频率过滤加载 fielddata。

  3)只加载那些至少在本段文档中出现 1% 的项。

  4)忽略任何文档个数小于 500 的段。太小的段关键词所占的比例失衡

PUT /music/_mapping/song 
{ 
  "properties": { 
    "tag": { 
      "type": "string", 
      "fielddata": {   (1) 
        "filter": { 
          "frequency": {   (2) 
            "min":              0.01,  (3)  
            "min_segment_size": 500  (4) 
          } 
        } 
      } 
    } 
  } 
}