【转】Elasticsearch干货(三):对于数值类型索引优化

Elasticsearch中数据类型

本来想自己总结一下的,无意中发现了一篇文章,总结的很详细,瞬间打消了我的念头。直接搬来:https://blog.csdn.net/chengyuqiang/article/details/79048800

好,回来我们已经大概了解了Elasticsearch中有哪些数据类型,本文我们主要探讨其中的数值类型。

Elasticsearch是如何存储数值类型的?

在了解Elasticsearch如何存储数据数值类型之前,需要了解Lucene是如何存储索引数据的。参考:Elasticsearch原理(二):索引存储方式

版本的区别

  Elasticsearch中对于数值类型的存储在5.X版本之后有一个重要的变化。从Elasticsearch5.x开始,Elasticsearch开始使用Lucene6.0版本,而Lucene6.0版本对于Lucene来说有非常大的改变,随之带来的是Elasticsearch有很大的改变。(但这并不是Elasticsearch从2.x跳到5.x的原因,之所以跳版本是为了ELK的版本统一。)

  在Lucene6.0之前对于数据的索引只支持字符串,虽然表面上是支持其他类型,但最终都是转成了字符串进行倒排索引的。

  这种方式最大的好处就是直接通过term进行查询,速度非常快。但也有弊端,对于数值类型的range查询就非常的笨重。比如说range[1,50],它实际上是转换成了bool中的or,就像(1 or 2 or 3 or 4 … or 50)。如果range一个很大的范围的话,效率可想而知。于是Lucene做了一定优化,把range[1,10]、range[11,20]… 这些转换成一个term,从而对应range内的doc。虽然看上去性能有一点点的提高,但遇到很大量的范围,依然很无力。
  所以,早期Elasticsearch(2.x)range查询性能很低。
  在Lucene6.0之后,Lucene引入了一种新的数据结构 Block k-d tree。这种数据结构的引入主要就是为了解决数值类型数据的问题。

BKD Tree

  学计算机的对二叉树都应该有所了解,BKD tree是一种特殊的BKD tree,对应这种特殊的数据结构,基本概念这里就不啰嗦了,千字不如一图:
【转】Elasticsearch干货(三):对于数值类型索引优化
  上图中,除了block部分,都是存在内存中的,block则是存在磁盘上。可以看出来,每个block都存储了一个范围value值。这样看上去对数值类型的range进行了很大程度的优化,我们可以快速的找到对应范围的docid,但这只是Lucene的实现,这种数据结构放到Elasticsearch中反而成为了一种性能负担。下面我们会详细介绍,为什么这个数据结构不适合Elasticsearch。

Elasticsearch5.x+

上文介绍了Lucene新版本的变化,但这并不是导致Elasticsearch数值类型查询变慢的唯一原因。

filter

  我们首先了解一下Elasticsearch的filter查询。如果Elasticsearch中查询条件的多个filter,但实际上并像mysql那样按照每个filter的顺序执行,而是每一个filter都是单独执行的,最后把每个filter的结果集进行合并(参考)。

Elasticsearch有个cost函数,它会先估算每个filter的代价,然后从代价最低的开始迭代docid。并且根据term、and、or的不同,来决定不同的合并方式。合并结果集Lucene使用了跳表。

参考:Lucene原理(三):查询篇

  先从term开始说,一般term都是keyword的类型。这种方式cost则直接返回倒排中的文档频率,频率越高的迭代代价自然就越大。所以会选择频率最低的term作为开始点。因为又引入了跳表数据结构,所以效率非常的快。postings list中的docid是顺序的,这是使用跳表的前提,因此,即使对于多个postings list的合并在磁盘上操作也不会产生太多随机IO。并且Elasticsearch5.0之后,索引数据采取了mmap file的方式访问,热数据读取引发的磁盘IO愈发的少。最终在Elasticsearch5.1.1之后,取消了TermQuery的cache。就是说所有term查询,都是直接读磁盘上的数据。因为跳表和页面缓存的存在,直接在磁盘上做合并也非常快,这么做也是为了减少资源开销,提高性能。但这也带来了一个隐患。

