Lucene底層原理和最佳化經驗分享(1)-Lucene簡介和索引原理

玻璃窗起霧了發表於2021-01-31

基於 Lucene 檢索引擎我們開發了自己的全文檢索系統,承擔起後臺 PB 級、萬億條資料記錄的檢索工作,這裡向大家分享下 Lucene 底層原理研究和一些最佳化經驗。
  從兩個方面介紹:
   1. Lucene 簡介和索引原理
   2. Lucene 最佳化經驗總結

1. Lucene 簡介和索引原理

  該部分從三方面展開: Lucene 簡介、索引原理、 Lucene 索引實現。

1.1 Lucene 簡介

   Lucene 最初由鼎鼎大名 Doug Cutting 開發, 2000 年開源,現在也是開源全文檢索方案的不二選擇,它的特點概述起來就是:全 Java 實現、開源、高效能、功能完整、易擴充,功能完整體現在對分詞的支援、各種查詢方式(字首、模糊、正則等)、打分高亮、列式儲存( DocValues )等等。
  而且 Lucene 雖已發展 10 餘年,但仍保持著一個活躍的開發度,以適應著日益增長的資料分析需求,最新的 6.0 版本里引入 block k-d trees ,全面提升了數字型別和地理位置資訊的檢索效能,另基於 Lucene Solr ElasticSearch 分散式檢索分析系統也發展地如火如荼, ElasticSearch 也在我們專案中有所應用。

  結合程式碼說明一下四個步驟:

IndexWriter iw=new IndexWriter();//建立IndexWriter
Document doc=new Document( new StringField("name", "Donald Trump", Field.Store.YES)); //構建索引文件
iw.addDocument(doc);            //做索引庫
IndexReader reader = DirectoryReader.open(FSDirectory.open(new File(index)));
IndexSearcher searcher = new IndexSearcher(reader); //開啟索引
Query query = parser.parse("name:trump");//解析查詢
 

TopDocs results =searcher.search(query, 100);//檢索並取回前100個文件號
for(ScoreDoc hit:results.hits)
{
    Document doc=searcher .doc(hit.doc)//真正取文件
}

  使用起來很簡單,但只有知道這背後的原理,才能更好地用好 Lucene ,後面將介紹通用檢索原理和 Lucene 的實現細節。

1.2 索引原理

  全文檢索技術由來已久,絕大多數都基於倒排索引來做,曾經也有過一些其他方案如檔案指紋。倒排索引,顧名思義,它相反於一篇文章包含了哪些詞,它從詞出發,記載了這個詞在哪些文件中出現過,由兩部分組成 —— 詞典和倒排表。

  其中詞典結構尤為重要,有很多種詞典結構,各有各的優缺點,最簡單如排序陣列,透過二分查詢來檢索資料,更快的有雜湊表,磁碟查詢有 B 樹、 B+ 樹,但一個能支援 TB 級資料的倒排索引結構需要在時間和空間上有個平衡,下圖列了一些常見詞典的優缺點:

  其中可用的有: B+ 樹、跳躍表、 FST
   B+ 樹:
               mysql InnoDB B+ 數結構

    理論基礎:平衡多路查詢樹
    優點:外存索引、可更新
缺點:空間大、速度不夠快
  跳躍表:
    優點:結構簡單、跳躍間隔、級數可控,Lucene3.0之前使用的也是跳躍表結構,後換成了FST,但跳躍表在Lucene其他地方還有應用如倒排表合併和文件號索引。
    缺點:模糊查詢支援不好

   FST
   Lucene 現在使用的索引結構

理論基礎:   《Direct construction of minimal acyclic subsequential transducers》,透過輸入有序字串構建最小有向無環圖。
優點:記憶體佔用率低,壓縮率一般在3倍~20倍之間、模糊查詢支援好、查詢快
缺點:結構複雜、輸入要求有序、更新不易
Lucene裡有個FST的實現,從對外介面上看,它跟Map結構很相似,有查詢,有迭代:
String inputs={"abc","abd","acf","acg"}; //keys
long outputs={1,3,5,7};                  //values
FST<Long> fst=new FST<>();
for(int i=0;i<inputs.length;i++)
{
    fst.add(inputs[i],outputs[i])
}
//get
Long value=fst.get("abd");               //得到3
//迭代
BytesRefFSTEnum<Long> iterator=new BytesRefFSTEnum<>(fst);
while(iterator.next!=null){...}
100萬資料效能測試:
·       1

