大家好,我是藍胖子,最近在做一些elasticsearch 慢查詢最佳化的事情,通常用分析elasticsearch 慢查詢的時候可以透過profile api 去分析,分析結果顯示的底層lucene在搜尋過程中使用到的函式呼叫。所以要想徹底弄懂elasticsearch慢查詢的原因,還必須將lucene的查詢原理搞懂,今天我們就先來介紹下lucene的查詢邏輯的各個階段。
lucene 查詢過程分析
先放上一張查詢過程的流程圖,下面的分析其實都是對這張圖的更詳細的介紹。
lucene的查詢可以大致分為4個階段,重寫查詢,建立查詢weight物件,建立scorer物件準備計分,進行統計計分。
簡單解釋下這4個階段;
1, 重寫查詢語句( rewrite query )
lucene提供了比較豐富的外部查詢型別,像wildcardQuery,MatchQuery等等,但它們最後都會替換為比較底層的查詢型別,例如wildcardQuery會被重寫為MultiTermsQuery。
2, 建立查詢weight物件( createWeight )
Query物件建立的權重物件, lucece的每個查詢都會計算一個該查詢佔用的權重值,如果是不需要計分的,則權重值是一個固定常量,得到的文件結果是根據多個查詢的權重值計算其得分的。下面是Weight 物件涉及的方法,
其中,scorer(LeafReaderContext context) 方法是個抽象方法,需要子類去實現的。
public abstract Scorer scorer(LeafReaderContext context) throws IOException;
方法返回的scorer物件擁有遍歷倒排列表和統計文件得分的功能,下面會講到實際上weight物件是建立BulkScore進行計分的,但BulkScore內部還是透過score物件進行計分。
再詳細解釋下Scorer物件中比較重要的方法;
- iterator() 方法返回的DocIdSetIterator 物件提供了遍歷倒排列表的能力。如下是DocIdSetIterator 涉及的方法,其中docID()是為了返回當前遍歷到的倒排列表的文件id,nextDoc()則是將遍歷指標移動到下一個文件,並且返回文件id,advance 用於移動遍歷指標。
- twoPhaseIterator 方法提供對文件二次精準匹配的能力,比如在matchPhrase查詢中,不但要查出某個詞,還要求查出的詞之間相對順序不變,那麼這個相對順序則是透過twoPhaseIterator的matches方法去進行判斷。
3, 建立bulkScorer物件( weight.bulkScore)
weight 物件會呼叫BulkScore方法建立BulkScorer物件,bulkScorer 內部首先呼叫的是scorer抽象方法(需要由weight子類去實現的方法),得到的scorer物件再拿去構建DefaultBulkScorer 物件,所以說,實際上最後計分的還是透過scorer物件進行計分的。
public BulkScorer bulkScorer(LeafReaderContext context) throws IOException {
Scorer scorer = scorer(context);
if (scorer == null) {
// No docs match
return null;
}
// This impl always scores docs in order, so we can
// ignore scoreDocsInOrder:
return new DefaultBulkScorer(scorer);
}
bulkScorer類有如下方法,一個是提供對段所有文件進行計分,一個是可以在段的某個文件id範圍內進行計分。
4, 進行統計計分
最後則是透過collector物件進行統計,這裡提到了collecor物件,它其實是作為了上述bulkScorer的score方法引數傳入的,在bulkScore.score方法內部,遍歷文件時,對篩選出的文件會透過呼叫collector.collect(doc)方法進行收集,在collect方法內部,則是呼叫scorer物件對文件進行打分。
完整的搜尋流程如下
public <C extends Collector, T> T search(Query query, CollectorManager<C, T> collectorManager)
throws IOException {
final C firstCollector = collectorManager.newCollector();
// 重寫查詢物件
query = rewrite(query, firstCollector.scoreMode().needsScores());
// 呼叫indexSearch的createWeight方法,本質上還是呼叫的Query的createWeight方法
final Weight weight = createWeight(query, firstCollector.scoreMode(), 1);
return search(weight, collectorManager, firstCollector);
}
// 簡化了程式碼,保留了主流程,呼叫scorer.score 進行計分。
protected void search(List<LeafReaderContext> leaves, Weight weight, Collector collector){
// 得到每個segment段的收集器,原始碼是可以線上程池中同時對幾個segment進行搜尋的,這裡省略了。
leafCollector = collector.getLeafCollector(ctx);
BulkScorer scorer = weight.bulkScorer(ctx);
// 將收集器作為buklScore.score引數傳入,對文件進行計分。
scorer.score(leafCollector, ctx.reader().getLiveDocs());
leafCollector.finish();
}
profile api 返回結果分析
理清楚了lucene的搜尋邏輯,我們再來看看透過profile api返回的各個階段耗時是統計的哪段邏輯。
在使用elasticsearch 的profile api 時,會返回如下的統計階段
如果不瞭解原始碼可能會對這些統計指標比較疑惑,結合剛才對lucece 原始碼的瞭解來看下幾個比較常見的統計指標。
next_doc 是取倒排連結串列中當前遍歷到的文件id,並且把遍歷的指標移動到下一個文件id消耗的時長。
score 是weight.scorer方法建立的score物件,進行文件計分的操作時消耗的時長。
match 是 twoPhaseIterator進行二次匹配判斷時消耗的時長。
advance 是直接將遍歷的指標移動到特定文件id處消耗的時長。
build_score 是weight物件在透過weight.scorer方法建立score物件時所耗費的時長。
create_weight 是query物件在呼叫其自身createWeight方法建立weight物件時耗費的時長。
set_min_competitive_score,compute_max_score,shallow_advance 我也還沒徹底弄懂它們用到的所有場景,這裡暫不做分析。
這裡還要注意的一點是,像布林查詢是結合了多個子查詢的結果,它內部會構造特別的scorer物件,比如ConjunctionScorer 交集scorer,它的next_doc 方法則是需要對其子查詢的倒排連結串列求交集,所以你在用profile api 分析時,可能會看到布林查詢的next_doc 耗時較長,而其子查詢耗時較長的邏輯則是advance,因為倒排列表合併邏輯會有比較多的advance移動指標的動作。
profile api 的實現原理
最後,我再來談談elasticsearch 是如何實現profile 的,lucene的搜尋都是透過IndexSearcher物件來執行的,IndexSearcher在呼叫query物件自身的rewrite 方法重寫query後,會呼叫IndexSearcher 的createWeight 方法來建立weight物件(本質上底層還是使用的query的createWeight方法)。
elasticsearch 繼承了IndexSearcher ,重寫了createWeight,在原本weight物件的基礎上,封裝了一個profileWeight物件。以下是關鍵程式碼。
public Weight createWeight(Query query, ScoreMode scoreMode, float boost) throws IOException {
if (profiler != null) {
// createWeight() is called for each query in the tree, so we tell the queryProfiler
// each invocation so that it can build an internal representation of the query // tree QueryProfileBreakdown profile = profiler.getQueryBreakdown(query);
Timer timer = profile.getNewTimer(QueryTimingType.CREATE_WEIGHT);
timer.start();
final Weight weight;
try {
weight = query.createWeight(this, scoreMode, boost);
} finally {
timer.stop();
profiler.pollLastElement();
}
return new ProfileWeight(query, weight, profile);
} else {
return super.createWeight(query, scoreMode, boost);
}
}
基於文章開頭的lucene查詢邏輯分析,可以知道,scorer物件最後也是透過weight物件的scorer方法得到的,所以建立出來的profileWeight的scorer方法通用也對返回的scorer物件封裝了一層,返回的是profileScorer物件。
public Scorer scorer(LeafReaderContext context) throws IOException {
ScorerSupplier supplier = scorerSupplier(context);
if (supplier == null) {
return null;
}
return supplier.get(Long.MAX_VALUE);
}
@Override
public ScorerSupplier scorerSupplier(LeafReaderContext context) throws IOException {
final Timer timer = profile.getNewTimer(QueryTimingType.BUILD_SCORER);
timer.start();
final ScorerSupplier subQueryScorerSupplier;
try {
subQueryScorerSupplier = subQueryWeight.scorerSupplier(context);
} finally {
timer.stop();
}
if (subQueryScorerSupplier == null) {
return null;
}
final ProfileWeight weight = this;
return new ScorerSupplier() {
@Override
public Scorer get(long loadCost) throws IOException {
timer.start();
try {
return new ProfileScorer(weight, subQueryScorerSupplier.get(loadCost), profile);
} finally {
timer.stop();
}
}
@Override
public long cost() {
timer.start();
try {
return subQueryScorerSupplier.cost();
} finally {
timer.stop();
}
}
};
}
剩下的就好辦了,在profileScore物件裡對scorer物件的原生方法前後加上時間統計即可對特定方法進行計時了。比如下面程式碼中profileScore的advanceShallow方法。
public int advanceShallow(int target) throws IOException {
shallowAdvanceTimer.start();
try {
return scorer.advanceShallow(target);
} finally {
shallowAdvanceTimer.stop();
}
}
總結
透過本篇文章,應該可以對lucene的查詢過程有了大概的瞭解,但其實對於elasticsearch的慢查詢分析還遠遠不夠,因為像布林查詢,wilcard之類的比較複雜的查詢,我們還得弄懂,它們底層是究竟如何把一個大查詢分解成小查詢的。才能更好的弄懂查詢耗時的原因,所以在下一節,我會講解這些比較常見的查詢型別的內部重寫和查詢邏輯。