Doc Values and Fielddata

Doc Values

聚合使用一个叫 doc values 的数据结构(在 Doc Values 介绍 里简单介绍)。 Doc values 可以使聚合更快、更高效并且内存友好,所以理解它的工作方式十分有益。

Doc values 的存在是因为倒排索引只对某些操作是高效的。 倒排索引的优势 在于查找包含某个项的文档,而对于从另外一个方向的相反操作并不高效,即:确定哪些项是否存在单个文档里,聚合需要这种次级的访问模式。

对于以下倒排索引:

Term      Doc_1   Doc_2   Doc_3

brown | X | X | dog | X | | X dogs | | X | X fox | X | | X foxes | | X | in | | X | jumped | X | | X lazy | X | X | leap | | X | over | X | X | X quick | X | X | X summer | | X | the | X | | X ------------------------------------</pre>

如果我们想要获得所有包含 brown 的文档的词的完整列表,我们会创建如下查询:

GET /my_index/_search
{
  "query" : {
    "match" : {
      "body" : "brown"
    }
  },
  "aggs" : {
    "popular_terms": {
      "terms" : {
        "field" : "body"
      }
    }
  }
}

查询部分简单又高效。倒排索引是根据项来排序的,所以我们首先在词项列表中找到 brown ,然后扫描所有列,找到包含 brown 的文档。我们可以快速看到 Doc_1Doc_2 包含 brown 这个 token。

然后,对于聚合部分,我们需要找到 Doc_1Doc_2 里所有唯一的词项。 用倒排索引做这件事情代价很高: 我们会迭代索引里的每个词项并收集 Doc_1Doc_2 列里面 token。这很慢而且难以扩展:随着词项和文档的数量增加,执行时间也会增加。

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 -----------------------------------------------------------------</pre>

当数据被转置之后,想要收集到 Doc_1Doc_2 的唯一 token 会非常容易。获得每个文档行,获取所有的词项,然后求两个集合的并集。

因此,搜索和聚合是相互紧密缠绕的。搜索使用倒排索引查找文档,聚合操作收集和聚合 doc values 里的数据。

Note Doc values 不仅可以用于聚合。 任何需要查找某个文档包含的值的操作都必须使用它。 除了聚合,还包括排序,访问字段值的脚本,父子关系处理(参见 父-子关系文档 )。

深入文档值

在上一节一开头我们就说文档值(doc values) 是 "更快、更高效并且内存友好" 。 听起来好像是不错的营销术语,不过话说回来文档值到底是如何工作的呢?

文档值是在索引时与倒排索引同时产生的。也就是说文档值是按段来产生的并且是不可变的,正如用于搜索的倒排索引一样。 同样,和倒排索引一样,文档值也序列化到磁盘。这些对于性能和伸缩性很重要。

通过序列化一个持久化的数据结构到磁盘,我们可以依赖于操作系统的缓存来管理内存,而不是在 JVM 堆栈里驻留数据。 当 “工作集(working set)” 数据要小于系统可用内存的情况下,操作系统会自然的将文档值驻留在内存,这将会带来和直接使用 JVM 堆栈数据结构相同的性能。

不过,如果你的工作集远大于可用内存,操作系统会开始根据需要对文档值进行分页开/关。这会显著慢于纯内存驻留的数据结构,当然,它也拥有使用远大于服务器内存容量的伸缩性的好处。 如果这些数据结构是纯粹的存储于 JVM 堆内存,那么唯一的选项只能是随着内存溢出(OutOfMemory)而崩溃(或是实现一个分页模式,正如操作系统的那样)。

Note 因为文档值不是由 JVM 来管理,所以 Elasticsearch 服务器可以配置一个很小的 JVM 堆栈。 这会给操作系统带来更多的内存来做缓存。同时也带来一个好处就是让 JVM 的垃圾回收器工作在一个很小的堆栈,结果就是更快更高效的回收周期。

列式存储的压缩

从广义来说,文档值本质上是一个序列化的 列式存储 。 正如我们上一节所讨论的,列式存储 擅长某些操作,因为这些数据的存储天然适合这些查询。

而且,他们也同样擅长数据压缩,特别是数字。 这对于节省磁盘空间和快速访问很重要。现代 CPU 的处理速度要比磁盘快几个数量级(尽管即将到来的 NVMe 驱动器正在迅速缩小差距)。 这意味着减少必须从磁盘读取的数据量总是有益的,尽管需要额外的 CPU 运算来进行解压。

要了解它如何帮助压缩数据,来看一组数字类型的文档值:

Doc      Terms

Doc_1 | 100 Doc_2 | 1000 Doc_3 | 1500 Doc_4 | 1200 Doc_5 | 300 Doc_6 | 1900 Doc_7 | 4200 -----------------------------------------------------------------</pre>

按列布局意味着我们有一个连续的数据块: [100,1000,1500,1200,300,1900,4200] 。因为我们已经知道他们都是数字(而不是像文档或行中看到的异构集合),所以我们可以使用统一的偏移来将他们紧紧排列。

而且,针对这样的数字有很多种压缩技巧。 你会注意到这里每个数字都是 100 的倍数,文档值会检测一个段里面的所有数值,并使用一个 最大公约数 ,方便做进一步的数据压缩。

如果我们保存 100 作为此段的除数,我们可以对每个数字都除以 100,然后得到: [1,10,15,12,3,19,42] 。现在这些数字变小了,只需要很少的位就可以存储下,也减少了磁盘存放的大小。

文档值正是使用了像这样的一些技巧。它会按依次检测以下压缩模式:

  1. 如果所有的数值各不相同(或缺失),设置一个标记并记录这些值

  2. 如果这些值小于 256,将使用一个简单的编码表

  3. 如果这些值大于 256,检测是否存在一个最大公约数

  4. 如果没有存在最大公约数,从最小的数值开始,统一计算偏移量进行编码

你会发现这些压缩模式不是传统的通用的压缩方式,比如 DEFLATE 或是 LZ4。 因为列式存储的结构是严格且良好定义的,我们可以通过使用专门的模式来达到比通用压缩算法(如 LZ4 )更高的压缩效果。

Note 你也许会想 "好吧,貌似对数字很好,不知道字符串怎么样?" 通过借助顺序表(ordinal table),字符类型也是类似进行编码的。字符类型是去重之后存放到顺序表的,通过分配一个 ID,然后这些 ID 和数值类型的文档值一样使用。 也就是说,字符类型和数值类型一样拥有相同的压缩特性。

禁用文档值

文档值默认对所有字段启用,除了分析字符类型字段。也就是说所有的数字、地理坐标、日期、IP 和不分析( not_analyzed )字符类型。

分析字符类型暂时还不使用文档值。分析流程会产生很多新的 token,这会让文档值不能高效的工作。我们将在 聚合与分析 讨论如何使用分析字符类型来做聚合。

因为文档值默认启用,你可以选择对你数据集里面的大多数字段进行聚合和排序操作。但是如果你知道你永远也不会对某些字段进行聚合、排序或是使用脚本操作?

尽管罕见,但当这些情况出现时,你还是希望有办法来为特定的字段禁用文档值。这回为你节省磁盘空间(因为文档值再也没有序列化到磁盘),也许还能提升些许索引速度(因为不需要生成文档值)。

要禁用文档值,在字段的映射(mapping)设置 doc_values: false 即可。例如,这里我们创建了一个新的索引,字段 &quot;session_id&quot; 禁用了文档值:

PUT my_index
{
  "mappings": {
    "my_type": {
      "properties": {
        "session_id": {
          "type":       "string",
          "index":      "not_analyzed",
          "doc_values": false (1)
        }
      }
    }
  }
}
  1. 通过设置 doc_values: false ,这个字段将不能被用于聚合、排序以及脚本操作

反过来也是可以进行配置的:让一个字段可以被聚合,通过禁用倒排索引,使它不能被正常搜索,例如:

PUT my_index
{
  "mappings": {
    "my_type": {
      "properties": {
        "customer_token": {
          "type":       "string",
          "index":      "not_analyzed",
          "doc_values": true, (1)
          "index": "no" (2)
        }
      }
    }
  }
}
  1. 文档值被启用来允许聚合

  2. 索引被禁用了,这让该字段不能被查询/搜索

通过设置 doc_values: trueindex: no ,我们得到一个只能被用于聚合/排序/脚本的字段。无可否认,这是一个非常罕见的需求,但有时很有用。

聚合与分析

有些聚合,比如 terms 桶, 操作字符串字段。字符串字段可能是 analyzed 或者 not_analyzed , 那么问题来了,分析是怎么影响聚合的呢?

答案是影响“很多”,有两个原因:分析影响聚合中使用的 tokens ,并且 doc values 不能使用于 分析字符串。

让我们解决第一个问题:分析 tokens 的产生如何影响聚合。首先索引一些代表美国各个州的文档:

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" }

