聚合性能与内存

聚合性能与内存 #

聚合的性能和内存使用是生产环境中需要重点关注的问题。本页介绍聚合背后的数据结构(doc_values 和 fielddata)、内存限制机制(断路器),以及如何优化聚合查询。

Doc Values #

聚合使用一个叫 doc values 的数据结构。Doc values 可以使聚合更快、更高效并且内存友好。

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

倒排索引 vs Doc Values #

对于以下倒排索引:

Term      Doc_1   Doc_2   Doc_3
------------------------------------
brown   |   X   |   X   |
dog     |   X   |       |   X
dogs    |       |   X   |   X
fox     |   X   |       |   X
...

如果我们想要获得所有包含 brown 的文档的词的完整列表,查询部分简单又高效。倒排索引是根据项来排序的,所以我们首先在词项列表中找到 brown,然后扫描所有列,找到包含 brown 的文档。

然后,对于聚合部分,我们需要找到 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
-----------------------------------------------------------------

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

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

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

Fielddata #

对于 text 字段,doc values 不可用(因为文本字段需要分析)。相反,Easysearch 使用 fielddata 数据结构。Fielddata 在查询时动态构建,将分析后的文本加载到内存中。

Fielddata 的特点 #

  • 延迟加载:如果你从来没有聚合一个分析字符串,就不会加载 fielddata 到内存中
  • 基于字段加载:只有很活跃地使用字段才会增加 fielddata 的负担
  • 加载所有文档:fielddata 会加载索引中(针对该特定字段的)所有的文档,而不管查询的特异性

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

限制 Fielddata 内存使用 #

JVM 堆是有限资源的,应该被合理利用。限制 fielddata 对堆使用的影响有多套机制:

Fielddata 大小限制 #

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

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

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

一个有界的大小会强制数据结构回收数据。可以通过在配置文件中增加配置为 fielddata 设置一个上限:

indices.fielddata.cache.size: 20%

可以设置堆大小的百分比,也可以是某个值,例如:5gb

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

警告:这个设置是一个安全卫士,而非内存不足的解决方案。如果没有足够空间可以将 fielddata 保留在内存中,Easysearch 就会时刻从磁盘重载数据,并回收其他数据以获得更多空间。内存的回收机制会导致重度磁盘 I/O,并且在内存中生成很多垃圾,这些垃圾必须在晚些时候被回收掉。

断路器(Circuit Breaker) #

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

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

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

可用的断路器 #

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

  • indices.breaker.fielddata.limitfielddata 断路器默认设置堆的 60% 作为 fielddata 大小的上限。
  • indices.breaker.request.limitrequest 断路器估算需要完成其他请求部分的结构大小,例如创建一个聚合桶,默认限制是堆内存的 40%。
  • indices.breaker.total.limittotal 揉合 requestfielddata 断路器保证两者组合起来不会使用超过堆内存的 70%。

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

PUT /_cluster/settings
{
  "persistent" : {
    "indices.breaker.fielddata.limit" : "40%"
  }
}

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

提示:indices.fielddata.cache.sizeindices.breaker.fielddata.limit 之间的关系非常重要。如果断路器的限制低于缓存大小,没有数据会被回收。为了能正常工作,断路器的限制必须要比缓存大小要高。

监控 Fielddata #

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

Fielddata 的使用可以被监控:

  • 按索引使用 indices-stats API:

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

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

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

深度优先 vs 广度优先 #

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

组合爆炸问题 #

假设我们现在有一些关于电影的数据集,每条数据里面会有一个数组类型的字段存储表演该电影的所有演员的名字。如果我们想要查询出演影片最多的十个演员以及与他们合作最多的演员,使用聚合是非常简单的:

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

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

但是,这个看上去简单的查询可以轻而易举地消耗大量内存。actors 聚合会构建树的第一层,每个演员都有一个桶。然后,内套在第一层的每个节点之下,costar 聚合会构建第二层,每个联合出演一个桶。这意味着每部影片会生成 n² 个桶!

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

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

深度优先(默认) #

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

广度优先 #

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

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

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

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

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

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

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

最佳实践 #

  1. 优先使用 doc_values:对于 keyword、数值、日期等字段,doc values 在索引时创建,查询时无需额外内存
  2. 避免在 text 字段上做聚合:如果需要对文本字段做聚合,考虑使用 multi-fields,在 keyword 子字段上做聚合
  3. 监控 fielddata 使用:定期检查 fielddata 的内存使用和回收情况
  4. 合理设置断路器:根据实际内存情况设置断路器限制,避免 OOM
  5. 使用广度优先模式:当嵌套聚合会产生大量桶时,考虑使用广度优先模式

小结 #

  • Doc values 是聚合的基础数据结构,在索引时创建,查询时高效
  • Fielddata 用于 text 字段的聚合,在查询时动态构建,需要消耗内存
  • 断路器可以防止内存溢出,但应该设置保守的值
  • 深度优先是默认策略,广度优先适合嵌套聚合产生大量桶的场景
  • 监控 fielddata 使用情况,及时发现问题

下一步可以继续阅读: