1.Doc Values
在es搜索中使用的是倒排索引的数据结构,而在聚合中使用的是一个叫Doc Values的数据结构,该结构可以使得聚合更快、更高效对内存更友好。
当你需要在索引中检索一个值时,此时倒排索引的结构非常适合索引,但是当你对该值进行排序时,该结构就不太适合了,因为我们需要获取该文档中该字段对应的值,此时我们就需要正排索引。
在es中,Doc Values 就是一种列式存储结构,默认情况下,在索引一个文档时,也会创建对应的Doc Values 。
es中的 Doc Values 常被应用到以下场景:
(1)对一个字段进行排序。
(2)对一个字段进行聚合。
(3)某些过滤,比如地理位置过滤。
(4)某些与字段相关的脚本计算。
Doc Values和倒排索引一样,基于段生成且不可变,同时都是序列化到磁盘,所以对性能和扩展有很大帮助。
因为文档值被序列化到磁盘,所以我们可以充分利用操作系统的内存。当 working set 远小于节点的可用内存,系统会所有的Doc Values值保存在操作系统的内存中,使得其读写十分高速; 当其远大于可用内存,操作系统会根据需要从磁盘读取Doc Values然后选择性的放到分页缓存中。
因为Doc Values不是由JVM进行管理,所以es可以配置一个更小的JVM Heap,这样可以给系统留下更多的内存。
由于Doc Values是列式存储,所以这种方式也非常便于压缩,尤其是数字类型,在压缩过程中会使用以下技巧:
(1)如果所有的数值各不相同(或缺失),设置一个标记并记录这些值。
(2)如果这些值小于 256,将使用一个简单的编码表。
(3)如果这些值大于 256,检测是否存在一个最大公约数。
(4)如果没有存在最大公约数,从最小的数值开始,统一计算偏移量进行编码。
比如多个doc中的 num 字段分别为[100, 200, 300, 400, 500],构建Doc Values时,会按照以上规则进行构建,它会发现这些字段值的公约数为100, 则它会构建[1, 2, 3, 4, 5]然后保存100作为此段的除数。
Doc Values默认会对所有不分词的字段开启,需要分词的字段并不适合Doc Values,因为会产生大量的Token, 会大大影响聚合的操作速率。而且会影响聚合的结果,如果你的索引不需要进行排序、聚合等操作你可以通过在设置该索引的mapping时,通过doc_values参数禁用该字段Doc Values,反过来也是可以的,如果你只想该字段参与聚合操作,不想它被搜索到, 可以设置 doc_values为true, index为false。
PUT my_index
{
"mappings": {
"my_type": {
"properties": {
"session_id": {
"type": "string",
"index": "not_analyzed",
"doc_values": false
"index": "true" //默认就为true
}
}
}
}
}
2. FieldData
我们之前说过,分词的字段不适合分词,如果对分词的字段进行聚合结果很可能会出乎意料。比如下边这个例子。
POST /agg_analysis/data/_bulk
{ "index": {}}
{ "state" : "New York" }
{ "index": {}}
{ "state" : "New Jersey" }
{ "index": {}}
{ "state" : "New Mexico" }
{ "index": {}}
{ "state" : "New York" }
{ "index": {}}
{ "state" : "New York" }
然后对该字段进行分组操作
GET /agg_analysis/data/_search
{
"size" : 0,
"aggs" : {
"states" : {
"terms" : {
"field" : "state"
}
}
}
}
然后会得到如下结果
{
...
"aggregations": {
"states": {
"buckets": [
{
"key": "new",
"doc_count": 5
},
{
"key": "york",
"doc_count": 3
},
{
"key": "jersey",
"doc_count": 1
},
{
"key": "mexico",
"doc_count": 1
}
]
}
}
}
我们可以看到,结果中显示的不是某个字段值对应的文档数量,而是某个 token 对应的文档数量。在本文中,我们说过Doc Values不支持分词的字段,因为它不能有效的表示多值字符串,但在上例中,分词的字段仍然可以使用聚合操作,这是为什么呢?
因为一种叫 FiledData 的数据结构,与Doc Values不同,它由JVM管理,常驻于JVM Heap中。由于FiledData是存储在内存中,所以一些高基数字段在生成 FiledData 是会消耗大量的内存,比如 n-gram的分析过程。
一旦分析字符串被加载到 fielddata ,他们会一直在那里,直到被驱逐(或者节点崩溃)。
Fielddata 是延迟加载。只有使用该字段进行聚合操作,才会加载该字段的 filedData 到内存中。此外,fielddata 是基于字段加载的,事实上,filedData 会加载针对该字段的所有文档。
与Doc Values不同,在索引文档时,不会创建 filedData, 而是在查询运行时,动态填充。因为filedData 是由JVM管理,所以资源有限,所以应该限制 FiledData 所占资源的大小,否则会导致节点不稳定(垃圾回收机制),甚至抛出异常(OOM)。
可以通过设置indices.fielddata.cache.size控制JVM为 filedData 分配的度空间大小,当一个没有加载过的字段,被分析聚合时,将会加载该字段的 filedData, 如果超过指定的空间,则会通过LRU算法剔除其他的值。默认es永远都不会剔除 filedData 。
我们都知道当一个字段的 filedData 加载之后,才会知道他所占用的内存大小,如果超过了可用内存, 将会抛出OOM异常。
es包括一个 fielddata 断熔器 ,该设计就是为了处理上述情况。 断熔器通过内部检查(字段的类型、基数、大小等等)来估算一个查询需要的内存。它然后检查要求加载的 fielddata 是否会超过配置的可用内存大小,如果超过,则将会结束该次查询,抛出异常。该断路器的配置请参考 断路器。
es加载 FiledData 是延迟加载,对于一些小的 filedData 加载时间会非常短,但是对于一些非常大的 filedData 所需时间有可能数十秒,这个过程对用户来说是不可能接受的,所以有以下三种解决这种问题:
(1) 预加载 FiledData
启动预加载,可以使得在新分段创建过程,就将该字段的filedData 加载到内存中。
PUT /music/_mapping/_song
{
"tags": {
"type": "string",
"fielddata": {
"loading" : "eager" // lazy
}
}
}
(2)全局序号
当该文档中的某个字段有很多重复的值时,比如 status1 , status2, status3 等等,它将会给这些状态保存一份状态到序号的映射,该全局序号是一个构建在 fielddata 之上的数据结构, 而且跨所有分段识别的,在进行聚合的过程中,将使用这些全局序号进行操作,在聚合完成之后,才会将这些序号替换成真实的值将结果返回。
PUT /music/_mapping/_song
{
"song_title": {
"type": "string",
"fielddata": {
"loading" : "eager_global_ordinals"
}
}
}
可以通过上述命令进行开启某个字段的全局序号,注意:序号的加载只适用于字符串,数值、地理、日期等不需要,因为本质上自己本身就是序号映射。
当然天下没有免费的午餐, 全局序号分布在索引的所有段中,所以如果新增或删除一个分段时,需要对全局序号进行重建。 重建需要读取每个分段的每个唯一项,基数越高(即存在更多的唯一项)这个过程会越长。
(3)索引预热器
该方式早于前两种方式出现,主要是使我们的一个查询或者聚合需要在新分片被搜索之前执行,
PUT /music/_warmer/warmer_id
{
"query" : {
"bool" : {
"filter" : {
"bool": {
"should": [
{ "term": { "tag": "rock" }},
{ "term": { "tag": "hiphop" }},
{ "term": { "tag": "electronics" }}
]
}
}
}
},
"aggs" : {
"price" : {
"histogram" : {
"field" : "price",
"interval" : 10
}
}
}
}
如上例所示,预热器基于索引进行创建的,每个预热器有唯一的id, 每个索引包括多个预热器。
当新建一个分段时,es会首先执行注册在该索引中的所有预热器,目的使用来手动加载 filedData ,和进行过滤器缓存, 用来提高搜索和聚合效率。
3.优化聚合查询
es允许我们改变聚合的集合模式默认的的策略叫做 深度优先 , 先构建完整的树,然后修剪无用节点。另一种集合策略叫做广度优先 。这种策略的工作方式有些不同,它先执行第一层聚合, 再继续下一层聚合之前会先做修剪。
比如接下来的这个例子,我们想找到今年最受欢迎的3个演员,和与他们合作最多的3个演员。
{
"aggs" : {
"actors" : {
"terms" : {
"field" : "actors",
"size" : 3,
"collect_mode" : "depth_first"
},
"aggs" : {
"cooperationActors" : {
"terms" : {
"field" : "cooperations",
"size" : 3
}
}
}
}
}
}
es会按照默认的集合模式深度优先进行构建结果,执行流程如下:
(1)现获取所有的演员,然后根据doc_count进行排序。
(2)获取该演员的合作过的所有演员,然后根据doc_count进行排序。
(3)裁剪结果,首先裁剪出第一步中前三个结果,然后在分别裁剪出这三个结果中的前三个结果。然后将结果返回。
这样的工作方式对大多数聚合操作都能正常工作,但是对于该例不太适合,应为我们只需要知道前三个结果就可以了,不需要再为其他的演员构建结果树了。所以我们可以将collect_mode参数设置为breadth_first。这样该流程的第一步就会变为如下:
获取所有的演员,然后根据doc_count进行排序,然后裁剪出前三条结果......。