Elasticsearch 性能优化之 valueCountAgg 的 8_20 倍性能提升 - 文章详情

本文由 简悦 SimpRead 转码, 原文地址 z.itpub.net

Elasticsearch 是一款优秀的开源搜索引擎,其除了可以完成复杂的 query 请求外,还可以做一些统计聚合的任务,类似 sql 中的 max、sum、count、avg 等。

Elasticsearch 是一款优秀的开源搜索引擎,其除了可以完成复杂的 query 请求外,还可以做一些统计聚合的任务,类似 sql 中的 max、sum、count、avg 等。事情的缘起在于某一次对 Elasticsearch 的性能测试中发现,countAgg 的性能会比 sum 等 agg 性能要低的多,甚至不在一个数量级。

先说结论,在计算 double、long 等数值类型时候,提高 8 倍~ 9 左右性能;在计算 geo_point 类型时候,提高 18~20 倍性能。优化后的代码已经贡献给 alibaba 内部团队共建的 Elasticsearch 分支,并已经通过开源途径贡献给 Elasticsearch 开源社区,提给 ES 官方的 PR 已经被接受,后续将随新版本发布。

测试中发现问题


测试在 2 亿数据上进行,使用了一个 double 字段进行测试,如下图所示,avg、sum 等字段在进行统计时候,性能十分接近,但是 value_count 的性能一直表现不佳,甚至不在一个数量级上。基于一些常识性的知识,我们认为 count 和 sum 等都是类似的操作,消耗的时间应该都是一样的,因此,我们对 value_count 的 agg 进行了探讨。

200Million docs, 1shard,0replica 的数据上进行测试如下。

https://image.z.itpub.net/zitpub.net/JPG/2020-05-22/BAF886218BF3DAED40D77565ED295085.jpg

基础理论知识


这里主要介绍 lucene 和 java 的 String,这两部分决定了我们后续可以进行大幅优化。

Lucene


在 lucene 中,Collector 系列接口可以完成查询结果收集、排序、自定义结果集过滤和收集。Collector 和 LeafCollector 是 Lucene 结果集收集的核心。Elasticsearch 中的统计聚合直接使用了 lucene 的接口,仅仅是自己实现了 Collector 接口。核心方法还是 lucene 中的 search 接口。

LeafCollector

org.apache.lucene.search.LeafCollector,它有 collect() 与 setScorer() 两个方法。

void org.apache.lucene.search.LeafCollector.collect(int doc)

这是一个及其重要的方法。这个 docid 是 segment 内的 docid,全局的 docid 可以由 LeafReaderContext.docBase(segment 文件的编号)+doc 得到,这个全局 id 在 Elasticsearch 中也大量使用。这里 collect 方法用来收集每个索引文档,提供的 doc 参数表示段文件编号,如果你要获取索引文档的编号,请加上当前 segment 文件 Reader 的 docBase 基数,如 leafReaderContext.reader().docBase + doc。

void org.apache.lucene.search.LeafCollector.setScorer(Scorer scorer)

设置打分器,如果你的 search 结果需要打分,可以在这里设置一个打分器,如果不需要打分可以忽略这一步。

TopDocsCollector

org.apache.lucene.search.TopDocsCollector,返回 top-N 的文档。

TopScoreDocCollector

这是最常用的一个结果收集器,默认情况下会根据评分和 docId 进行排序,因此这个收集器不用显示的指定。

TopFieldCollector

它和上面收集器的区别在于它可以由用户指定按照某一个字段排序然后返回,完成我们常见的 Sort 函数。

TimeLimitingCollector

它是一个包装器,可以将其他 Collector 进行 wrap,被 wrap 的 Collector 必须要指定的时间内返回,超过时间则中断收集过程。

Java String


java 开发者都知道 jvm 中存在着一个叫字符串常量池的东西,字符串被创建出后,即便 String 类不再使用,也不会被马上回收,因此如果大量的字符串被频繁的创建,而内存又不够使用,那么就会触发字符串常量池的 gc,这里的 gc 是量特别大的字符串的 gc,耗时也是十分可观的,例如在 es 中,随便执行一个上亿规模的查询,如果将所有的字符串放入内存中,内存很容易不够用。

其次,java 中还存在这样一个问题,类型转换在强类型转化为泛型时候,开销是几乎可以忽略的,而强制类型转化则会花费很大的开销。如下代码,当 Long 转 String 时候,开销会比以往更多,其内部使用了 O(n) 的时间来拼凑这个字符串,因此 long 转 String 在数据量特别大的时候,也会特别的慢。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
public static String toString(long i) {
        int size = stringSize(i);
        if (COMPACT_STRINGS) {
            byte[] buf = new byte[size];
            getChars(i, size, buf);
            return new String(buf, LATIN1);
        } else {
            byte[] buf = new byte[size * 2];
            StringUTF16.getChars(i, size, buf);
            return new String(buf, UTF16);
        }
}