我们希望创建一个数据集里各个州的唯一列表,并且计数。 简单,让我们使用 terms 桶:

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
            }
         ]
      }
   }
}

宝贝儿,这完全不是我们想要的!没有对州名计数,聚合计算了每个词的数目。背后的原因很简单:聚合是基于倒排索引创建的,倒排索引是 后置分析( post-analysis )的。

当我们把这些文档加入到 Elasticsearch 中时,字符串 &quot;New York&quot; 被分析/分析成 [&quot;new&quot;, &quot;york&quot;] 。这些单独的 tokens ,都被用来填充聚合计数,所以我们最终看到 new 的数量而不是 New York

这显然不是我们想要的行为,但幸运的是很容易修正它。

我们需要为 state 定义 multifield 并且设置成 not_analyzed 。这样可以防止 New York 被分析,也意味着在聚合过程中它会以单个 token 的形式存在。让我们尝试完整的过程,但这次指定一个 raw multifield:

DELETE /agg_analysis/
PUT /agg_analysis
{
  "mappings": {
    "data": {
      "properties": {
        "state" : {
          "type": "string",
          "fields": {
            "raw" : {
              "type": "string",
              "index": "not_analyzed"(1)
            }
          }
        }
      }
    }
  }
}

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.raw" (2)
        }
    }
  }
}
  1. 这次我们显式映射 state 字段并包括一个 not_analyzed 辅字段。

  2. 聚合针对 state.raw 字段而不是 state 。

现在运行聚合,我们得到了合理的结果:

{
...
   "aggregations": {
      "states": {
         "buckets": [
            {
               "key": "New York",
               "doc_count": 3
            },
            {
               "key": "New Jersey",
               "doc_count": 1
            },
            {
               "key": "New Mexico",
               "doc_count": 1
            }
         ]
      }
   }
}

在实际中,这样的问题很容易被察觉,我们的聚合会返回一些奇怪的桶,我们会记住分析的问题。 总之,很少有在聚合中使用分析字段的实例。当我们疑惑时,只要增加一个 multifield 就能有两种选择。

分析字符串和 Fielddata(Analyzed strings and Fielddata)

当第一个问题涉及如何聚合数据并显示给用户,第二个问题主要是技术和幕后。

Doc values 不支持 analyzed 字符串字段,因为它们不能很有效的表示多值字符串。 Doc values 最有效的是,当每个文档都有一个或几个 tokens 时, 但不是无数的,分析字符串(想象一个 PDF ,可能有几兆字节并有数以千计的独特 tokens)。

出于这个原因,doc values 不生成分析的字符串,然而,这些字段仍然可以使用聚合,那怎么可能呢?

答案是一种被称为 fielddata 的数据结构。与 doc values 不同,fielddata 构建和管理 100% 在内存中,常驻于 JVM 内存堆。这意味着它本质上是不可扩展的,有很多边缘情况下要提防。 本章的其余部分是解决在分析字符串上下文中 fielddata 的挑战。

Note 从历史上看,fielddata 是 所有 字段的默认设置。但是 Elasticsearch 已迁移到 doc values 以减少 OOM 的几率。分析的字符串是仍然使用 fielddata 的最后一块阵地。 最终目标是建立一个序列化的数据结构类似于 doc values ,可以处理高维度的分析字符串,逐步淘汰 fielddata。

高基数内存的影响(High-Cardinality Memory Implications)

避免分析字段的另外一个原因就是:高基数字段在加载到 fielddata 时会消耗大量内存。 分析的过程会经常(尽管不总是这样)生成大量的 token,这些 token 大多都是唯一的。 这会增加字段的整体基数并且带来更大的内存压力。

有些类型的分析对于内存来说 极度 不友好,想想 n-gram 的分析过程, New York 会被 n-gram 分析成以下 token:

  • ne

  • ew

  • w{nbsp}

  • {nbsp}y

  • yo

  • or

  • rk

可以想象 n-gram 的过程是如何生成大量唯一 token 的,特别是在分析成段文本的时候。当这些数据加载到内存中,会轻而易举的将我们堆空间消耗殆尽。

因此,在聚合字符串字段之前,请评估情况:

  • 这是一个 not_analyzed 字段吗?如果是,可以通过 doc values 节省内存 。

  • 否则,这是一个 analyzed 字段,它将使用 fielddata 并加载到内存中。这个字段因为 ngrams 有一个非常大的基数?如果是,这对于内存来说极度不友好。

限制内存使用

