1. 程式人生 > >Lucene底層原理和優化經驗分享(2)-Lucene優化經驗總結

Lucene底層原理和優化經驗分享(2)-Lucene優化經驗總結

  系統優化遵從木桶原理:一隻木桶能盛多少水,並不取決於最高的木板,而取決於最短的那塊木板。Lucene優化也一樣,找到效能瓶頸,找對解決方法,才能事半功倍,本文將從三方面闡述我們的Lucene優化經驗:
  1. 找準方向 -> Lucene效能瓶頸分析。
  2. 找對方法 -> Lucene程式碼架構分析。
  3. 方法落地 -> 優化經驗總結。

1. Lucene效能瓶頸分析

  上篇Lucene底層原理分析了Lucene索引結構:記憶體+磁碟,開啟索引庫時只有tip和fdx檔案會被載入到記憶體中,tip為FST的字首索引,fdx為正向檔案索引,其他檔案tim、doc、fdt都放在硬碟,一次完整的檢索過程與索引檔案的互動過程如圖:
這裡寫圖片描述

 
  整個流程至少發生三次隨機IO:
  1. 讀後綴詞塊
  2. 讀倒排表
  3. 取文件(如果文件號跳躍性很大或者因為打分完全亂序,那麼會發生更多次隨機IO,極端情況就是取多少文件就發生多少次隨機IO)
  當前機械硬碟隨機IO響應時間平均在10ms左右,遠大於CPU+記憶體計算時間,而且這只是針對一個查詢條件,若多個查詢條件、跨多列、甚至模糊查詢,隨機IO請求更多,因此Lucene查詢效能瓶頸主要集中磁碟IO效能上,尤其隨機IO效能。所以我們的優化方向就是:
  1. 減少IO請求。
  2. 順序IO代替隨機IO。

2. Lucene程式碼架構

  上一節分析了Lucene效能瓶頸,這一節分析Lucene程式碼架構,找到從哪裡下手去實現優化。
  Lucene從4.0版本後,程式碼全面模組化,並開放了很多介面,包括索引格式介面Codec、打分介面Similarity、文件收集介面Collector,開發者想基於Lucene再開發,不再需要侵入式修改原始碼,而是基於介面,外掛式修改。我們結合業務場景和開放介面自定義了Lucene檢索模式。
  Lucene檢索大致時序圖:
  這裡寫圖片描述

   
  1. APP解析使用者查詢生成查詢條件Query。
  2. IndexSearcher重寫Query並生成Weight。
  3. Weight會生成Scorer,Scorer建立相應查詢條件的倒排表迭代器。
  4. 呼叫scoreALl(),遍歷所有文件ID,依次傳給傳給Collector。
  5. Collector得到文件ID後,呼叫打分模組Similarity得到文件分值,並根據分值和文件收集器具體實現決定是否返回。Lucene預設的收集器TopScoreDocCollector,會根據使用者定義的文件數如100,返回分值前100的文件ID。
  
  我們對Lucene的修改主要在圖中標紅的文件收集過程,一是遮蔽打分,二是修改文件收集模式,下一節會詳細闡述。

3. 優化經驗總結

  基於底層原理和程式碼架構,我們知道了需要做什麼和怎麼做:IO、IO、還是IO,以下我們全文檢索系統的主要優化方案:

3.1單盤優化

解決問題:
  硬碟隨機IO效能低。
解決方案:
  1. 將原先的Raid5拆分,改用單盤,因為Raid5隨機讀寫效能 < n*單盤。
  2. 將索引檔案tim、doc使用固態硬碟SSD存放,正向檔案fdt使用機械硬碟,這樣綜合了SSD隨機讀寫效能高,機械硬碟成本低、儲存空間大的優點。
  3. 對同一磁碟上索引庫進行統一管理,單執行緒處理對同一硬碟上索引庫的檢索請求,防止同一硬碟多庫之間同時訪問降低磁碟效能。這裡可以根據實際測試情況調整具體執行緒數,但執行緒數不宜過多。
  

3.2布隆過濾器

解決問題:
  有些單詞不在索引庫裡,但還需要進索引庫查詢,發起不必要的IO請求。

解決方案:
  使用布隆過濾器,預先判斷單詞是不是在該索引庫裡。布隆過濾器原理很簡單,對一單詞雜湊,並對映到相應bit,設定為1,判斷時同樣做雜湊,並去相應bit位取值,若為1,則可能存在,進庫查詢,若為0,則肯定不存在,不需進庫查詢。