问题及优化


es 中在进行 count 时候的原理是,遍历每一个文档的这个 field,如果这个 field 是普通值,则 count 值加 1,如果是 array 类型的,则 count 值加 n。然而问题就出在遍历每一个文档并取出这个 field,这个 field 从磁盘中变成 java 语言中的一个对象。我们将其 Elasticsearch 目前现有的问题总结为:

  1. number cast to string 的开销, 以及大量的 string 带来的 GC 问题
  2. number 类型转为 string 后进行了排序等没有必要的操作

问题 1 是 Elasticsearch 团队也发现的问题,如下图所示。程序热点图显示 toString 的耗时特别严重。但是他们并没有着急解决,基于现有框架对代码侵入较大,无法简单解决。

https://image.z.itpub.net/zitpub.net/JPG/2020-05-22/22AFFCE73A71B0EE3499201C86808CA4.jpg

我们在线下也模拟了一亿条数据记性类型转化的时间开销,这个开销是很恐怖的,如下表格所示。这中间消耗的时间特别大,也就是 countAggregation 在计算时候花销的时间。

https://image.z.itpub.net/zitpub.net/JPG/2020-05-22/7792F5E9DFF38D35B25DFD7AA5B6CC8E.jpg

field 在磁盘中存储的本身就是 long,因此必须要再次转化为 string。Elasticsearch 为什么要转化为 string 呢,因为 count 可以计算任何类型的值,而任何值都可以转化为 string,因此它们认为只要转化为 string 即可将事情化简。任何代码都可以在他们一套框架里执行。带来的代价也是效率特别的慢。

除了转化为 string 带来的开销,还有写排序的开销。在他们的框架中,也就是 SortedBinaryDocValues 中,他们统一将任何值转化为 string,在他们的框架中,需要将这些 string 进行排序,这也是框架中定死的套路。因此这些没必要的排序也将影响性能,这部分缺陷的证明我们通过修改 es 源码实现。我们在 es 中进行注释没必要的排序代码然后测试原来的性能,发现在处理数值类型时候速度能够大大提升。

我们的优化代码其实很简单,就是针对不同的类型分别治理,而不是统一的转化为 string 统一处理,我们在 ValueCountAggregator 中增加了对类型识别,针对 number 和 geopoint 类型做了特殊处理,取消其类型转化和没必要的排序。因为 string 类型减少了,字符串常量减少,运行召回量巨大的数值类 aggregation 时候隔一段时间的 gc 问题也将得到缓解。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
......
final BigArrays bigArrays = context.bigArrays();
if (valuesSource instanceof ValuesSource.Numeric) {
    final SortedNumericDocValues values = ((ValuesSource.Numeric)valuesSource).longValues(ctx);
    return new LeafBucketCollectorBase(sub, values) {
        @Override
        public void collect(int doc, long bucket) throws IOException {
            counts = bigArrays.grow(counts, bucket + 1);
            if (values.advanceExact(doc)) {
                counts.increment(bucket, values.docValueCount());
            }
        }
    };
}
if (valuesSource instanceof ValuesSource.Bytes.GeoPoint) {
    MultiGeoPointValues values = ((ValuesSource.GeoPoint)valuesSource).geoPointValues(ctx);
    return new LeafBucketCollectorBase(sub, null) {
        @Override
        public void collect(int doc, long bucket) throws IOException {
            counts = bigArrays.grow(counts, bucket + 1);
            if (values.advanceExact(doc)) {
                counts.increment(bucket, values.docValueCount());
            }
        }
    };
}

final SortedBinaryDocValues values = valuesSource.bytesValues(ctx);
return new LeafBucketCollectorBase(sub, values) {
    ......

优化后性能测试


https://image.z.itpub.net/zitpub.net/JPG/2020-05-22/79A2D9D601A89187A5AC8045F3B22F2F.jpg

结论

  1. 结果比较符合常识。
  2. valuecount 和 avg、sum 等时间差不多,或者比这些还要低。而之前 count 远大于 avg、sum,数据量大了则不在一个数量级。
  3. 数据量大的时候,可以观察到性能提升:
  4. 数值类:8~9 倍。
  5. geo_point: 18~20 倍。
  6. keyword:无差别,原本性能就没问题。

参考

  1. https://www.ibm.com/developerworks/cn/java/j-lo-optmizestring/index.html
  2. https://www.cnblogs.com/huangfox/archive/2012/07/10/2584750.html
  3. https://github.com/elastic/elasticsearch/issues/36752
  4. https://github.com/elastic/elasticsearch/pull/54854