資料結構

HashMap

TreeMap

FST

構建時間 (ms)

185

500

1512

查詢所有 key(ms)

106

218

890

  可以看出, FST 效能基本跟 HaspMap 差距不大,但 FST 有個不可比擬的優勢就是佔用記憶體小,只有 HashMap10 分之一左右,這對大資料規模檢索是至關重要的,畢竟速度再快放不進記憶體也是沒用的。
  因此一個合格的詞典結構要求有:
   1. 查詢速度。
   2. 記憶體佔用。
   3. 記憶體 + 磁碟結合。
  後面我們將解析 Lucene 索引結構,重點從 Lucene FST 實現特點來闡述這三點。

1.3 Lucene 索引實現

*(本文對Lucene的原理介紹都是基於4.10.3)*
  

索引結構

   Lucene 現在採用的資料結構為 FST ,它的特點就是:
   1 、詞查詢複雜度為 O(len(str))
   2 、共享字首、節省空間
   3 、記憶體存放字首索引、磁碟存放字尾詞塊
  這跟我們前面說到的詞典結構三要素是一致的: 1. 查詢速度。 2. 記憶體佔用。 3. 記憶體 + 磁碟結合。我們往索引庫裡插入四個單詞 abd abe acf acg, 看看它的索引檔案內容。

   tip 部分,每列一個 FST 索引,所以會有多個 FST ,每個 FST 存放字首和字尾塊指標,這裡字首就為 a ab ac tim 面存放字尾塊和詞的其他 資訊如倒排表指標 TFDF 等, doc 檔案裡就為每個單詞的倒排表。
  所以它的檢索過程分為三個步驟:
   1. 記憶體載入 tip 檔案,透過 FST 匹配字首找到字尾詞塊位置。
   2. 根據詞塊位置,讀取磁碟中 tim 檔案中字尾塊並找到字尾和相應的倒排表位置資訊。
   3. 根據倒排表位置去 doc 檔案中載入倒排表。
  這裡就會有兩個問題,第一就是字首如何計算,第二就是字尾如何寫磁碟並透過 FST 定位,下面將描述下 Lucene 構建 FST 過程 :
  已知 FST 要求輸入有序,所以 Lucene 會將解析出來的文件單詞預先排序,然後構建 FST ,我們假設輸入為 abd,abd,acf,acg ,那麼整個構建過程如下:

1. 插入abd時,沒有輸出。
2. 插入abe時,計算出字首ab,但此時不知道後續還不會有其他以ab為字首的詞,所以此時無輸出。
3. 插入acf時,因為是有序的,知道不會再有ab字首的詞了,這時就可以寫tip和tim了,tim中寫入字尾詞塊d、e和它們的倒排表位置ip_d,ip_e,tip中寫入a,b和以ab為字首的字尾詞塊位置(真實情況下會寫入更多資訊如詞頻等)。
4. 插入acg時,計算出和acf共享字首ac,這時輸入已經結束,所有資料寫入磁碟。tim中寫入字尾詞塊f、g和相對應的倒排表位置,tip中寫入c和以ac為字首的字尾詞塊位置。

  以上是一個簡化過程, Lucene FST 實現的主要最佳化策略有:

1. 最小字尾數。Lucene對寫入tip的字首有個最小字尾數要求,預設25,這時為了進一步減少記憶體使用。如果按照25的字尾數,那麼就不存在ab、ac字首,將只有一個跟節點,abd、abe、acf、acg將都作為字尾存在tim檔案中。我們的10g的一個索引庫,索引記憶體消耗只佔20M左右。
2. 字首計算基於byte,而不是char,這樣可以減少字尾數,防止字尾數太多,影響效能。如對宇(e9 b8 a2)、守(e9 b8 a3)、安(e9 b8 a4)這三個漢字,FST構建出來,不是隻有根節點,三個漢字為字尾,而是從unicode碼出發,以e9、b8為字首,a2、a3、a4為字尾,

