elasticsearch wildcard 慢查詢原因分析(深入到原始碼!!!)

2023-09-05 15:00:38

大家好,我是藍胖子,前段時間線上elasticsearch叢集遇到多次wildcard產生的效能問題, elasticsearch wildcard 一直是容易引發elasticsearch 容易宕機的一個風險點, 但究竟它為何消耗cpu呢?又該如何理解elasticsearch profile api的返回結果呢?在探索了部分原始碼後,我將在這篇文章一一揭曉答案。

學完這篇文章,你可以學到如下內容:

首先,我們來看下elasticsearch的查詢過程,elasticsearch 底層是使用lucece來實現資料的儲存與搜尋,你可以簡單的理解為elasticsearch 為lucece提供了分散式儲存的能力,lucece只能單機儲存。

所以,理解elasticsearch查詢過程分為兩部分,第一是elasticsearch 如何使用lucece進行查詢,第二是lucece本身是如何進行查詢的。

elasticsearch查詢流程

elasticsearch 的查詢簡單來說可以分為兩部分,第一步稱為query通過呼叫lucece查詢的api獲取符合條件的檔案id,第二步稱為fetch階段,再通過檔案id,向lucece獲取原檔案內容。所以宏觀上看對於elasticsearch 的慢查詢,要麼是query階段慢,要麼是fetch階段慢。

lucece 查詢流程

對於fetch階段沒有多的可講,即用檔案id在lucece正排索引中獲取原檔案內容。我們著重分析下query階段,即lucece是如何進行檔案id搜尋的。

在使用lucece 搜尋時,通常是構造一個lucece的indexsearcher 物件,通過呼叫indexsearcher物件的search方法實現搜尋,程式碼如下:

Query query = new WildcardQuery(new Term("body", "m*tal*"));
ScoreDoc[] result = searcher.search(query, 1000).scoreDocs;

socreDoc是封裝了檔案id和搜尋得分的型別,程式碼如下,

public class ScoreDoc {  
  /** The score of this document for the query. */  
  public float score;  
  /**  
   * A hit document's number.   *   * @see StoredFields#document(int)  
   */  public int doc;  
  /** Only set by {@link TopDocs#merge} */  
  public int shardIndex;

而整個search的流程可以總結為以下幾個階段,

1,重寫(rewrite)query 型別。

2, 建立weight物件。

3,建立bulksocre物件。

4, 呼叫bulkscore.score方法,進行檔案算分。

為了對下面分析的階段更加深刻,我將search 方法程式碼貼上到這下面,

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());  
  final Weight weight = createWeight(query, firstCollector.scoreMode(), 1);  
  return search(weight, collectorManager, firstCollector);  
}

接著,我們依次來看下這幾個階段。

rewrite query

lucece中每個查詢型別都繼承自org.apache.lucene.search.Query 這個抽象類,在每個查詢各自的實現中可以覆蓋其中的rewrite方法,來將原本的複雜查詢轉換為更底層的查詢型別。比如wildcard query 查詢型別會被rewrite 修改為MultiTermQueryConstantScoreWrapper,MultiTermQueryConstantScoreBlendedWrapper或其他查詢型別,具體取決於elasticsearch 執行wildcard查詢時指定的rewrite型別。

在lucece執行search方法內部執行rewrite時實際就是在呼叫查詢型別Query的rewrite方法。

createWeight

接著lucece的search方法會呼叫createWeight方法產生一個weight物件,createWeight 本質上也是對Query型別的createWeight方法的呼叫,weight物件的作用是計算查詢的權重以及構建打分score物件

elasticsearch 會為每個查詢匹配的檔案進行打分,分數越高的檔案就會排在前面,打分時也會考慮查詢條件的權重,權重越高,分數越高。

計分階段

像前面提到的search 方法原始碼那樣, 接著又會呼叫一個過載的search方法來進行search階段的最後的步驟

  return search(weight, collectorManager, firstCollector);  

在這個search方法裡,lucece會判斷會不會對索引的多個段segment採用多執行緒方式搜尋。

lucece的每個段segment是可以單獨被搜尋的,多執行緒搜尋的結果最後會由collecotor物件通過reduce方法進行合併。這裡就不展開了,我們著重關注下,lucece是如何對每個段進行搜尋的。

第一步則是由前面的weight 物件通過bulkScorer 獲得一個bulkScore物件。

BulkScorer scorer = weight.bulkScorer(ctx);

bulkScore 物件顧名思義,批次打分,這個物件將會對篩選出來的檔案進行批次打分。

所以緊接著第二部便是呼叫bulkScore的score方法進行批次打分,

scorer.score(leafCollector, ctx.reader().getLiveDocs());

批次打分的結果會被leafCollector 收集起來。

lucece 查詢過程時是在哪個階段查詢匹配檔案的?

在瞭解了lucece的整體查詢過程後,你可能還對lucece是在哪個階段進行檔案篩選的感到疑惑,所以我準備單獨講下這部分。

