基於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也在我們項目中有所應用。
Lucene整體使用如圖所示:

結合代碼說明一下四個步驟:
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)//真正取文檔 }
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
使用起來很簡單,但只有知道這背后的原理,才能更好地用好Lucene,后面將介紹通用檢索原理和Lucene的實現細節。
1.2 索引原理
全文檢索技術由來已久,絕大多數都基於倒排索引來做,曾經也有過一些其他方案如文件指紋。倒排索引,顧名思義,它相反於一篇文章包含了哪些詞,它從詞出發,記載了這個詞在哪些文檔中出現過,由兩部分組成——詞典和倒排表。

其中詞典結構尤為重要,有很多種詞典結構,各有各的優缺點,最簡單如排序數組,通過二分查找來檢索數據,更快的有哈希表,磁盤查找有B樹、B+樹,但一個能支持TB級數據的倒排索引結構需要在時間和空間上有個平衡,下圖列了一些常見詞典的優缺點:

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

理論基礎:平衡多路查找樹
優點:外存索引、可更新
缺點:空間大、速度不夠快
- 1
- 2
- 3
- 4
跳躍表:

優點:結構簡單、跳躍間隔、級數可控,Lucene3.0之前使用的也是跳躍表結構,后換成了FST,但跳躍表在Lucene其他地方還有應用如倒排表合並和文檔號索引。
缺點:模糊查詢支持不好
- 1
- 2
- 3
FST
Lucene現在使用的索引結構

理論基礎: 《Direct construction of minimal acyclic subsequential transducers》,通過輸入有序字符串構建最小有向無環圖。
優點:內存占用率低,壓縮率一般在3倍~20倍之間、模糊查詢支持好、查詢快
缺點:結構復雜、輸入要求有序、更新不易
Lucene里有個FST的實現,從對外接口上看,它跟Map結構很相似,有查找,有迭代:
- 1
- 2
- 3
- 4
- 5
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){...}
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
100萬數據性能測試:
- 1
- 2
| 數據結構 | 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)*
- 1
- 2
Lucene經多年演進優化,現在的一個索引文件結構如圖所示,基本可以分為三個部分:詞典、倒排表、正向文件、列式存儲DocValues。
下面詳細介紹各部分結構:
索引結構
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為前綴的后綴詞塊位置。
- 1
- 2
- 3
- 4
- 5
以上是一個簡化過程,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為后綴,如下圖:
- 1
- 2
- 3

倒排表結構
倒排表就是文檔號集合,但怎么存,怎么取也有很多講究,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 銷售部門男女人數、平均年齡是多少
- 1
- 2
- 3
我們看下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實現策略與特點,下一篇將介紹我們如何從這些底層原理出發來優化我們的全文檢索系統。
