elasticsearch会有索引吗?ElasticSearch为什么要用倒排索引
elasticsearch会有索引吗?ElasticSearch为什么要用倒排索引要能够在这样的游戏获胜,关键就是看谁能够在脑海中建立好关于花的倒排索引。比如:而倒排索引是与这样的数据结构相反的,有一个从古代流传至今的游戏,叫做《飞花令》,规则就是要能够说出含有“花”的诗句,谁能够说的多谁就获胜。比如记忆古诗,当别人问我们《静夜思》这首诗的时候,我们很容易就能够背出完整的诗句。但是如果有人问我们哪一首诗里面包含有霜这个字的时候,我们就很难想到《静夜思》这首诗了。因为我们的大脑在记忆古诗的时候是建立了一个正排索引。静夜思→窗前明月光,疑是地上霜,举头望明月,低头思故乡。
【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 的段。太小的段关键词所占的比例失衡。
PUT /music/_mapping/song
{
"properties": {
"tag": {
"type": "string"
"fielddata": { (1)
"filter": {
"frequency": { (2)
"min": 0.01 (3)
"min_segment_size": 500 (4)
}
}
}
}
}
}
全局序列号
介绍完了 doc values 和 fielddata,接着我们要介绍一个在 ES 中采用的用于是的 doc values 和 fielddata 更快的手段;全局序列号。
什么是全局序列号?为什么需要全局序列号?有一个索引具有 10 亿的文档,每一个文档都有一个字段 status,这个 staus 只有 3 个枚举值 TOEXCURE,EXCUTING,FINISHED。
为了方便,我设置的 3 个枚举值都是 8 个字符。那么我们存储的时候要是直接存储这三个枚举值,就需要使用 8byte*10 亿的空间,而要是为他们使用序列号,0 1 2 来指代,就可以使用 1bit 存储每一个枚举值。
TOEXCURE -> 0
EXCUTING -> 1
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
简介:水滴健康基础架构资深技术专家
编辑:陶家龙
【51CTO原创稿件,合作站点转载请注明原文作者和出处为51CTO.com】