Lucene解析 - IndexWriter
來自專欄 Elasticsearch技術研討14 人贊了文章
前言
在上一篇文章我們介紹了Lucene的基本概念,在本篇文章我們將深入Lucene中最核心的類之一IndexWriter,來探索Lucene中數據寫入和索引構建的整個過程。
IndexWriter
// initializationDirectory index = new NIOFSDirectory(Paths.get("/index"));IndexWriterConfig config = new IndexWriterConfig();IndexWriter writer = new IndexWriter(index, config);// create a documentDocument doc = new Document();doc.add(new TextField("title", "Lucene - IndexWriter", Field.Store.YES));doc.add(new StringField("content", "招人,求私信", Field.Store.YES));// index the documentwriter.addDocument(doc);writer.commit();
先看下Lucene中如何使用IndexWriter來寫入數據,上面是一段精簡的調用示例代碼,整個過程主要有三個步驟:
- 初始化:初始化IndexWriter必要的兩個元素是Directory和IndexWriterConfig,Directory是Lucene中數據持久層的抽象介面,通過這層介面可以實現很多不同類型的數據持久層,例如本地文件系統、網路文件系統、資料庫或者是分散式文件系統。IndexWriterConfig內提供了很多可配置的高級參數,提供給高級玩家進行性能調優和功能定製,它提供的幾個關鍵參數後面會細說。
- 構造文檔:Lucene中文檔由Document表示,Document由Field構成。Lucene提供多種不同類型的Field,其FiledType決定了它所支持的索引模式,當然也支持自定義Field,具體方式可參考上一篇文章。
- 寫入文檔:通過IndexWriter的addDocument函數寫入文檔,寫入時同時根據FieldType創建不同的索引。文檔寫入完成後,還不可被搜索,最後需要調用IndexWriter的commit,在commit完後Lucene才保證文檔被持久化並且是searchable的。
以上就是Lucene的一個簡明的數據寫入流程,核心是IndexWriter,整個過程被抽象的非常簡潔明了。一個設計優良的庫的最大特點,就是可以讓普通玩家以非常小的代價學習和使用,同時又照顧高級玩家能夠提供可調節的性能參數和功能定製能力。
IndexWriterConfig
IndexWriterConfig內提供了一些供高級玩家做性能調優和功能定製的核心參數,我們列幾個主要的看下:
- IndexDeletionPolicy:Lucene開放對commit point的管理,通過對commit point的管理可以實現例如snapshot等功能。Lucene默認配置的DeletionPolicy,只會保留最新的一個commit point。
- Similarity:搜索的核心是相關性,Similarity是相關性演算法的抽象介面,Lucene默認實現了TF-IDF和BM25演算法。相關性計算在數據寫入和搜索時都會發生,數據寫入時的相關性計算稱為Index-time boosting,計算Normalizaiton並寫入索引,搜索時的相關性計算稱為query-time boosting。
- MergePolicy:Lucene內部數據寫入會產生很多Segment,查詢時會對多個Segment查詢併合並結果。所以Segment的數量一定程度上會影響查詢的效率,所以需要對Segment進行合併,合併的過程就稱為Merge,而何時觸發Merge由MergePolicy決定。
- MergeScheduler:當MergePolicy觸發Merge後,執行Merge會由MergeScheduler來管理。Merge通常是比較耗CPU和IO的過程,MergeScheduler提供了對Merge過程定製管理的能力。
- Codec:Codec可以說是Lucene中最核心的部分,定義了Lucene內部所有類型索引的Encoder和Decoder。Lucene在Config這一層將Codec配置化,主要目的是提供對不同版本數據的處理能力。對於Lucene用戶來說,這一層的定製需求通常較少,能玩Codec的通常都是頂級玩家了。
- IndexerThreadPool:管理IndexWriter內部索引線程(DocumentsWriterPerThread)池,這也是Lucene內部定製資源管理的一部分。
- FlushPolicy:FlushPolicy決定了In-memory buffer何時被flush,默認的實現會根據RAM大小和文檔個數來判斷Flush的時機,FlushPolicy會在每次文檔add/update/delete時調用判定。
- MaxBufferedDoc:Lucene提供的默認FlushPolicy的實現FlushByRamOrCountsPolicy中允許DocumentsWriterPerThread使用的最大文檔數上限,超過則觸發Flush。
- RAMBufferSizeMB:Lucene提供的默認FlushPolicy的實現FlushByRamOrCountsPolicy中允許DocumentsWriterPerThread使用的最大內存上限,超過則觸發flush。
- RAMPerThreadHardLimitMB:除了FlushPolicy能決定Flush外,Lucene還會有一個指標強制限制DocumentsWriterPerThread佔用的內存大小,當超過閾值則強制flush。
- Analyzer:即分詞器,這個通常是定製化最多的,特別是針對不同的語言。
核心操作
IndexWriter提供很簡單的幾種操作介面,這一章節會做一個簡單的功能和用途解釋,下一個章節會對其內部實現做一個詳細的剖析。IndexWrite的提供的核心API如下:
- addDocument:比較純粹的一個API,就是向Lucene內新增一個文檔。Lucene內部沒有主鍵索引,所有新增文檔都會被認為一個新的文檔,分配一個獨立的docId。
- updateDocuments:更新文檔,但是和資料庫的更新不太一樣。資料庫的更新是查詢後更新,Lucene的更新是查詢後刪除再新增。流程是先delete by term,後add document。但是這個流程又和直接先調用delete後調用add效果不一樣,只有update能夠保證在Thread內部刪除和新增保證原子性,詳細流程在下一章節會細說。
- deleteDocument:刪除文檔,支持兩種類型刪除,by term和by query。在IndexWriter內部這兩種刪除的流程不太一樣,在下一章節再細說。
- flush:觸發強制flush,將所有Thread的In-memory buffer flush成segment文件,這個動作可以清理內存,強制對數據做持久化。
- prepareCommit/commit/rollback:commit後數據才可被搜索,commit是一個二階段操作,prepareCommit是二階段操作的第一個階段,也可以通過調用commit一步完成,rollback提供了回滾到last commit的操作。
- maybeMerge/forceMerge:maybeMerge觸發一次MergePolicy的判定,而forceMerge則觸發一次強制merge。
數據路徑
上面幾個章節介紹了IndexWriter的基本流程、配置和核心介面,非常簡單和易理解。這一章節,我們將深入IndexWriter內部,來探索其內核實現。
如上是IndexWriter內部核心流程架構圖,接下來我們將以add/update/delete/commit這些主要操作來講解IndexWriter內部的數據路徑。
並發模型
IndexWriter提供的核心介面都是線程安全的,並且內部做了特殊的並發優化來優化多線程寫入的性能。IndexWriter內部為每個線程都會單獨開闢一個空間來寫入,這塊空間由DocumentsWriterPerThread來控制。整個多線程數據處理流程為:
- 多線程並發調用IndexWriter的寫介面,在IndexWriter內部具體請求會由DocumentsWriter來執行。DocumentsWriter內部在處理請求之前,會先根據當前執行操作的Thread來分配DocumentsWriterPerThread。
- 每個線程在其獨立的DocumentsWriterPerThread空間內部進行數據處理,包括分詞、相關性計算、索引構建等。
- 數據處理完畢後,在DocumentsWriter層面執行一些後續動作,例如觸發FlushPolicy的判定等。
引入了DocumentsWriterPerThread(後續簡稱為DWPT)後,Lucene內部在處理數據時,整個處理步驟只需要對以上第一步和第三步進行加鎖,第二步完全不用加鎖,每個線程都在自己獨立的空間內處理數據。而通常來說,第一步和第三步都是非常輕量級的,而第二步是對計算和內存資源消耗最大的。所以這樣做之後,能夠將加鎖的時間大大縮短,提高並發的效率。每個DWPT內單獨包含一個In-memory buffer,這個buffer最終會flush成不同的獨立的segment文件。
這種方案下,對多線程並發寫入性能有很大的提升。特別是針對純新增文檔的場景,所有數據寫入都不會有衝突,所以非常適合這種空間隔離式的數據寫入方式。但對於刪除文檔的場景,一次刪除動作可能會涉及刪除不同線程空間內的數據,這裡Lucene也採取了一種特殊的交互方式來降低鎖的開銷,在剖析delete操作時會細說。
在搜索場景中,全量構建索引的階段,基本是純新增文檔式的寫入,而在後續增量索引階段(特別是數據源是資料庫時),會涉及大量的update和delete操作。從原理上來分析,一個最佳實踐是包含相同唯一主鍵Term的文檔分配相同的線程來處理,使數據更新發生在一個獨立線程空間內,避免跨線程。
add & update
add介面用於新增文檔,update介面用於更新文檔。但Lucene的update和資料庫的update不太一樣。資料庫的更新是查詢後更新,Lucene的更新是查詢後刪除再新增,不支持更新文檔內部分列。流程是先delete by term,後add document。
IndexWriter提供的add和update介面,都會映射到DocumentsWriter的udpate介面,看下介面定義:
long updateDocument(final Iterable<? extends IndexableField> doc, final Analyzer analyzer, final Term delTerm) throws IOException, AbortingException
這個函數內的處理流程是:
- 根據Thread分配DWPT
- 在DWPT內執行delete
- 在DWPT內執行add
關於delete操作的細節在下一小結詳細說,add操作會直接將文檔寫入DWPT內的In-memory buffer。
delete
delete相對add和update來說,是完全不同的一個數據路徑。而且update和delete雖然內部都會執行數據刪除,但這兩者又是不同的數據路徑。文檔刪除不會直接影響In-memory buffer內的數據,而是會有另外的方式來達到刪除的目的。
在Delete路徑上關鍵的數據結構就是Deletion queue,在IndexWriter內部會有一個全局的Deletion Queue,稱為Global Deletion Queue,而在每個DWPT內部,還會有一個獨立的Deletion Queue,稱為Pending Updates。DWPT Pending Updates會與Global Deletion Queue進行雙向同步,因為文檔刪除是全局範圍的,不應該只發生在DWPT範圍內。
Pending Updates內部會按發生順序記錄每個刪除動作,並且標記該刪除影響的文檔範圍,文檔影響範圍通過記錄當前已寫入的最大DocId(DocId Upto)來標記,即代表這個刪除動作只刪除小於等於該DocId的文檔。
update介面和delete介面都可以進行文檔刪除,但是有一些差異:
- update只能進行by term的文檔刪除,而delete除了by term,還支持by query。
- update的刪除會先作用於DWPT內部,後作用於Global,再由Global同步到其他DWPT。
- delete的刪除會作用在Global級別,後非同步同步到DWPT級別。
update和delete流程上的差異也決定了他們行為上的一些差異,update的刪除操作會先發生在DWPT內部,並且是和add同時發生,所以能夠保證該DWPT內部的delete和add的原子性,即保證在add之前的所有符合條件的文檔一定被刪除。
DWPT Pending Updates里的刪除操作什麼時候會真正作用於數據呢?在Lucene Segment內部,數據實際上並不會被真正刪除。Segment中有一個特殊的文件叫live docs,內部是一個點陣圖的數據結構,記錄了這個Segment內部哪些DocId是存活的,哪些DocId是被刪除的。所以刪除的過程就是構建live docs標記點陣圖的過程,數據實際上不會被真正刪除,只是在live docs里會被標記刪除。Term刪除和Query刪除會在不同階段構建live docs,Term刪除要求先根據Term查詢出它關聯的所有doc,所以很明顯這個會發生在倒排索引構建時。而Query刪除要求執行一次完整的查詢後才能拿到其對應的docId,所以會發生在segment被flush完成後,基於flush後的索引文件構建IndexReader後執行搜索才能完成。
還有一點要注意的是,live docs隻影響倒排,所以在live docs里被標記刪除的文檔沒有辦法通過倒排索引檢索出,但是還能夠通過doc id查詢到store fields。當然文檔數據最終是會被真正物理刪除,這個過程會發生在merge時。
flush
flush是將DWPT內In-memory buffer里的數據持久化到文件的過程,flush會在每次新增文檔後由FlushPolicy判定自動觸發,也可以通過IndexWriter的flush介面手動觸發。
每個DWPT會flush成一個segment文件,flush完成後這個segment文件是不可被搜索的,只有在commit之後,所有commit之前flush的文件才可被搜索。
commit
commit時會觸發數據的一次強制flush,commit完成後再此之前flush的數據才可被搜索。commit動作會觸發生成一個commit point,commit point是一個文件。Commit point會由IndexDeletionPolicy管理,lucene默認配置的策略只會保留last commit point,當然lucene提供其他多種不同的策略供選擇。
merge
merge是對segment文件合併的動作,合併的好處是能夠提高查詢的效率以及回收一些被刪除的文檔。Merge會在segment文件flush時觸發MergePolicy來判定自動觸發,也可通過IndexWriter進行一次force merge。
IndexingChain
前面幾個章節主要介紹了IndexWriter內部各個關鍵操作的流程,本小節會介紹最核心的DWPT內部對文檔進行索引構建的流程。Lucene內部索引構建最關鍵的概念是IndexingChain,顧名思義,鏈式的索引構建。為啥是鏈式的?這個和Lucene的整個索引體系結構有關係,Lucene提供了各種不同類型的索引類型,例如倒排、正排(列存)、StoreField、DocValues等。每個不同的索引類型對應不同的索引演算法、數據結構以及文件存儲,有些是列級別的,有些是文檔級別的。所以一個文檔寫入後,需要被這麼多種不同索引處理,有些索引會共享memory-buffer,有些則是完全獨立的。基於這個架構,理論上Lucene是提供了擴展其他類型索引的可能性,頂級玩家也可以去嘗試。
在IndexWriter內部,indexing chain上索引構建順序是invert index、store fields、doc values和point values。有些索引類型處理文檔後會將索引內容直接寫入文件(主要是store field和term vector),而有些索引類型會先將文檔內容寫入memory buffer,最後在flush的時候再寫入文件。能直接寫入文件的索引,通常是文檔級的索引,索引構建可以文檔級的增量構建。而不能寫入文件的索引,例如倒排,則必須等Segment內所有文檔全部寫入完畢後,會先對Term進行一個全排序,之後才能構建索引,所以必須要有一個memory-buffer先緩存所有文檔。
前面提到,IndexWriterConfig支持配置Codec,Codec就是對應每種類型索引的Encoder和Decoder。在上圖可以看到,在Lucene 7.2.1版本中,主要有這麼幾種Codec:
- BlockTreeTermsWriter:倒排索引對應的Codec,其中倒排表部分使用Lucene50PostingsWriter(Block方式寫入倒排鏈)和Lucene50SkipWriter(對Block的SkipList索引),詞典部分則是使用FST(針對倒排表Block級的詞典索引)。
- CompressingTermVectorsWriter:對應Term vector索引的Writer,底層是壓縮Block格式。
- CompressingStoredFieldsWriter:對應Store fields索引的Writer,底層是壓縮Block格式。
- Lucene70DocValuesConsumer:對應Doc values索引的Writer。
- Lucene60PointsWriter:對應Point values索引的Writer。
這一章節主要了解IndexingChain內部的文檔索引處理流程,核心是鏈式分階段的索引,並且不同類型索引支持Codec可配置。
總結
這篇文章主要從一個全局視角來講解IndexWriter的配置、介面、並發模型、核心操作的數據路徑以及索引鏈,在之後的文章中,會再深入索引鏈中每種不同類型索引的構建流程,探索其memory-buffer的實現、索引演算法以及數據存儲格式。
推薦閱讀:
※Elasticsearch學習,請先看這一篇!
※ES官方調優指南翻譯
※卧槽,簡單的Django ElasticSearch Haystack我竟然調了那麼久。。。
※elasticsearch:我對_all、_source的理解,對index、store認識
※Elastic search中使用nested類型的內嵌對象
TAG:編程 | Lucene | Elasticsearch |