数值类型的TermQuery

  上面说到了对于TermQuery,取消了cache,每次都去操作磁盘。虽然是对TermQuery进行了优化,但这主要是针对keyword类型,对于数值类型,文章前面提到了Lucene做的改变,两个改变一碰撞差生了火花。
  前面提到Lucene6.0以后取消了对数值类型进行倒排索引,而是采用BKD tree的方式。所以所有数值类型的TermQuery会被转成PointRangeQuery。回到上面的BKD tree图我们可以看到,每个block中value是有序的,而docid是无序的。无序也就不能使用跳表结构。这种情况下要对结果集进行合并,就要对docidlist做一个特殊处理。具体处理过程在org.apache.lucene.search.PointRangeQuery#createWeight中。

源码参考地址:
https://github.com/apache/lucene-solr/blob/54f256503867c4265a994b94af2917e1375c2181/lucene/core/src/java/org/apache/lucene/search/PointRangeQuery.java

@Override
public Scorer get(long leadCost) throws IOException {
  if (values.getDocCount() == reader.maxDoc()
      && values.getDocCount() == values.size()
      && cost() > reader.maxDoc() / 2) {
    // If all docs have exactly one value and the cost is greater
    // than half the leaf size then maybe we can make things faster
    // by computing the set of documents that do NOT match the range
    final FixedBitSet result = new FixedBitSet(reader.maxDoc());
    result.set(0, reader.maxDoc());
    int[] cost = new int[] { reader.maxDoc() };
    values.intersect(getInverseIntersectVisitor(result, cost));
    final DocIdSetIterator iterator = new BitSetIterator(result, cost[0]);
    return new ConstantScoreScorer(weight, score(), iterator);
  }

从上述源码中看到在创建Scorer的过程中有一个bitset的迭代器,这个过程是导致查询缓慢的罪魁祸首。因为如果range的结果集范围非常大的话,耗费的时间就会越长。

数值类型的RangeQuery

到此有同学就想到了,那如果把数值类型存成keyword呢?

上面我们了解到了对数值类型的range的优化,依然达不到我们想要的效果,于是Elasticsearch从5.4版本开始又加入了 indexOrDocValuesQuery (参考)。
  这个Query包装了上面的PointRangeQuery和SortedSetDocValuesRangeQuery,并且会根据Rang查询的数据集大小,以及要做的合并操作类型,决定用哪种Query。
  如果Range的代价小,可以用来引领合并过程,就走PointRangeQuery,直接构造bitset来进行迭代。 而如果range的代价高,构造bitset太慢,就使用SortedSetDocValuesRangeQuery。 这个Query利用了DocValues这种全局docid序,并包含每个docid对应value的数据结构来做文档的匹配。 当给定一个docid的时候,一次随机磁盘访问就可以定位到该id对应的value,从而可以判断该doc是否match。这样从其他过滤条件返回的小结果集中一次调用docid来判断value是否匹配。

源码参考地址:
https://github.com/apache/lucene-solr/blob/1d85cd783863f75cea133fb9c452302214165a4d/lucene/core/src/java/org/apache/lucene/search/IndexOrDocValuesQuery.java

总结

  本文我们了解了Elasticsearch和Lucene不同版本中对数值类型range查询的优化,我们牢记以下两点来避免不必要的缓慢查询。

  • 对于数值类型的数据,我们是要确定该字段是需要range还是term。比如id,有些情况下我们只需要对它做term查询,这种情况下使用keyword来存储会更好。
  • 如果需要使用数值类型(非keyword),并且range的结果集很大的情况下,建议升级到Elasticsearch5.4+之后版本。

更多:Elasticsearch深入理解专栏