從根上理解elasticsearch(lucene)查詢原理(1)-lucece查詢邏輯介紹

藍胖子的程式設計夢發表於2023-12-08

大家好,我是藍胖子,最近在做一些elasticsearch 慢查詢最佳化的事情,通常用分析elasticsearch 慢查詢的時候可以透過profile api 去分析,分析結果顯示的底層lucene在搜尋過程中使用到的函式呼叫。所以要想徹底弄懂elasticsearch慢查詢的原因,還必須將lucene的查詢原理搞懂,今天我們就先來介紹下lucene的查詢邏輯的各個階段。

lucene 查詢過程分析

先放上一張查詢過程的流程圖,下面的分析其實都是對這張圖的更詳細的介紹。

未命名檔案 (2).jpg

lucene的查詢可以大致分為4個階段,重寫查詢,建立查詢weight物件,建立scorer物件準備計分,進行統計計分。

簡單解釋下這4個階段;

1, 重寫查詢語句( rewrite query )

lucene提供了比較豐富的外部查詢型別,像wildcardQuery,MatchQuery等等,但它們最後都會替換為比較底層的查詢型別,例如wildcardQuery會被重寫為MultiTermsQuery。

2, 建立查詢weight物件( createWeight )

Query物件建立的權重物件, lucece的每個查詢都會計算一個該查詢佔用的權重值,如果是不需要計分的,則權重值是一個固定常量,得到的文件結果是根據多個查詢的權重值計算其得分的。下面是Weight 物件涉及的方法,

Pasted image 20231207173540.png

其中,scorer(LeafReaderContext context) 方法是個抽象方法,需要子類去實現的。

public abstract Scorer scorer(LeafReaderContext context) throws IOException;

方法返回的scorer物件擁有遍歷倒排列表和統計文件得分的功能,下面會講到實際上weight物件是建立BulkScore進行計分的,但BulkScore內部還是透過score物件進行計分。

Pasted image 20231207175531.png

再詳細解釋下Scorer物件中比較重要的方法;

  • iterator() 方法返回的DocIdSetIterator 物件提供了遍歷倒排列表的能力。如下是DocIdSetIterator 涉及的方法,其中docID()是為了返回當前遍歷到的倒排列表的文件id,nextDoc()則是將遍歷指標移動到下一個文件,並且返回文件id,advance 用於移動遍歷指標。

Pasted image 20231207180951.png

  • twoPhaseIterator 方法提供對文件二次精準匹配的能力,比如在matchPhrase查詢中,不但要查出某個詞,還要求查出的詞之間相對順序不變,那麼這個相對順序則是透過twoPhaseIterator的matches方法去進行判斷。

Pasted image 20231207180839.png

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範圍內進行計分。

Pasted image 20231207181829.png

4, 進行統計計分

最後則是透過collector物件進行統計,這裡提到了collecor物件,它其實是作為了上述bulkScorer的score方法引數傳入的,在bulkScore.score方法內部,遍歷文件時,對篩選出的文件會透過呼叫collector.collect(doc)方法進行收集,在collect方法內部,則是呼叫scorer物件對文件進行打分。

Pasted image 20231207175514.png
完整的搜尋流程如下

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();
}

未命名檔案 (2).jpg

profile api 返回結果分析

理清楚了lucene的搜尋邏輯,我們再來看看透過profile api返回的各個階段耗時是統計的哪段邏輯。

在使用elasticsearch 的profile api 時,會返回如下的統計階段

Pasted image 20231208133407.png

如果不瞭解原始碼可能會對這些統計指標比較疑惑,結合剛才對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之類的比較複雜的查詢,我們還得弄懂,它們底層是究竟如何把一個大查詢分解成小查詢的。才能更好的弄懂查詢耗時的原因,所以在下一節,我會講解這些比較常見的查詢型別的內部重寫和查詢邏輯。