一旦分析字符串被加载到 fielddata ,他们会一直在那里,直到被驱逐(或者节点崩溃)。由于这个原因,留意内存的使用情况,了解它是如何以及何时加载的,怎样限制对集群的影响是很重要的。

Fielddata 是 延迟 加载。如果你从来没有聚合一个分析字符串,就不会加载 fielddata 到内存中。此外,fielddata 是基于字段加载的, 这意味着只有很活跃地使用字段才会增加 fielddata 的负担。

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

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

与 doc values 不同,fielddata 结构不会在索引时创建。相反,它是在查询运行时,动态填充。这可能是一个比较复杂的操作,可能需要一些时间。 将所有的信息一次加载,再将其维持在内存中的方式要比反复只加载一个 fielddata 的部分代价要低。

JVM 堆 是有限资源的,应该被合理利用。 限制 fielddata 对堆使用的影响有多套机制,这些限制方式非常重要,因为堆栈的乱用会导致节点不稳定(感谢缓慢的垃圾回收机制),甚至导致节点宕机(通常伴随 OutOfMemory 异常)。

选择堆大小(Choosing a Heap Size)

在设置 Elasticsearch 堆大小时需要通过 $ES_HEAP_SIZE 环境变量应用两个规则:

  • 不要超过可用 RAM 的 50%
  • Lucene 能很好利用文件系统的缓存,它是通过系统内核管理的。如果没有足够的文件系统缓存空间,性能会受到影响。 此外,专用于堆的内存越多意味着其他所有使用 doc values 的字段内存越少。

  • 不要超过 32 GB

  • 如果堆大小小于 32 GB,JVM 可以利用指针压缩,这可以大大降低内存的使用:每个指针 4 字节而不是 8 字节。

更详细和更完整的堆大小讨论,请参阅 堆内存:大小和交换

Fielddata的大小

indices.fielddata.cache.size 控制为 fielddata 分配的堆空间大小。 当你发起一个查询,分析字符串的聚合将会被加载到 fielddata,如果这些字符串之前没有被加载过。如果结果中 fielddata 大小超过了指定 大小 ,其他的值将会被回收从而获得空间。

默认情况下,设置都是 unbounded ,Elasticsearch 永远都不会从 fielddata 中回收数据。

这个默认设置是刻意选择的:fielddata 不是临时缓存。它是驻留内存里的数据结构,必须可以快速执行访问,而且构建它的代价十分高昂。如果每个请求都重载数据,性能会十分糟糕。

一个有界的大小会强制数据结构回收数据。我们会看何时应该设置这个值,但请首先阅读以下警告:

Warning 这个设置是一个安全卫士,而非内存不足的解决方案。

设想我们正在对日志进行索引,每天使用一个新的索引。通常我们只对过去一两天的数据感兴趣,尽管我们会保留老的索引,但我们很少需要查询它们。不过如果采用默认设置,旧索引的 fielddata 永远不会从缓存中回收! fieldata 会保持增长直到 fielddata 发生断熔(请参阅 断路器),这样我们就无法载入更多的 fielddata。

这个时候,我们被困在了死胡同。但我们仍然可以访问旧索引中的 fielddata,也无法加载任何新的值。相反,我们应该回收旧的数据,并为新值获得更多空间。

为了防止发生这样的事情,可以通过在 config/elasticsearch.yml 文件中增加配置为 fielddata 设置一个上限:

indices.fielddata.cache.size:  20% (1)
  1. 可以设置堆大小的百分比,也可以是某个值,例如: 5gb

有了这个设置,最久未使用(LRU)的 fielddata 会被回收为新数据腾出空间。

Warning 可能发现在线文档有另外一个设置: indices.fielddata.cache.expire

监控 fielddata(Monitoring fielddata)

无论是仔细监控 fielddata 的内存使用情况, 还是看有无数据被回收都十分重要。高的回收数可以预示严重的资源问题以及性能不佳的原因。

Fielddata 的使用可以被监控:

  • 按索引使用 indices-stats API

    GET /_stats/fielddata?fields=*
    
  • 按节点使用 nodes-stats API

    GET /_nodes/stats/indices/fielddata?fields=*
    
  • 按索引节点:

GET /_nodes/stats/indices/fielddata?level=indices&fields=*

使用设置 ?fields=* ,可以将内存使用分配到每个字段。

断路器

机敏的读者可能已经发现 fielddata 大小设置的一个问题。fielddata 大小是在数据加载 之后 检查的。 如果一个查询试图加载比可用内存更多的信息到 fielddata 中会发生什么?答案很丑陋:我们会碰到 OutOfMemoryException 。