倒排表結構

  倒排表就是文件號集合,但怎麼存,怎麼取也有很多講究, Lucene 現使用的倒排表結構叫 Frame of reference, 它主要有兩個特點:
   1. 資料壓縮,可以看下圖怎麼將 6 個數字從原先的 24bytes 壓縮到 7bytes
   2. 跳躍表加速合併,因為布林查詢時, and or 操作都需要合併倒排表,這時就需要快速定位相同文件號,所以利用跳躍表來進行相同文件號查詢。
  這部分可參考 ElasticSearch 的一篇部落格,裡面有一些效能測試:
   ElasticSearch 倒排表

正向檔案

  正向檔案指的就是原始文件, Lucene 對原始文件也提供了儲存功能,它儲存特點就是分塊 + 壓縮, fdt 檔案就是存放原始文件的檔案,它佔了索引庫 90% 的磁碟空間, fdx 檔案為索引檔案,透過文件號(自增數字)快速得到文件位置,它們的檔案結構如下:
   fnm 中為元資訊存放了各列型別、列名、儲存方式等資訊。
   fdt 為文件值,裡面一個 chunk 就是一個塊, Lucene 索引文件時,先快取文件,快取大於 16KB 時,就會把文件壓縮儲存。一個 chunk 包含了該 chunk 起始文件、多少個文件、壓縮後的文件內容。
   fdx 為文件號索引,倒排表存放的時文件號,透過 fdx 才能快速定位到文件位置即 chunk 位置,它的索引結構比較簡單,就是跳躍表結構,首先它會把 1024 chunk 歸為一個 block, 每個 block 記載了起始文件值, block 就相當於一級跳錶。
  所以查詢文件,就分為三步:
  第一步二分查詢 block ,定位屬於哪個 block
  第二步就是根據從 block 里根據每個 chunk 的起始文件號,找到屬於哪個 chunk chunk 位置。
  第三步就是去載入 fdt chunk ,找到文件。這裡還有一個細節就是存放 chunk 起始文件值和 chunk 位置不是簡單的陣列,而是採用了平均值壓縮法。所以第 N chunk 的起始文件值由 DocBase + AvgChunkDocs * n + DocBaseDeltas[n] 恢復而來,而第 N chunk fdt 中的位置由 StartPointerBase + AvgChunkSize * n + StartPointerDeltas[n] 恢復而來。
  從上面分析可以看出, lucene 對原始檔案的存放是行是儲存,並且為了提高空間利用率,是多文件一起壓縮,因此取文件時需要讀入和解壓額外文件,因此取文件過程非常依賴隨機 IO ,以及 lucene 雖然提供了取特定列,但從儲存結構可以看出,並不會減少取文件時間。