這裡寫圖片描述

  對Lucene實現布隆過濾器有兩種方式:
  1. 在應用層,Lucene之外實現。
  2. 改寫Lucene的Codec介面,添加布隆過濾器功能,使用布隆過濾器預先過濾查詢條件。
  後來我們經過測試,選用了第一種方案,因為布隆過濾器十分消耗記憶體、載入時間很長,而且我們同一索引庫為提高效能,複製到多個硬碟上,所以如果布隆過濾器放在Lucene裡,相同過濾器會被載入多次,會浪費相當多的記憶體,所以我們在Lucene之外做了布隆過濾器,同一索引庫共享一個布隆過濾器,節約了記憶體。
  

3.3遮蔽打分/排序機制

解決問題:
  一次測試發現,同樣的條件,精確查詢速度還沒有模糊查詢速度快
這裡寫圖片描述
  研究原始碼發現,Lucene會對分詞列的精確查詢條件進行打分。打分是搜尋引擎重要一部分,倒排索引只能回答是不是的問題,打分能夠評判查詢條件和文件的匹配度,提高檢索質量。Lucene打分過程集成了多種經典模型,如TF-IDF、VSM,如圖:
  這裡寫圖片描述
  1. coord 一個document滿足幾個查詢,滿足多的分值高。
  2. queryNorm,查詢歸一化,它的意義是讓同一文件但不同查詢的打分結果有可比較。
  3. tf-idf,tf是term在文件中出現次數,idf逆文件頻率是term在多少個文件中出現過除以總文件數。
  4. getBoost,查詢時賦的權重。
  5. 歸一化,主要三個因素文件權重、field權重、文件長度,這個很重要,因為這個需要單獨載入nvm檔案,而且在開啟庫時不會載入,而是在第一次查詢時會載入,因此才會造成查詢時間的巨大差異。
  這裡不詳細闡述,只說下它的幾個基本原則:
  1. 一個文件符合的查詢條件越多分越高。
  2. 一個文件關鍵詞出現次數越多分越高,文件內容越多分越低。
  3. 一個查詢詞在越多文件中出現權重越低。
  有興趣的可檢視LuceneAPI文件TFIDFSimilarity類說明:
  http://lucene.apache.org/core/4_10_3/core/index.html
  打分會消耗額外IO、需要更多CPU計算、載入整個倒排表,拖累了查詢速度,特別實在文件數非常多的情況下。而對模糊查詢,Lucene不會進行打分,所以反而更快。在我們的業務場景下,我們不需要TF-IDF這種打分方式,所以我們完全遮蔽了打分這個過程,大大提高了檢索速度。
解決方案:
  1. 實現EmptySimilarity,去掉所有計算過程,打分過程完全為空。

public class EmptySimilarity extends Similarity {

    private static long ZERO=0;
    @Override
    public long computeNorm(FieldInvertState state) {
        return ZERO;
    }

    @Override
    public SimWeight computeWeight(float queryBoost,
            CollectionStatistics collectionStats, TermStatistics... termStats) {
        return new EmptySimWeight();
    }

    @Override
    public SimScorer simScorer(SimWeight weight, AtomicReaderContext context)
            throws IOException {
        return new EmptyScorer();
    }

    public class EmptySimWeight extends SimWeight {

        @Override
        public float getValueForNormalization() {
            return ZERO;
        }

        @Override
        public void normalize(float queryNorm, float topLevelBoost) {

        }

    }

    public static class EmptyScorer extends SimScorer {

        @Override
        public float score(int doc, float freq) {
            return ZERO;
        }

        @Override
        public float computeSlopFactor(int distance) {
            return ZERO;
        }

        @Override
        public float computePayloadFactor(int doc, int start, int end,
                BytesRef payload) {
            return ZERO;
        }

    }

}

  2. 自定義Collector,結果數滿足了拋異常退出,防止讀入多餘倒排表。


public class SimpleCollector extends Collector implements Iterable<Integer> {

    private final List<Integer> hitList;
    private final int numHits;
    private int docBase;

    public SimpleCollector(int numHits) {
        if(numHits<0)
            throw new IllegalArgumentException("numHits should > 0");
        this.numHits = numHits;
        this.hitList = new ArrayList<Integer>(numHits);
    }


    @Override
    public void collect(int doc) throws IOException {
        if(hitList.size()<numHits)
        {
            hitList.add(docBase+doc);
        }
        else{
            //若結果滿了拋異常退出
            throw new HitListFullException();
        }
    }
    public int size(){
        return hitList.size();
    }
    @Override
    public void setScorer(Scorer scorer) throws IOException {
        //ignore scorer
    }

    @Override
    public void setNextReader(AtomicReaderContext context) throws IOException {
        //因為是分段的,所以需要記載每個段起始文件號
        this.docBase=context.docBase;
    }