Elasticsearch 包括一个 fielddata 断熔器 ,这个设计就是为了处理上述情况。 断熔器通过内部检查(字段的类型、基数、大小等等)来估算一个查询需要的内存。它然后检查要求加载的 fielddata 是否会导致 fielddata 的总量超过堆的配置比例。

如果估算查询的大小超出限制,就会 触发 断路器,查询会被中止并返回异常。这都发生在数据加载 之前 ,也就意味着不会引起 OutOfMemoryException 。

可用的断路器(Available Circuit Breakers)

Elasticsearch 有一系列的断路器,它们都能保证内存不会超出限制:

  • indices.breaker.fielddata.limit
  • fielddata 断路器默认设置堆的 60% 作为 fielddata 大小的上限。

  • indices.breaker.request.limit

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

  • indices.breaker.total.limit

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

断路器的限制可以在文件 config/elasticsearch.yml 中指定,可以动态更新一个正在运行的集群:

PUT /_cluster/settings
{
  "persistent" : {
    "indices.breaker.fielddata.limit" : "40%" (1)
  }
}
  1. 这个限制是按对内存的百分比设置的。

最好为断路器设置一个相对保守点的值。 记住 fielddata 需要与 request 断路器共享堆内存、索引缓冲内存和过滤器缓存。Lucene 的数据被用来构造索引,以及各种其他临时的数据结构。 正因如此,它默认值非常保守,只有 60% 。过于乐观的设置可能会引起潜在的堆栈溢出(OOM)异常,这会使整个节点宕掉。

另一方面,过度保守的值只会返回查询异常,应用程序可以对异常做相应处理。异常比服务器崩溃要好。这些异常应该也能促进我们对查询进行重新评估:为什么单个查询需要超过堆内存的 60% 之多?

Tip Fielddata的大小 中,我们提过关于给 fielddata 的大小加一个限制,从而确保旧的无用 fielddata 被回收的方法。 indices.fielddata.cache.sizeindices.breaker.fielddata.limit 之间的关系非常重要。 如果断路器的限制低于缓存大小,没有数据会被回收。为了能正常工作,断路器的限制 必须 要比缓存大小要高。

值得注意的是:断路器是根据总堆内存大小估算查询大小的,而 根据实际堆内存的使用情况。 这是由于各种技术原因造成的(例如,堆可能看上去是满的但实际上可能只是在等待垃圾回收,这使我们难以进行合理的估算)。但作为终端用户,这意味着设置需要保守,因为它是根据总堆内存必要的,而 不是 可用堆内存。

Fielddata 的过滤

设想我们正在运行一个网站允许用户收听他们喜欢的歌曲。 为了让他们可以更容易的管理自己的音乐库,用户可以为歌曲设置任何他们喜欢的标签,这样我们就会有很多歌曲被附上 rock(摇滚)hiphop(嘻哈)electronica(电音) ,但也会有些歌曲被附上 my_16th_birthday_favorite_anthem 这样的标签。

现在设想我们想要为用户展示每首歌曲最受欢迎的三个标签,很有可能 rock 这样的标签会排在三个中的最前面,而 my_16th_birthday_favorite_anthem 则不太可能得到评级。 尽管如此,为了计算最受欢迎的标签,我们必须强制将这些一次性使用的项加载到内存中。

感谢 fielddata 过滤,我们可以控制这种状况。我们 知道 自己只对最流行的项感兴趣,所以我们可以简单地避免加载那些不太有意思的长尾项:

PUT /music/_mapping/song
{
  "properties": {
    "tag": {
      "type": "string",
      "fielddata": { (1)
        "filter": {
          "frequency": { (2)
            "min":              0.01, (3)
            "min_segment_size": 500  (4)
          }
        }
      }
    }
  }
}
  1. fielddata 关键字允许我们配置 fielddata 处理该字段的方式。

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

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

  4. 忽略任何文档个数小于 500 的段。

有了这个映射,只有那些至少在 本段 文档中出现超过 1% 的项才会被加载到内存中。我们也可以指定一个 最大 词频,它可以被用来排除 常用 项,比如 停用词

这种情况下,词频是按照段来计算的。这是实现的一个限制:fielddata 是按段来加载的,所以可见的词频只是该段内的频率。但是,这个限制也有些有趣的特性:它可以让受欢迎的新项迅速提升到顶部。