要了解這部分主要就是要了解weight.bulkScorer和scorer.score的細節,因為查詢邏輯就封裝在裡面。

weight.bulkScorer 建立了BulkScore,bulkScore由scorer,DocIdSetIterator,TwoPhaseIterator 3個物件構成。

scorer 物件是由weight建立的,封裝了打分的規則。

DocIdSetIterator ,TwoPhaseIterator用於遍歷匹配的檔案,它們是scorer物件的兩個屬性,在遍歷匹配檔案時,一般它們只有一個有值。

無論是DocIdSetIterator 還是TwoPhaseIterator ,它們都擁有共同的方法迭代檔案的方法,next() 用於遍歷到下一個檔案,advance方法用於跳過某些檔案,match()方法則是專門在TwoPhaseIterator 進行二次匹配判斷時用到的。

TwoPhaseIterator特別用於說明此次遍歷是二次遍歷,比如PhraseQuery 型別的查詢,lucece 是先將滿足所有給定查詢的term對應的檔案篩選出來,然後再進行二次遍歷看檔案的term相對位置是不是滿足查詢條件的短語位置。

所以針對 lucece 查詢過程時是在哪個階段查詢匹配檔案的?這個問題,要區分不同的查詢型別。由於我僅僅看了部分查詢原始碼,這裡直接說下phraseQuery 型別和wildcardQuery 型別查詢檔案各自在什麼階段。

phraseQuery

在createWeight方法呼叫時,此時會進行第一次查詢,通過給定的phrase短語拆分成term後,找出包含所有term的檔案集合。

然後在scorer.score 方法內,會對檔案集合進行二次遍歷得到最終符合條件的檔案集合。

wildcardQuery

由於wildcardQuery會被重寫,這裡我用預設的重寫類MultiTermQueryConstantScoreWrapper舉例,它會在weight.bulkScorer 呼叫時遍歷 查詢欄位所有的term,傳入提前建立好的有限狀態機(有限狀態機是在構建wildcardQuery物件時建立的)進行匹配,如果匹配成功,則說明檔案符和查詢條件。最後將檔案id集合構建成一個DocIdSetIterator物件作為scorer物件的屬性用於後續的遍歷。

所以在scorer.score方法裡遍歷的物件就是在weight.bulkScorer 方法裡建立的DocIdSetIterator物件。

關於wildcardQuery 有限狀態機的原理將不會在這節展開,但是你需要知道的是lucece中最終是將使用者輸入的模式匹配字串構建成了DFA(確定性有限狀態機),它的時間複雜度是O(2^n),n輸入的字串長度,所以這也是為什麼模式匹配字串越長,wildcardQuery消耗時間越長的原因之一,且構建過程十分消耗cpu資源。

其次,構建的狀態機需要和索引中該欄位的所有的term去進行比較匹配,這在term數量比較大時也會出現wildCardQuery緩慢的情況。

elasticsearch profile api

profile api 實現原理

elasticsearch 提供了profile 引數設定可以在查詢的時候設定為true來得到查詢的分析結果,也可以在kibana的dev tools 介面直接使用profile功能。

而profile api能夠實現的原理則是通過裝飾器模式,通過包裝lucece原有的的weight,scorer,searchindexer型別,然後在前面提到的建立scorer方法,DocIdSetIterator的advance,next,TwoPhaseIterator的match方法前後加上埋點資訊統計耗時時間。

查詢結果分析

最後,我們著重來看下profile的返回結果,這裡我用kibana的介面返回結果舉例。


profile 返回的結果列出了被重寫後的查詢,每個查詢耗時情況,而單個的查詢各個階段的耗時情況也被列舉了出來。

像wildCardQuery的查詢主要耗時在build_scorer 階段,正如前面提到的那樣,wildCardQuey 查詢在執行weight.bulkScorer 時會遍歷查詢欄位所有的term 傳入狀態機進行匹配,這個過程是比較耗時的,生產上還是慎用,我們生產上3億多的資料,用wildcard 查詢一個匹配 *abc* 的字串需要6,7s 。慢也是慢在build_scorer, 其次需要注意的是構建狀態機的耗時沒有出現在profile的返回結果裡,當模式匹配字串很長時,構建自動狀態機也是很消耗cpu的,一般還是要限制模式匹配字串的長度。

profile 結果中返回的其他階段, 例如next_doc ,advance 則是在scorer.score 方法內部呼叫檔案迭代器進行迭代時,呼叫的方法。

create_weight 是建立weight物件時需要呼叫的,像phraseQuery 在create_weight還進行了第一次檔案查詢,所以針對phraseQuery而言,可能整個過程中,最耗時的就是create_weight階段。

score是對最後匹配的檔案進行打分時需要呼叫的方法,match 則是二次匹配時需要呼叫的方法,其餘3個方法耗時,compute_max_score,set_min_competive_score,shallow_advance 具體在什麼場景下會被呼叫就沒有深入研究了,等碰到耗時比較高的情況可以再去翻原始碼檢視。