    @Override
    public boolean acceptsDocsOutOfOrder() {
        //接受亂序,提高效能,因為最後要自己排序
        return true;
    }

    @Override
    public Iterator<Integer> iterator() {
        Collections.sort(hitList);
        return hitList.iterator();
    }

    public static class HitListFullException extends RuntimeException{

        public HitListFullException()
        {
            super("HitList already full");
        }
    }
}

  使用如下:

    IndexSearcher indexSearcher = new IndexSearcher(
                DirectoryReader.open(FSDirectory
                        .open(new File("/index/lucene_test"))));
        //使用空打分器
        indexSearcher.setSimilarity(new EmptySimilarity());
        SimpleCollector simpleCollector=new SimpleCollector(2);
        try {
            indexSearcher.search(query, simpleCollector);
        } catch (HitListFullException e) {
            //e.printStackTrace();
            // ignore
        }
        System.out.println(simpleCollector.size());
        //遍歷文件號
        for(int hit:simpleCollector)
        {
            indexSearcher.doc(hit);
        }
        indexSearcher.getIndexReader().close();

3.4 取結果優化

解決問題:
  上面的測試條件還有一個問題,就是他們取同樣數量的文件數,時間卻差了很多。
這裡寫圖片描述

  原因就是因為模糊查詢不打分,所以文件ID是順序的,為順序IO讀方式,而打分後文檔ID完全亂序,為隨機IO讀方式。
解決方案:
  1. 自定義Collector,按文件ID升序排序且結果數滿足立即退出。
  2. 多工合併取結果操作,這樣相同ID的文件只會取一次。
  

3.5解決Query被轉成Filter

解決問題:
  我們有一個組合條件:

select * from indexdb where Time > 20170104 AND Time < 20170105 AND Protocol = 'TCP' AND Content ='not exist'

  這裡需要合併多個查詢條件的倒排表,Lucene在合併倒排表時,並不會一次性讀出所有倒排表,而是將倒排表抽象成迭代器,延遲獲取,而且如果有一個AND條件查詢結果為空,它就直接返回,不會讀任一倒排表。這裡Content查詢結果為空,但這個查詢還是很久才返回,debug跟蹤Lucene原始碼發現,Lucene會對Query查詢重寫來優化效能,這裡的Time條件因為匹配到詞數太多,而被Lucene改寫成Filter,Filter一個特點就是會讀出符合查詢條件的所有倒排表,並做成BitSet,所以查詢時間都消耗在了讀倒排表上。
解決方案:
  1. 去掉了CapTime條件,改由應用層去做,按時間預先分庫。
  2. 調整子查詢順序,將匹配結果更少的放前面。
  3. 留心Lucene的重寫機制,有時候重寫過的查詢條件不一定符合我們預期。
  

3.6索引庫大小效能比較

解決問題:
  Lucene一個索引庫多大合適?
解決方案:
  這裡涉及到Lucene索引結構設計:Lucene是分段的。分段是指Lucene接收到索引請求後,會先放快取,快取滿後才會寫到磁碟中去,變成一個Segment,Segment建立好了之後就不會再修改,每個Segment相當於一個功能完整的小索引庫,它包含之前說的所有索引檔案。當然這樣會導致索引庫中有很多段,所以Lucene後臺會有合併執行緒定期去合併小的段。
  段數越少,檢索時隨機IO次數請求就越少,段結果合併操作越少。如果只有一個段,那麼一個查詢條件就需要載入一個字尾詞塊,但有10個段,就需要分別載入10個段的字尾詞塊和倒排表,再合併10個段的查詢結果。分庫本質上跟分段是一樣的,調整庫大小,減少庫數量,就是減少段數來提高效能。
  庫大小測試結果:
  總共575G的索引庫,我們分為6個100g的庫和71個10g的庫來分別測試
  開啟庫測試

庫型別 開啟時間(s) 庫記憶體佔用(g)
大庫 11 1.3
小庫 18 2.2

  查詢測試

庫型別 查詢條件 查詢時間(ms)
大庫 content=’trump’ 1100
小庫 content=’trump’ 5700

  可以看出大庫相比小庫不管再開啟時間、記憶體佔用、查詢效率上都有著很大優勢,所以在條件允許下,儘量把庫調大。但也需注意兩個問題:
  1. 合併大庫是有成本的。
  2. 庫越大,分發成本越高,容錯率越低。

總結

 以上就是我們對Lucene的一些優化經驗,回顧起來就是三點:
  1. 認清業務需求。
  2. 分析底層原理,找出效能瓶頸。
  3. 研究程式碼架構,找到優化切入點。
 這也是我們對其他開源專案的使用方法,知其然更只知其所以然。
 
 謝謝。