优化聚合查询 (Preventing Combinatorial Explosions, 防止组合激增)edit

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

terms 桶(bucket) 根据数据动态构建桶;它并不知道到底生成了多少桶。 大多数时候对单个字段的聚合查询还是非常快的, 但是当需要同时聚合多个字段时,就可能会产生大量的分组,最终结果就是占用 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 聚合会构建第二层,每个联合出演者一个桶,请参见 Figure 42, “Build full depth tree” 所示。这意味着每部影片会生成 n2 个桶!

Build full depth tree
Figure 42. Build full depth tree

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

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

Sort tree
Figure 43. 排序树 (Sort tree)
Prune tree
Figure 44. 裁剪树 (Prune tree)

这时你一定非常抓狂。在 2 万条数据下执行任何聚合查询都是毫无压力的。但是, 如果你有2亿个文档,想要前100名演员和他们的前20名联合主演,以及联合主演的联合主演呢?

您能意识到组合扩张的增长速度有多快,这使得这种策略站不住脚。世界上并不存在足够的内存来支持这种不受控制的组合激增。

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

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

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

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

Build first level
Figure 45. 创建第一层(Build first level)
Sort first level
Figure 46. 对第一层进行排序(Sort first level)
Prune first level
Figure 47. 裁剪第一层(Prune first level)

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

Step 4: populate full depth for remaining nodes
Figure 48. 剩余节点的全深度填充(Populate full depth for remaining nodes)

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

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

在每个聚合的基础上启用 广度优先(breadth_first) 。

广度优先仅仅适用于产生的桶的数量远大于桶中的文档的数量时(也就是每个分组/桶的文档数量都比较少)。 广度优先会缓存桶(bucket)级别的文档数据, 裁剪后给子聚合(以便子聚合可以复用这些数据)。

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

但是那个演员的例子, 它产生了大量的桶, 但是每个桶的文档数很少, 这时 广度优先 的内存效率更高, 并允许构建可能失败的聚合.