比如一个新风格的歌曲在一夜之间受大众欢迎,我们可能想要将这种新风格的歌曲标签包括在最受欢迎列表中,但如果我们倚赖对索引做完整的计算获取词频,我们就必须等到新标签变得像 rockelectronica )一样流行。 由于频度过滤的实现方式,新加的标签会很快作为高频标签出现在新段内,也当然会迅速上升到顶部。

min_segment_size 参数要求 Elasticsearch 忽略某个大小以下的段。 如果一个段内只有少量文档,它的词频会非常粗略没有任何意义。 小的分段会很快被合并到更大的分段中,某一刻超过这个限制,将会被纳入计算。

Tip 通过频次来过滤项并不是唯一的选择,我们也可以使用正则式来决定只加载那些匹配的项。例如,我们可以用 regex 过滤器 处理 twitte 上的消息只将以 # 号开始的标签加载到内存中。 这假设我们使用的分析器会保留标点符号,像 whitespace 分析器。

Fielddata 过滤对内存使用有 巨大的 影响,权衡也是显而易见的:我们实际上是在忽略数据。但对于很多应用,这种权衡是合理的,因为这些数据根本就没有被使用到。内存的节省通常要比包括一个大量而无用的长尾项更为重要。

预加载 fielddata

Elasticsearch 加载内存 fielddata 的默认行为是 延迟 加载 。 当 Elasticsearch 第一次查询某个字段时,它将会完整加载这个字段所有 Segment 中的倒排索引到内存中,以便于以后的查询能够获取更好的性能。

对于小索引段来说,这个过程的需要的时间可以忽略。但如果我们有一些 5 GB 的索引段,并希望加载 10 GB 的 fielddata 到内存中,这个过程可能会要数十秒。 已经习惯亚秒响应的用户很难会接受停顿数秒卡着没反应的网站。

有三种方式可以解决这个延时高峰:

  • 预加载 fielddata

  • 预加载全局序号

  • 缓存预热

所有的变化都基于同一概念:预加载 fielddata ,这样在用户进行搜索时就不会碰到延迟高峰。

预加载 fielddata(Eagerly Loading Fielddata)

第一个工具称为 预加载 (与默认的 延迟加载相对)。随着新分段的创建(通过刷新、写入或合并等方式), 启动字段预加载可以使那些对搜索不可见的分段里的 fielddata 提前 加载。

这就意味着首次命中分段的查询不需要促发 fielddata 的加载,因为 fielddata 已经被载入到内存。避免了用户遇到搜索卡顿的情形。

预加载是按字段启用的,所以我们可以控制具体哪个字段可以预先加载:

PUT /music/_mapping/_song
{
  "tags": {
    "type": "string",
    "fielddata": {
      "loading" : "eager" (1)
    }
  }
}
  1. 设置 fielddata.loading: eager 可以告诉 Elasticsearch 预先将此字段的内容载入内存中。

Fielddata 的载入可以使用 update-mapping API 对已有字段设置 lazyeager 两种模式。

Warning 预加载只是简单的将载入 fielddata 的代价转移到索引刷新的时候,而不是查询时,从而大大提高了搜索体验。

全局序号(Global Ordinals)

有种可以用来降低字符串 fielddata 内存使用的技术叫做 序号

设想我们有十亿文档,每个文档都有自己的 status 状态字段,状态总共有三种: status_pendingstatus_publishedstatus_deleted 。如果我们为每个文档都保留其状态的完整字符串形式,那么每个文档就需要使用 14 到 16 字节,或总共 15 GB。

取而代之的是我们可以指定三个不同的字符串,对其排序、编号:0,1,2。

Ordinal | Term

0 | status_deleted 1 | status_pending 2 | status_published</pre>

序号字符串在序号列表中只存储一次,每个文档只要使用数值编号的序号来替代它原始的值。

Doc     | Ordinal

0 | 1 # pending 1 | 1 # pending 2 | 2 # published 3 | 0 # deleted</pre>

这样可以将内存使用从 15 GB 降到 1 GB 以下!

但这里有个问题,记得 fielddata 是按分 来缓存的。如果一个分段只包含两个状态( status_deletedstatus_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" (1)
    }
  }
}
  1. 设置 eager_global_ordinals 也暗示着 fielddata 是预加载的。

正如 fielddata 的预加载一样,预构建全局序号发生在新分段对于搜索可见之前。

Note 序号的构建只被应用于字符串。数值信息(integers(整数)、geopoints(地理经纬度)、dates(日期)等等)不需要使用序号映射,因为这些值自己本质上就是序号映射。

也可以对 Doc values 进行全局序号预构建:

PUT /music/_mapping/_song
{
  "song_title": {
    "type":       "string",
    "doc_values": true,
    "fielddata": {
      "loading" : "eager_global_ordinals" (1)
    }
  }
}
  1. 这种情况下,fielddata 没有载入到内存中,而是 doc values 被载入到文件系统缓存中。

与 fielddata 预加载不一样,预建全局序号会对数据的 实时性 产生影响,构建一个高基数的全局序号会使一个刷新延时数秒。 选择在于是每次刷新时付出代价,还是在刷新后的第一次查询时。如果经常索引而查询较少,那么在查询时付出代价要比每次刷新时要好。如果写大于读,那么在选择在查询时重建全局序号将会是一个更好的选择。

Tip 针对实际场景优化全局序号的重建频次。如果我们有高基数字段需要花数秒钟重建,增加 refresh_interval 的刷新的时间从而可以使我们的全局序号保留更长的有效期,这也会节省 CPU 资源,因为我们重建的频次下降了。

索引预热器(Index Warmers)

最后我们谈谈 索引预热器 。预热器早于 fielddata 预加载和全局序号预加载之前出现,它们仍然尤其存在的理由。一个索引预热器允许我们指定一个查询和聚合须要在新分片对于搜索可见之前执行。 这个想法是通过预先填充或 预热缓存 让用户永远无法遇到延迟的波峰。

原来,预热器最重要的用法是确保 fielddata 被预先加载,因为这通常是最耗时的一步。现在可以通过前面讨论的那些技术来更好的控制它,但是预热器还是可以用来预建过滤器缓存,当然我们也还是能选择用它来预加载 fielddata。

让我们注册一个预热器然后解释发生了什么:

PUT /music/_warmer/warmer_1 (1)
{
  "query" : {
    "bool" : {
      "filter" : {
        "bool": {
          "should": [ (2)
            { "term": { "tag": "rock"        }},
            { "term": { "tag": "hiphop"      }},
            { "term": { "tag": "electronics" }}
          ]
        }
      }
    }
  },
  "aggs" : {
    "price" : {
      "histogram" : {
        "field" : "price", (3)
        "interval" : 10
      }
    }
  }
}
  1. 预热器被关联到索引( music )上,使用接入口 _warmer 以及 ID ( warmer_1 )。

  2. 为三种最受欢迎的曲风预建过滤器缓存。

  3. 字段 price 的 fielddata 和全局序号会被预加载。

预热器是根据具体索引注册的, 每个预热器都有唯一的 ID ,因为每个索引可能有多个预热器。

然后我们可以指定查询,任何查询。它可以包括查询、过滤器、聚合、排序值、脚本,任何有效的查询表达式都毫不夸张。 这里的目的是想注册那些可以代表用户产生流量压力的查询,从而将合适的内容载入缓存。

当新建一个分段时,Elasticsearch 将会执行注册在预热器中的查询。执行这些查询会强制加载缓存,只有在所有预热器执行完,这个分段才会对搜索可见。

Warning 与预加载类似,预热器只是将冷缓存的代价转移到刷新的时候。当注册预热器时,做出明智的决定十分重要。 为了确保每个缓存都被读入,我们 可以 加入上千的预热器,但这也会使新分段对于搜索可见的时间急剧上升。

有些管理的细节(比如获得已有预热器和删除预热器)没有在本小节提到,剩下的详细内容可以参考 预热器文档(warmers documentation)

优化聚合查询

“elasticsearch 里面桶的叫法和 SQL 里面分组的概念是类似的,一个桶就类似 SQL 里面的一个 group,多级嵌套的 aggregation, 类似 SQL 里面的多字段分组(group by field1,field2, …​..),注意这里仅仅是概念类似,底层的实现原理是不一样的。 -译者注”

terms 桶基于我们的数据动态构建桶;它并不知道到底生成了多少桶。 大多数时候对单个字段的聚合查询还是非常快的, 但是当需要同时聚合多个字段时,就可能会产生大量的分组,最终结果就是占用 es 大量内存,从而导致 OOM 的情况发生。

假设我们现在有一些关于电影的数据集,每条数据里面会有一个数组类型的字段存储表演该电影的所有演员的名字。

{
  "actors" : [
    "Fred Jones",
    "Mary Jane",
    "Elizabeth Worthing"
  ]
}

如果我们想要查询出演影片最多的十个演员以及与他们合作最多的演员,使用聚合是非常简单的:

{
  "aggs" : {
    "actors" : {
      "terms" : {
         "field" : "actors",
         "size" :  10
      },
      "aggs" : {
        "costars" : {
          "terms" : {
            "field" : "actors",
            "size" :  5
          }
        }
      }
    }
  }
}

这会返回前十位出演最多的演员,以及与他们合作最多的五位演员。这看起来是一个简单的聚合查询,最终只返回 50 条数据!

但是, 这个看上去简单的查询可以轻而易举地消耗大量内存,我们可以通过在内存中构建一个树来查看这个 terms 聚合。 actors 聚合会构建树的第一层,每个演员都有一个桶。然后,内套在第一层的每个节点之下, costar 聚合会构建第二层,每个联合出演一个桶,请参见 Build full depth tree 所示。这意味着每部影片会生成 n2 个桶!

Build full depth treeFigure 42. Build full depth tree

用真实点的数据,设想平均每部影片有 10 名演员,每部影片就会生成 102 == 100 个桶。如果总共有 20,000 部影片,粗率计算就会生成 2,000,000 个桶。

现在,记住,聚合只是简单的希望得到前十位演员和与他们联合出演者,总共 50 条数据。为了得到最终的结果,我们创建了一个有 2,000,000 桶的树,然后对其排序,取 top10。 图 Sort tree 和图 Prune tree 对这个过程进行了阐述。

Sort treeFigure 43. Sort treePrune treeFigure 44. Prune tree

这时我们一定非常抓狂,在 2 万条数据下执行任何聚合查询都是毫无压力的。如果我们有 2 亿文档,想要得到前 100 位演员以及与他们合作最多的 20 位演员,作为查询的最终结果会出现什么情况呢?

可以推测聚合出来的分组数非常大,会使这种策略难以维持。世界上并不存在足够的内存来支持这种不受控制的聚合查询。

深度优先与广度优先(Depth-First Versus Breadth-First)

Elasticsearch 允许我们改变聚合的 集合模式 ,就是为了应对这种状况。 我们之前展示的策略叫做 深度优先 ,它是默认设置, 先构建完整的树,然后修剪无用节点。 深度优先 的方式对于大多数聚合都能正常工作,但对于如我们演员和联合演员这样例子的情形就不太适用。

为了应对这些特殊的应用场景,我们应该使用另一种集合策略叫做 广度优先 。这种策略的工作方式有些不同,它先执行第一层聚合, 继续下一层聚合之前会先做修剪。 图 Build first level 和图 Prune first level 对这个过程进行了阐述。

在我们的示例中, actors 聚合会首先执行,在这个时候,我们的树只有一层,但我们已经知道了前 10 位的演员!这就没有必要保留其他的演员信息,因为它们无论如何都不会出现在前十位中。

Build first levelFigure 45. Build first levelSort first levelFigure 46. Sort first levelPrune first levelFigure 47. Prune first level

因为我们已经知道了前十名演员,我们可以安全的修剪其他节点。修剪后,下一层是基于 它的 执行模式读入的,重复执行这个过程直到聚合完成,如图 Populate full depth for remaining nodes 所示。 这种场景下,广度优先可以大幅度节省内存。

Step 4: populate full depth for remaining nodesFigure 48. Populate full depth for remaining nodes

要使用广度优先,只需简单 的通过参数 collect 开启:

{
  "aggs" : {
    "actors" : {
      "terms" : {
         "field" :        "actors",
         "size" :         10,
         "collect_mode" : "breadth_first" (1)
      },
      "aggs" : {
        "costars" : {
          "terms" : {
            "field" : "actors",
            "size" :  5
          }
        }
      }
    }
  }
}
  1. 按聚合来开启 breadth_first

广度优先仅仅适用于每个组的聚合数量远远小于当前总组数的情况下,因为广度优先会在内存中缓存裁剪后的仅仅需要缓存的每个组的所有数据,以便于它的子聚合分组查询可以复用上级聚合的数据。

广度优先的内存使用情况与裁剪后的缓存分组数据量是成线性的。对于很多聚合来说,每个桶内的文档数量是相当大的。 想象一种按月分组的直方图,总组数肯定是固定的,因为每年只有12个月,这个时候每个月下的数据量可能非常大。这使广度优先不是一个好的选择,这也是为什么深度优先作为默认策略的原因。

针对上面演员的例子,如果数据量越大,那么默认的使用深度优先的聚合模式生成的总分组数就会非常多,但是预估二级的聚合字段分组后的数据量相比总的分组数会小很多所以这种情况下使用广度优先的模式能大大节省内存,从而通过优化聚合模式来大大提高了在某些特定场景下聚合查询的成功率。