1.Doc Values
聚合使用一个叫Doc Values的数据结构。Doc Values使聚合更快、更高效且内存友好。
Doc Values的存在是因为倒排索引只对某些操作是高效的。倒排索引的优势在于查找包含某个项的文档,而反过来确定哪些项在单个文档里并不高效。
结构类似如下:
Doc Terms ----------------------------------------------------------------- Doc_1 | brown, dog, fox, jumped, lazy, over, quick, the Doc_2 | brown, dogs, foxes, in, lazy, leap, over, quick, summer Doc_3 | dog, dogs, fox, jumped, over, quick, the
Doc values在索引的时候生成,伴随倒排索引的创建。像倒排索引一样基于per-segment,且是不可变,被序列化存储到磁盘。通过序列化持久化数据结构到磁盘,可以以来操作系统的文件缓存来代替JVM heap。但是当工作空间需要的内存很大时,Doc Values会被置换出内存,这样会导致访问速度降低,但是如果放在JVM heap,将直接导致内存溢出错误。
Doc Values默认对除了分词的所有字段起作用。因为分此字段产生太多tokens且Doc Values对其并不是很有效。
由于Doc Values默认开启,如果你不会执行基于一个确定的子段 聚合、排序或执行脚本(Script ),你可以选择关闭Doc Values,这可以为你节省磁盘空间,提高索引数据的速度。
PUT my_index { "mappings": { "my_type": { "properties": { "session_id": { "type": "string", "index": "not_analyzed", "doc_values": false } } } } }
设置doc_values: false,这个字段将不再支持据聚合、排序和脚本执行(Script);
同时也可以对倒排索引做类似的配置:
PUT my_index { "mappings": { "my_type": { "properties": { "customer_token": { "type": "string", "index": "not_analyzed", "doc_values": true, "index": "no" } } } } }
这个可以支持聚合,但不支持查询,因为不会对这个字段生成倒排索引。
2.聚合与分析
分析对聚合有两方面的影响,
2.1.分析影响聚合中使用的 tokens
例如字符串 "New York" 被分析/分析成 ["new", "york"] 。这些单独的 tokens ,都被用来填充聚合计数,所以我们最终看到 new 的数量而不是 New York。
可以通过加multifield来修正,如下:聚合时指定为未分词的raw字段。
PUT /agg_analysis { "mappings": { "data": { "properties": { "state" : { "type": "string", "fields": { "raw" : { "type": "string", "index": "not_analyzed" } } } } } } }
2.1.Doc values 不支持 analyzed
Doc values 不支持 analyzed 字符串字段,因为它们不能很有效的表示多值字符串。 Doc values 最有效的是,当每个文档都有一个或几个 tokens 时, 但不是无 数的,分词字符串(想象一个 PDF ,可能有几兆字节并有数以千计的独特 tokens)。
出于这个原因,doc values 不生成分词的字符串,然而,这些字段仍然可以使用聚合,那怎么可能呢?
答案是一种被称为 fielddata 的数据结构。与 doc values 不同,fielddata 构建和管理 100% 在内存中,常驻于 JVM 内存堆。这意味着它本质上是不可扩展的,有很多边缘情况下要提防。
本章的其余部分是解决在分词字符串上下文中 fielddata 的挑战。
从历史上看,fielddata 是 所有字段的默认设置。但是 Elasticsearch 已迁移到 doc values 以减少 OOM 的几率。分词字符串是仍然使用 fielddata 的最后一块阵地。 最终目标是建立一个序列化的数据结构类似于 doc values ,可以处理高维度的分词字符串,逐步淘汰 fielddata。
避免分词字段的另外一个原因就是:高基数字段在加载到 fielddata 时会消耗大量内存。 分词的过程会经常(尽管不总是这样)生成大量的 token,这些 token 大多都是唯一的。 这会增加字段的整体基数并且带来更大的内存压力。
有些类型的分词对于内存来说 极度 不友好,想想 n-gram 的分析过程, New York 会被 n-gram 分析成以下 token:
ne、ew、w 、 y、yo、or、rk
可以想象 n-gram 的过程是如何生成大量唯一 token 的,特别是在对成段文本分词的时候。当这些数据加载到内存中,会轻而易举的将我们堆空间消耗殆尽。
在聚合字符串字段之前,请评估情况:
a.这是一个 not_analyzed 字段吗?如果是,可以通过 doc values 节省内存 。
b.否则,这是一个 analyzed 字段,它将使用 fielddata 并加载到内存中。这个字段因为 n-grams 有一个非常大的基数?如果是,这对于内存来说极度不友好。
2.2.text类型默认禁用fielddate,排序、聚合需要手动开启
POST book1/_mapping/english/?pretty { "english":{ "properties":{ "addr":{ "type":"text", "fielddata":true } } } }
Fielddata可能会消耗大量的堆空间,尤其是在加载高基数text
字段时。一旦fielddata已加载到堆中,它将在该段的生命周期内保留。此外,加载fielddata是一个昂贵的过程,可能会导致用户遇到延迟命中。这就是默认情况下禁用fielddata的原因。
如果您尝试对text
字段上的脚本进行排序,聚合或访问,您将看到以下异常:
默认情况下,在文本字段上禁用Fielddata。设置
fielddata=true
为[your_field_name
]以通过同相反向索引在内存中加载fielddata。请注意,这可能会占用大量内存。
3.Fielddata
一旦分词字符串被加载到 fielddata ,他们会一直在那里,直到被驱逐(或者节点崩溃)。由于这个原因,留意内存的使用情况,了解它是如何以及何时加载的,怎样限制对集群的影响是很重要的。
Fielddata 是 延迟 加载。如果你从来没有聚合一个分析字符串,就不会加载 fielddata 到内存中。此外,fielddata 是基于字段加载的, 这意味着只有很活跃地使用字段才会增加 fielddata 的负担。
然而,这里有一个令人惊讶的地方。假设你的查询是高度选择性和只返回命中的 100 个结果。大多数人认为 fielddata 只加载 100 个文档。
实际情况是,fielddata 会加载索引中(针对该特定字段的) 所有的文档,而不管查询的特异性。逻辑是这样:如果查询会访问文档 X、Y 和 Z,那很有可能会在下一个查询中访问其他文档。
与 doc values 不同,fielddata 结构不会在索引时创建。相反,它是在查询运行时,动态填充。这可能是一个比较复杂的操作,可能需要一些时间。 将所有的信息一次加载,再将其维持在内存中的方式要比反复只加载一个 fielddata 的部分代价要低。
JVM 堆 是有限资源的,应该被合理利用。 限制 fielddata 对堆使用的影响有多套机制,这些限制方式非常重要,因为堆栈的乱用会导致节点不稳定(感谢缓慢的垃圾回收机制),甚至导致节点宕机(通常伴随 OutOfMemory 异常)。
在设置 Elasticsearch 堆大小时需要通过 $ES_HEAP_SIZE 环境变量应用两个规则:
3.1.不要超过可用 RAM 的 50%
Lucene 能很好利用文件系统的缓存,它是通过系统内核管理的。如果没有足够的文件系统缓存空间,性能会收到影响。 此外,专用于堆的内存越多意味着其他所有使用 doc values 的字段内存越少。
3.2.不要超过 32 GB
如果堆大小小于 32 GB,JVM 可以利用指针压缩,这可以大大降低内存的使用:每个指针 4 字节而不是 8 字节。
4.Fielddata的大小
indices.fielddata.cache.size 控制为 fielddata 分配的堆空间大小。 当你发起一个查询,分析字符串的聚合将会被加载到 fielddata,如果这些字符串之前没有被加载过。如果结果中 fielddata 大小超过了指定大小,其他的值将会被回收从而获得空间。
默认情况下,这个设置是禁用的,Elasticsearch 永远都不会从 fielddata 中回收数据。
这个默认设置是刻意选择的:fielddata 不是临时缓存。它是驻留内存里的数据结构,必须可以快速执行访问,而且构建它的代价十分高昂。如果每个请求都重载数据,性能会十分糟糕。
一个有界的大小会强制数据结构回收数据。
设想我们正在对日志进行索引,每天使用一个新的索引。通常我们只对过去一两天的数据感兴趣,尽管我们会保留老的索引,但我们很少需要查询它们。不过如果采用默认设置,旧索引的 fielddata 永远不会从缓存中回收! fieldata 会保持增长直到 fielddata 发生断熔,这样我们就无法载入更多的 fielddata。
这个时候,我们被困在了死胡同。但我们仍然可以访问旧索引中的 fielddata,也无法加载任何新的值。相反,我们应该回收旧的数据,并为新值获得更多空间。
为了防止发生这样的事情,可以通过在 config/elasticsearch.yml 文件中增加配置为 fielddata 设置一个上限:
indices.fielddata.cache.size: 20% : 有了这个设置,最久未使用(LRU)的 fielddata 会被回收为新数据腾出空间。
4.1.监控fileddata
Fielddata 的使用可以被监控:
1).按索引使用 indices-stats API :GET /_stats/fielddata?fields=*
2).按节点使用 nodes-stats API : GET /_nodes/stats/indices/fielddata?fields=*
3).按索引节点:GET /_nodes/stats/indices/fielddata?level=indices&fields=*
4.2.断路器(Circuit Breakers)
fielddata 大小是在数据加载 之后 检查的。 如果一个查询试图加载比可用内存更多的信息到 fielddata 中会发生什么?答案很丑陋:我们会碰到 OutOfMemoryException 。
Elasticsearch 包括一个 fielddata 断熔器 ,这个设计就是为了处理上述情况。 断熔器通过内部检查(字段的类型、基数、大小等等)来估算一个查询需要的内存。它然后检查要求加载的 fielddata 是否会导致 fielddata 的总量超过堆的配置比例。
如果估算查询的大小超出限制,就会 触发 断路器,查询会被中止并返回异常。这都发生在数据加载 之前 ,也就意味着不会引起 OutOfMemoryException 。
Elasticsearch 有一系列的断路器,它们都能保证内存不会超出限制:
1).indices.breaker.fielddata.limit
fielddata 断路器默认设置堆的 60% 作为 fielddata 大小的上限。
2).indices.breaker.request.limit
request 断路器估算需要完成其他请求部分的结构大小,例如创建一个聚合桶,默认限制是堆内存的 40%。
3).indices.breaker.total.limit
total 揉合 request 和 fielddata 断路器保证两者组合起来不会使用超过堆内存的 70%。
断路器的限制可以在文件 config/elasticsearch.yml 中指定,可以动态更新一个正在运行的集群:
PUT /_cluster/settings { "persistent" : { "indices.breaker.fielddata.limit" : "40%" } }
关于给 fielddata 的大小加一个限制,从而确保旧的无用 fielddata 被回收的方法。 indices.fielddata.cache.size 和 indices.breaker.fielddata.limit 之间的关系非常重要。 如果断路器的限制低于缓存大小,没有数据会被回收。为了能正常工作,断路器的限制 必须 要比缓存大小要高。
4.3.fielddata过滤
PUT /music/_mapping/song { "properties": { "tag": { "type": "string", "fielddata": { "filter": { "frequency": { "min": 0.01, "min_segment_size": 500 } } } } } }
1).只加载那些至少在本段文档中出现 1% 的项。
2).忽略任何文档个数小于 500 的段。
有了这个映射,只有那些至少在 本段 文档中出现超过 1% 的项才会被加载到内存中。我们也可以指定一个 最大 词频,它可以被用来排除 常用 项,比如 停用词 。
这种情况下,词频是按照段来计算的。这是实现的一个限制:fielddata 是按段来加载的,所以可见的词频只是该段内的频率。但是,这个限制也有些有趣的特性:它可以让受欢迎的新项迅速提升到顶部。
min_segment_size 参数要求 Elasticsearch 忽略某个大小以下的段。 如果一个段内只有少量文档,它的词频会非常粗略没有任何意义。 小的分段会很快被合并到更大的分段中,某一刻超过这个限制,将会被纳入计算。
5.预加载fielddata
Elasticsearch 加载内存 fielddata 的默认行为是 延迟 加载 。 当 Elasticsearch 第一次查询某个字段时,它将会完整加载这个字段所有 Segment 中的倒排索引到内存中,以便于以后的查询能够获取更好的性能。
对于小索引段来说,这个过程的需要的时间可以忽略。但如果我们有一些 5 GB 的索引段,并希望加载 10 GB 的 fielddata 到内存中,这个过程可能会要数十秒。 已经习惯亚秒响应的用户很难会接受停顿数秒卡着没反应的网站。
有三种方式可以解决这个延时高峰:
1).预加载 fielddata
2).预加载全局序号
3).缓存预热
所有的变化都基于同一概念:预加载 fielddata ,这样在用户进行搜索时就不会碰到延迟高峰。
5.1.预加载
第一个工具称为 预加载 (与默认的 延迟加载相对)。随着新分段的创建(通过刷新、写入或合并等方式), 启动字段预加载可以使那些对搜索不可见的分段里的 fielddata 提前 加载。
这就意味着首次命中分段的查询不需要促发 fielddata 的加载,因为 fielddata 已经被载入到内存。避免了用户遇到搜索卡顿的情形。
预加载是按字段启用的,所以我们可以控制具体哪个字段可以预先加载:
PUT /music/_mapping/_song { "tags": { "type": "string", "fielddata": { "loading" : "eager" } } }
Fielddata 的载入可以使用 update-mapping API 对已有字段设置 lazy 或 eager 两种模式。
5.2全局序号
有种可以用来降低字符串 fielddata 内存使用的技术叫做 序号 。
设想我们有十亿文档,每个文档都有自己的 status 状态字段,状态总共有三种: status_pending 、 status_published 、 status_deleted 。如果我们为每个文档都保留其状态的完整字符串形式,那么每个文档就需要使用 14 到 16 字节,或总共 15 GB。
取而代之的是我们可以指定三个不同的字符串,对其排序、编号:0,1,2。
Ordinal | Term ------------------- 0 | status_deleted 1 | status_pending 2 | status_published 序号字符串在序号列表中只存储一次,每个文档只要使用数值编号的序号来替代它原始的值。 Doc | Ordinal ------------------------- 0 | 1 # pending 1 | 1 # pending 2 | 2 # published 3 | 0 # deleted
这样可以将内存使用从 15 GB 降到 1 GB 以下!
但这里有个问题,记得 fielddata 是按分 段 来缓存的。如果一个分段只包含两个状态( status_deleted 和 status_published )。那么结果中的序号(0 和 1)就会与包含所有三个状态的分段不一样。
如果我们尝试对 status 字段运行 terms 聚合,我们需要对实际字符串的值进行聚合,也就是说我们需要识别所有分段中相同的值。一个简单粗暴的方式就是对每个分段执行聚合操作,返回每个分段的字符串值,再将它们归纳得出完整的结果。 尽管这样做可行,但会很慢而且大量消耗 CPU。
取而代之的是使用一个被称为 全局序号 的结构。 全局序号是一个构建在 fielddata 之上的数据结构,它只占用少量内存。唯一值是 跨所有分段 识别的,然后将它们存入一个序号列表中,正如我们描述过的那样。
现在, terms 聚合可以对全局序号进行聚合操作,将序号转换成真实字符串值的过程只会在聚合结束时发生一次。这会将聚合(和排序)的性能提高三到四倍。
构建全局序号(Building global ordinals)
当然,天下没有免费的晚餐。 全局序号分布在索引的所有段中,所以如果新增或删除一个分段时,需要对全局序号进行重建。 重建需要读取每个分段的每个唯一项,基数越高(即存在更多的唯一项)这个过程会越长。
全局序号是构建在内存 fielddata 和 doc values 之上的。实际上,它们正是 doc values 性能表现不错的一个主要原因。
和 fielddata 加载一样,全局序号默认也是延迟构建的。首个需要访问索引内 fielddata 的请求会促发全局序号的构建。由于字段的基数不同,这会导致给用户带来显著延迟这一糟糕结果。一旦全局序号发生重建,仍会使用旧的全局序号,直到索引中的分段产生变化:在刷新、写入或合并之后。
预构建全局序号(Eager global ordinals)
单个字符串字段 可以通过配置预先构建全局序号:
PUT /music/_mapping/_song { "song_title": { "type": "string", "fielddata": { "loading" : "eager_global_ordinals" } } }
正如 fielddata 的预加载一样,预构建全局序号发生在新分段对于搜索可见之前。
序号的构建只被应用于字符串。数值信息(integers(整数)、geopoints(地理经纬度)、dates(日期)等等)不需要使用序号映射,因为这些值自己本质上就是序号映射。 因此,我们只能为字符串字段预构建其全局序号
也可以对 Doc values 进行全局序号预构建:
PUT /music/_mapping/_song { "song_title": { "type": "string", "doc_values": true, "fielddata": { "loading" : "eager_global_ordinals" } } }
这种情况下,fielddata 没有载入到内存中,而是 doc values 被载入到文件系统缓存中。
与 fielddata 预加载不一样,预建全局序号会对数据的 实时性 产生影响,构建一个高基数的全局序号会使一个刷新延时数秒。 选择在于是每次刷新时付出代价,还是在刷新后的第一次查询时。如果经常索引而查询较少,那么在查询时付出代价要比每次刷新时要好。如果写大于读,那么在选择在查询时重建全局序号将会是一个更好的选择。
5.3.索引预热器(index warmers)
最后我们谈谈 索引预热器 。预热器早于 fielddata 预加载和全局序号预加载之前出现,它们仍然尤其存在的理由。一个索引预热器允许我们指定一个查询和聚合须要在新分片对于搜索可见之前执行。 这个想法是通过预先填充或 预热缓存 让用户永远无法遇到延迟的波峰。
原来,预热器最重要的用法是确保 fielddata 被预先加载,因为这通常是最耗时的一步。现在可以通过前面讨论的那些技术来更好的控制它,但是预热器还是可以用来预建过滤器缓存,当然我们也还是能选择用它来预加载 fielddata。
让我们注册一个预热器然后解释发生了什么:
PUT /music/_warmer/warmer_1 { "query" : { "bool" : { "filter" : { "bool": { "should": [ { "term": { "tag": "rock" }}, { "term": { "tag": "hiphop" }}, { "term": { "tag": "electronics" }} ] } } } }, "aggs" : { "price" : { "histogram" : { "field" : "price", "interval" : 10 } } } }
1).预热器被关联到索引( music )上,使用接入口 _warmer 以及 ID ( warmer_1 )。
2).为三种最受欢迎的曲风预建过滤器缓存。
3).字段 price 的 fielddata 和全局序号会被预加载。
预热器是根据具体索引注册的, 每个预热器都有唯一的 ID ,因为每个索引可能有多个预热器。
然后我们可以指定查询,任何查询。它可以包括查询、过滤器、聚合、排序值、脚本,任何有效的查询表达式都毫不夸张。 这里的目的是想注册那些可以代表用户产生流量压力的查询,从而将合适的内容载入缓存。
当新建一个分段时,Elasticsearch 将会执行注册在预热器中的查询。执行这些查询会强制加载缓存,只有在所有预热器执行完,这个分段才会对搜索可见。