es的聚合查詢會涉及到很多概念,比如fielddata,DocValue,也會引出很多問題,比如聚合查詢導致的內存溢出。在沒有真正瞭解聚合查詢的情況下,我們往往對這些概念,問題都是雲山霧繞的。本文我們分析一下ES聚合查詢的源碼,理清楚聚合查詢的流程。穿越層層迷霧來認清聚合的本質。
聚合查詢的入口
es的聚合查詢的入口代碼如下:
public void execute(SearchContext searchContext) throws QueryPhaseExecutionException {
aggregationPhase.preProcess(searchContext); <1>
boolean rescore = execute(searchContext, searchContext.searcher());<2>
aggregationPhase.execute(searchContext);<3>
}
}
<1>爲聚合查詢做準備
<2>根據條件進行查詢,獲取查詢結果
<3>對查詢結果進行聚合
<3>纔是聚合的真正入口,但是要想真正理解ES的聚合,我們必須瞭解<1><2>。因爲<1>中提供了聚合查詢必要的採集器(collector), 正排索引。<2>爲聚合查詢提供了數據基礎,即<3>是在<2>中採集出來的數據的基礎上進行的。下面以這3步爲大綱,分析es的聚合查詢源碼
聚合前的準備
聚合前所需要做的準備主要就是一件事:構建採集器aggregators。
aggregators是一個由aggregator組成的列表,aggregator包裝着聚合的實現邏輯,因爲es擁有多種聚合方式,所以也就有多種不同實現邏輯的aggregator。在查詢階段中,es會調用aggregator中的邏輯去採集數據。
aggregator的構建
AggregatorFactories factories = context.aggregations().factories(); <1>
aggregators = factories.createTopLevelAggregators(aggregationContext); <2>
<1>從上下文中獲取aggregator的工廠
<2>工廠生產出aggregators
值得一提的是<2>步驟表面是隻是生產了aggregator。實際上還偷偷幹了一件重要的事情:加載Doc Value 。
Doc Value 和FieldData是es的正排索引,它對提升es聚合查詢的性能起着至關重要的作用。因此,有必要探究一下它的加載邏輯。
DocValue的加載
DocValue的加載的源碼位置:AggregatorFactories:createTopLevelAggregators -> AggregatorFactory:create -> ValuesSourceAggregatorFactory:createInternal
public Aggregator createInternal(AggregationContext context, Aggregator parent, boolean collectsFromSingleBucket,
List<PipelineAggregator> pipelineAggregators, Map<String, Object> metaData) throws IOException {
VS vs = config.toValuesSource(context.getQueryShardContext()); <1>
return doCreateInternal(vs, context, parent, collectsFromSingleBucket, pipelineAggregators, metaData); <2>
}
<1> 從config中獲取value source,vs中包含了DocValue
<2>fielddata 作爲vs參數傳入該方法中。
protected Aggregator doCreateInternal(ValuesSource valuesSource, Aggregator parent, boolean collectsFromSingleBucket,
List<PipelineAggregator> pipelineAggregators, Map<String, Object> metaData) throws IOException {
...
ValuesSource.Bytes.WithOrdinals valueSourceWithOrdinals = (ValuesSource.Bytes.WithOrdinals) valuesSource;
IndexSearcher indexSearcher = context.searcher();
maxOrd = valueSourceWithOrdinals.globalMaxOrd(indexSearcher);<1>
ratio = maxOrd / ((double) indexSearcher.getIndexReader().numDocs());<2>
...
}
<1>從vs中加載docValue,它首先會嘗試去本地緩存中找,如果本地緩存中沒有DocValue的話,就從磁盤文件中讀取,着就是傳入indexSearcher的目的。獲取到docvalue之後就可以獲得maxOrd,它表示這個字段中term的總數。
<2>ratio=詞項總數/文檔總數。radio越小聚合出來結果的bucket數量就越小。根據ratio的值我們應該選擇適合的聚合模式以優化聚合查詢的性能。
加載出來的docValue真正排上用場是在執行查詢的過程中。
在查詢過程中採集數據
這個步驟的入口代碼位於QueryPhase:execute方法中,這個方法很長,內容很多,但是我們只關注它與聚合部分的聯繫,因此我們只需要看到其中的一行代碼
searcher.search(query, collector);
這行代碼是es正式開始查詢的入口,它直接調用的lucene的查詢接口,query參數包含了查詢條件,collector則是封裝了aggregator,它攜帶了聚合的邏輯,我們稱collector爲採集器。
protected void search(List<LeafReaderContext> leaves, Weight weight, Collector collector)
throws IOException {
for (LeafReaderContext ctx : leaves) { // search each subreader
final LeafCollector leafCollector;
try {
leafCollector = collector.getLeafCollector(ctx);
} catch (CollectionTerminatedException e) {
continue;
}
BulkScorer scorer = weight.bulkScorer(ctx);<1>
if (scorer != null) {
try {
scorer.score(leafCollector, ctx.reader().getLiveDocs());<1>
} catch (CollectionTerminatedException e) {
// collection was terminated prematurely
// continue with the following leaf
}
}
}
}
這段代碼的邏輯一目瞭然。我們知道es中一個索引包含多個分片,一個分片包含多個段,這裏的leaves就是段的集合。代碼中遍歷每個段,去查詢段中符合查詢條件的文檔,給文檔打分,用採集器收集匹配查詢文檔的聚合指標數據。
<1>找出匹配條件的文檔集合
<2>遍歷匹配的文檔集合,用採集器採集指標數據。
我們只關注跟聚合相關的<2>
for (int doc = iterator.nextDoc(); doc != DocIdSetIterator.NO_MORE_DOCS; doc = iterator.nextDoc()) {
if (acceptDocs == null || acceptDocs.get(doc)) {
collector.collect(doc);
}
}
public void collect(int doc) throws IOException {
final LeafCollector[] collectors = this.collectors;
int numCollectors = this.numCollectors;
for (int i = 0; i < numCollectors; ) {
final LeafCollector collector = collectors[i];
try {
collector.collect(doc);
++i;
} catch (CollectionTerminatedException e) {
removeCollector(i);
numCollectors = this.numCollectors;
if (numCollectors == 0) {
throw new CollectionTerminatedException();
}
}
}
}
遍歷匹配的文檔集合,用採集器採集這個文檔的指標
public void collect(int doc, long bucket) throws IOException {
assert bucket == 0;
final int ord = singleValues.getOrd(doc);<1>
if (ord >= 0) {
collectGlobalOrd(doc, ord, sub);<2>
}
}
<1>用正排索引DocValue尋找指定文檔對應的詞項,這裏就是前面加載的DocValue排上用場的地方了
<2>更新詞項對應的指標
private void collectGlobalOrd(int doc, long globalOrd, LeafBucketCollector sub) throws IOException {
collectExistingBucket(sub, doc, globalOrd);
}
public final void collectExistingBucket(LeafBucketCollector subCollector, int doc, long bucketOrd) throws IOException {
docCounts.increment(bucketOrd, 1);
}
docCounts可以理解爲一個Map,以詞項作爲key,以詞項對應的文檔數量作爲value。這裏說的詞項實際上是一個數字bucketOrd,它是詞項在全局的唯一標誌。
到這裏查詢階段數據的採集完成,docCounts就是在查詢階段爲聚合準備的數據。聚合中的bucket就是從docCounts的基礎上構建出來的。
ES request斷路器對docCounts的內存限制
docCounts的大小取決於詞項的數量,我們假設如果聚合請求涉及到的詞項非常龐大,那麼docCounts佔用的內存空間也會非常龐大,這是不是有OOM的風險呢?所幸ES對此早已有了對策,那就是通過request 斷路器來限制docCounts的大小。request 斷路器的作用就是防止每個請求(比如聚合查詢請求)的數據結構佔用的內存超出一定的量。
private IntArray docCounts;
public BucketsAggregator(String name, AggregatorFactories factories, SearchContext context, Aggregator parent,
List<PipelineAggregator> pipelineAggregators, Map<String, Object> metaData) throws IOException {
super(name, factories, context, parent, pipelineAggregators, metaData);
bigArrays = context.bigArrays();
docCounts = bigArrays.newIntArray(1, true);
}
public IntArray newIntArray(long size, boolean clearOnResize) {
if (size > INT_PAGE_SIZE) {
// when allocating big arrays, we want to first ensure we have the capacity by
// checking with the circuit breaker before attempting to allocate
adjustBreaker(BigIntArray.estimateRamBytes(size), false);
return new BigIntArray(size, this, clearOnResize);
}
}
以上代碼可以看到,docCounts的類型是IntArray 。而在創建一個IntArray 對象的時候,會調用adjustBreaker方法預估,加上這個intArray之後佔用的內存會不會達到request 斷路器定義的limit,如果超過limit就會拋出異常終止查詢。這就是斷路器對內存的保護。
數據聚合
進過前兩個步驟,我們已經獲取到了用於聚合的基礎數據,現在我們可以開始聚合了。
聚合的關鍵代碼:
aggregations.add(aggregator.buildAggregation(0));
public InternalAggregation buildAggregation(long owningBucketOrdinal) throws IOException {
final int size;
BucketPriorityQueue<OrdBucket> ordered = new BucketPriorityQueue<>(size, order.comparator(this));<1>
OrdBucket spare = new OrdBucket(-1, 0, null, showTermDocCountError, 0);<2>
for (long globalTermOrd = 0; globalTermOrd < valueCount; ++globalTermOrd) {<3>
final long bucketOrd = getBucketOrd(globalTermOrd);
final int bucketDocCount = bucketOrd < 0 ? 0 : bucketDocCount(bucketOrd);
if (bucketCountThresholds.getMinDocCount() > 0 && bucketDocCount == 0) {
continue;
}
otherDocCount += bucketDocCount;
spare.globalOrd = globalTermOrd;
spare.bucketOrd = bucketOrd;
spare.docCount = bucketDocCount;
if (bucketCountThresholds.getShardMinDocCount() <= spare.docCount) {
spare = ordered.insertWithOverflow(spare);
if (spare == null) {
spare = new OrdBucket(-1, 0, null, showTermDocCountError, 0);
}
}
}
// Get the top buckets
final StringTerms.Bucket[] list = new StringTerms.Bucket[ordered.size()];<4>
long survivingBucketOrds[] = new long[ordered.size()];
for (int i = ordered.size() - 1; i >= 0; --i) {
final OrdBucket bucket = ordered.pop();
survivingBucketOrds[i] = bucket.bucketOrd;
BytesRef scratch = new BytesRef();
copy(lookupGlobalOrd.apply(bucket.globalOrd), scratch);
list[i] = new StringTerms.Bucket(scratch, bucket.docCount, null, showTermDocCountError, 0, format);
list[i].bucketOrd = bucket.bucketOrd;
otherDocCount -= list[i].docCount;
}
//replay any deferred collections
runDeferredCollections(survivingBucketOrds);
//Now build the aggs
for (int i = 0; i < list.length; i++) {
StringTerms.Bucket bucket = list[i];
bucket.aggregations = bucket.docCount == 0 ? bucketEmptyAggregations() : bucketAggregations(bucket.bucketOrd);
bucket.docCountError = 0;
}
return new StringTerms(name, order, bucketCountThresholds.getRequiredSize(), bucketCountThresholds.getMinDocCount(),
pipelineAggregators(), metaData(), format, bucketCountThresholds.getShardSize(), showTermDocCountError,
otherDocCount, Arrays.asList(list), 0);<5>
}
<1>創建一個bucket隊列ordered ,存放bucket
<2>構建一個空的bucket對象spare
<3>構建所有bucket並且添加到ordered。其中spare.globalOrd這個詞項在全局的序號,spare.bucketOrd是這個詞項在段中的序號,spare.docCount這個詞項擁有的文檔數,這些信息都是從DocValue和docCounts中獲取的。
<4>創建bucket列表list,將ordered中的bucket放入list中
<5>最後用StringTerms對象包裝list返回聚合結果。
這裏需要提醒的是,bucket列表list是存儲在內存中的,如果這個list中bucket的數量太過龐大,比如達到了幾千萬甚至上億的數據量,很可能會引發esOOM的慘案。事實上着中情況在es的運維過程中時有發生,一些不同瞭解es聚合原理的業餘操作者,動不動就在上億數據量的索引上對時間戳,主鍵這種唯一標誌的字段做聚合查詢,導致生成上億個bucket最終出發es OOM。最後還抱怨不好用。對於這種問題,目前主要的規避方法是:1、培訓es操作者,杜絕提交這種不合理的查詢請求;2、在es上層做一層網關或者代理,過拒絕這種惡意請求。
不過我發現es6.2.0中新推出了一個search.max_buckets的配置,如果查詢產生的buckets數量超過配置的數量,就能終止查詢,防止es OOM。
看到這裏各位看官可能會疑惑,前面不是說過es 的request斷路器可以保護內存的嗎?爲什麼阻止不了bucket列表的內存溢出。這裏我們需要知道,request斷路器監控的只是查詢過程中產生的docCounts佔用內存的大小,並沒有監控聚合階段bucket列表佔用的內存。千萬不要錯誤的以爲request斷路器會監控聚合查詢過程中所有數據結構佔用的內存!