列式儲存DocValues

  我們知道倒排索引能夠解決從詞到文件的快速對映,但當我們需要對檢索結果進行分類、排序、數學計算等聚合操作時需要文件號到值的快速對映,而原先不管是倒排索引還是行式儲存的文件都無法滿足要求。
  原先 4.0 版本之前, Lucene 實現這種需求是透過 FieldCache ,它的原理是透過按列逆轉倒排表將( field value ->doc )對映變成( doc -> field value )對映 , 但這種實現方法有著兩大顯著問題:
   1. 構建時間長。
   2. 記憶體佔用大,易 OutOfMemory ,且影響垃圾回收。
  因此 4.0 版本後 Lucene 推出了 DocValues 來解決這一問題,它和 FieldCache 一樣,都為列式儲存,但它有如下優點:
   1. 預先構建,寫入檔案。
   2. 基於對映檔案來做,脫離 JVM 堆記憶體,系統排程缺頁。
   DocValues 這種實現方法只比記憶體 FieldCache 慢大概 10~25% ,但穩定性卻得到了極大提升。
   Lucene 目前有五種型別的 DocValues NUMERIC BINARY SORTED SORTED_SET SORTED_NUMERIC ,針對每種型別 Lucene 都有特定的壓縮方法。
  如對 NUMERIC 型別即數字型別,數字型別壓縮方法很多,如:增量、表壓縮、最大公約數,根據資料特徵選取不同壓縮方法。
   SORTED 型別即字串型別,壓縮方法就是表壓縮:預先對字串字典排序分配數字 ID ,儲存時只需儲存字串對映表,和數字陣列即可,而這數字陣列又可以採用 NUMERIC 壓縮方法再壓縮,圖示如下:
  這樣就將原先的字串陣列變成數字陣列,一是減少了空間,檔案對映更有效率,二是原先變成訪問方式變成固長訪問。
  對 DocValues 的應用, ElasticSearch 功能實現地更系統、更完整,即 ElasticSearch Aggregations—— 聚合功能,它的聚合功能分為三類:
   1. Metric -> 統計
   典型功能: sum min max avg cardinality percent
   2. Bucket -> 分組
   典型功能:日期直方圖,分組,地理位置分割槽
   3. Pipline -> 基於聚合再聚合
   典型功能:基於各分組的平均值求最大值。
基於這些聚合功能, ElasticSearch 不再侷限與檢索,而能夠回答如下 SQL 的問題

select gender,count(*),avg(age) from employee where dept='sales' group by gender
銷售部門男女人數、平均年齡是多少

  我們看下 ElasticSearch 如何基於倒排索引和 DocValues 實現上述 SQL 的。
   1. 從倒排索引中找出銷售部門的倒排表。
   2. 根據倒排表去性別的 DocValues 裡取出每個人對應的性別,並分組到 Female Male 裡。
   3. 根據分組情況和年齡 DocValues ,計算各分組人數和平均年齡
   4. 因為 ElasticSearch 是分割槽的,所以對每個分割槽的返回結果進行合併就是最終的結果。
 上面就是 ElasticSearch 進行聚合的整體流程,也可以看出 ElasticSearch 做聚合的一個瓶頸就是最後一步的聚合只能單機聚合,也因此一些統計會有誤差,比如 count(*) group by producet limit 5, 最終總數不是精確的。因為單點記憶體聚合,所以每個分割槽不可能返回所有分組統計資訊,只能返回部分,彙總時就會導致最終結果不正確,具體如下:
 原始資料:

Shard 1

Shard 2

Shard 3

Product A (25)

Product A (30)

Product A (45)

Product B (18)

Product B (25)

Product C (44)

Product C (6)

Product F (17)

Product Z (36)

Product D (3)

Product Z (16)

Product G (30)

Product E (2)

Product G (15)

Product E (29)

Product F (2)

Product H (14)

Product H (28)

Product G (2)

Product I (10)

Product Q (2)

Product H (2)

Product Q (6)

Product D (1)

Product I (1)

Product J (8)


Product J (1)

Product C (4)


  count(*) group by producet limit 5 ,每個節點返回的資料如下:

Shard 1

Shard 2

Shard 3

Product A (25)

Product A (30)

Product A (45)

Product B (18)

Product B (25)

Product C (44)

Product C (6)

Product F (17)

Product Z (36)

Product D (3)

Product Z (16)

Product G (30)

Product E (2)

Product G (15)

Product E (29)

 合併後:

Merged

Product A (100)

Product Z (52)

Product C (50)

Product G (45)

Product B (43)

 商品 A 的總數是對的,因為每個節點都返回了,但商品 C 在節點 2 因為排不到前 5 所以沒有返回,因此總數是錯的。

總結

  以上就是 Lucene 簡介和底層原理分析,側重於 Lucene 實現策略與特點,下一篇將介紹我們如何從這些底層原理出發來最佳化我們的全文檢索系統。


來自 “ ITPUB部落格 ” ,連結:http://blog.itpub.net/69992957/viewspace-2754878/,如需轉載,請註明出處,否則將追究法律責任。

相關文章