Lucene倒排索引簡述 細說倒排索引構建
在《Lucene倒排索引簡述 之索引表》和《Lucene倒排索引簡述 之倒排表》兩篇文章中介紹了Lucene如何將倒排索引結構寫入索引檔案,如何為實現高效搜尋過程奠定了基礎。
Lucene需要收集每個Term在整個Segment的所有資訊(DocID/TermFreq/Position/Offset/Payload),從而實現下圖所示倒排索引結構構建且儲存。所以問題的關鍵在於Lucene採用了些資料結構和手段實現高效的收集任務,完成索引時效能要求的呢?
前面的文章中介紹過Lucene的倒排索引有兩種格式,一種是用於搜尋的Postings,在原始碼中由
FreqProxTermsWriterPerField
負責;另一種是TermsVectors,由TermsVectorsWriterPerField
實現。這兩種索引的收集過程基本相同,只是寫檔案時有各自的編排方式上有所差異。更多的內容請閱讀《Lucene倒排索引簡述 番外篇》。這裡我們以第一種方式為例,來探索Lucene索引的高效之道。
假設直接利用檔案系統來完成這種任務,那麼就是每個Term都有一個獨立的檔案,檔名就是TermID,用一個List來儲存檔案控制代碼。這種做法也是可以的完成我們的需求,但這種方式存在非常大的問題。因為它會帶大量的磁碟的隨機寫,磁碟本身的效能比較差,所以它可能還會加劇系統的IO問題。
Lucene利用這種思想在記憶體上設計一系列的結構以實現高效能,能夠避免大量物件的頻繁建立與銷燬帶來的效能開銷和可能引發GC問題的實現方案。本文將從這個角度開始來探究倒排索引構建過程中使用的各個資料結構。
一、資料結構
設計合適的資料結構對影響提升至關,在特定的場景使用的合適的結構是成功的基石,進而考慮演算法問題。演算法通常是特定結構下討論的話題,Lucene採用哪些資料結構奠定了構建索引的效能呢?
1. ByteBlockPool
ByteBlockPool是Lucene實現高效的可變長的基本型別陣列,但實際上陣列一旦初始化之後是長度是固定的,因為陣列申請的記憶體必須是連續分配的,以致能夠提供隨機訪問的能力。那麼ByteBlockPool採用什麼手段來解決這樣的問題?
1.1 Buffer結構
在所有JDK中以陣列為底層儲存的資料結構,如ArrayList/HashMap,實現可增長時都需要花一定代價的。陣列增長的過程分兩步,首先申請更大陣列,然後將原陣列複製到新陣列上。即使JVM已經對陣列拷貝做了很多優化,如今拷貝的效能開銷已經很小了。但是在大量的索引時使得儲存在陣列上的資料不斷增長,拷貝的效能開銷會慢慢突顯出現,同時陣列頻繁建立也都會帶來JVM的GC問題。
ByteBlockPool是一個增長的資料結構,它實現可增長的祕密是利用二維資料的特性,二維陣列是在一維陣列上引用另一維陣列,即是陣列中的陣列。 如果把它看成是List<byte[]>的形式,在List中每個元素儲存放的是int[]的引用,將它視覺化之後如下圖:
當在我們宣告二維陣列的時候,如果只指定第一維組的長度時,這方式申請的二維陣列,兩維陣列在Heap上不會連續分配。
如此說來,我們將的二維陣列的每一行稱為Buffer,列叫為Buffer索引。放回到List<byte[]>理解這個結構,List儲存的是byte[]引用,那相當於是byte[]的索引。這裡我們稱byte[]為Buffer,List即是Buffer的索引。Buffer的長度是固定,當Buffer被寫滿了之後,需要僅繼續申請一個Buffer,省去常規的陣列增長之後資料拷貝的過程。
1.2 Slice連結串列
到這裡Lucene僅僅完成了儲存大量資料的問題,索引構建過程還需要為每個Term都需要一塊相對獨立的空間來儲存Posting資訊。但在收集過程,Term會出現在幾個文件中,每個文件出現次數和位置都無法確定,所以Lucene無法預Term需要多大的陣列來儲存Posting資訊。
為此Lucene在ByteBlockPool之上設計了可變長的邏輯結構,這結構就是Slice連結串列。它是由Slice為節點的連結串列,Lucene將Slice分成十級別,逐層遞增,十層之後長度恆定。Slice的最後一個位置用於儲存下個節點Offset,當最後一個Slice時儲存下個Slice的層級數。
Slice節點可以跨越多Buffer,Slice連結串列為我們提供了一個邏輯上連續的記憶體塊(byte[])。它就是類似於檔案系統上檔案,每個Slice即是檔案的資料塊,不過檔案系統的資料塊的大小是固定的,而Slice的長度是分層級的。
這種設計方案好處是,Buffer會是相對比較緊湊的結構,能夠更充分利用的Buffer記憶體。按Zipf定律可知一個單詞出現的次數與它在頻率表裡的排名成反比,也就說可能會很多Term的頻率是很小的,同樣也有小部分Term的頻率非常大,Slice的設計也是考慮這一特點。
Slice連結初始化時在Buffer上分配一個比較小的Slice節點,可以看得出來Buffer上的Slice非常緊湊的資料結構,屬於ByteBlockPool的Buffer內部結構,Slice的連結串列相當於是一個檔案,是一個連續且統一的結構。
ByteBlockPool與IntBlockPool設計思想完全一樣,IntBlockPool只能儲存int,ByteBlockPool儲存byte,這裡我們不再複述。Lucene僅實現這兩種資料型別,其它的型別可以通過編碼之後用ByteBlockPool儲存。
2. BytesRefHash
Lucene在構建Postings的時候,採用一種類似HashMap結構,這個類HashMap的結構便是BytesRefHash,它是BytesRefHash是一個非通用的Map實現。 它的非通用性表現在BytesRefHash儲存的鍵值對分別是Term和TermID,其次它並沒有實現Map介面,也沒有實現Map的相關操作。
Term在Lucene中通常會被表示BytesRef,而BytesRef的內部是一個byte[],這是一個可以複用物件。當通過TermID在BytesRefHash獲取詞元的時候,便是詞元在儲存ByteBlockPool的byte[]拷貝到BytesRef的byte[],同時指指定有效長度。整個BytesRefHash生存週期中僅持有一個BytsRef,所以該BytesRef的byte[]長度是最詞元的長度。
BytesRefHash是為Postings構建過程生成並存儲Term和TermID之間的而設計的結構,如果Term已經存在,返回對應TermID;否則將Term儲存,同時生成TermID並返回。Term在儲存過程BytesRefHash將BytesRef的有效資料拷貝在ByteBlockPool上,從而實現緊湊的key的儲存。TermID是從0開始自增長的連續數值,儲存在int[]上。BytesRefHash非雜湊雜湊表,從而TermID的儲存也是緊湊的。
因為BytesRefHash為了儘可能避免用到物件型別,所以直接採用int[]儲存TermID,實際上也就很難直接採用散列表的資料結構來解決HashCode衝突的問題。
Lucene構建倒排索引的過程分了兩步操作,構建Postings和TermVectors。它們倆過程共享一個ByteBlookPool,也就是在每個DocumentsWriter
共用同一個ByteRefHash(因為BytesRefHash以ByteBlockPool都不是執行緒安全的,因此它們都是執行緒級別)。它為Postings收集過程提供去重和Term與TermID對應關係的儲存及檢索。
3. PostingsArrays
從PostingsArrays的名字上容易被誤以為是就Postings收集的儲存結構,實則並不是。在Postings構建過程中,Lucene是將各項資訊寫到ByteBlockPool的Slice連結串列上。連結串列是單向連結串列,它的表頭和表尾儲存到PostingsArrays,從而快速寫入和從開頭開始遍歷。這個結構曾在《Lucene倒排索引簡述 番外篇》介紹過,可以配合起來讀。
PostingsArrays除了記錄了Slice連結串列的索引之外,它還儲存上個文件的DocID和TermFreq,還有Term上次出現的位置和位移的情況。即是PostingsArrays由幾個int[]組成,其下標都是TermID(TermID是連續分配的整型數,所以PostingsArrays是能被緊湊的儲存的),對應的值便是記錄TermID上一次出現的各種資訊了。就是說Lucene用多個int[]儲存Term的各種資訊,一個int[]僅存TermID的一種資訊中的一個數據。
Lucene為了能夠直接使用基本型別資料,才有PostingsArrays結構的。方便理解你可以理解成是Postings[],每個Postings物件含有DocId,TermFreq,intStarts,lastPosition等屬性。
二、構建索引過程
在索引構過程中,Term由TextField經過TokenStream之後產生,它由一個可複用物件BytesRef表示。但在建構索引的鏈路上,Lucene更多的是用TermID來表示Term,因為TermID是連續分配的數值,也是為了適用PostingsArrays的結構特點。這是PostingsArrays工作原理的第一前提,可以用陣列來儲存Term對的資訊。
當Term第一次出現時,Lucene嘗試在BytesRefHash取到TermID失敗,此時Lucene將它的狀態標記新人
。新出現的Term作為新生嬰兒,需要在BytesRefHash上為它分配一個身份證號(TermID)。然後在PostingsArrays登記戶籍資訊了,它記錄了它的名字(BytesRef的byte[])在哪個位置(前面提過,BytesRefHash直接把Term儲存在ByteBlockPool的,所以需要位置記下來);然後為建立一個履歷檔案(第一Slice連結串列),記錄它將來在每個年級(DocID)的考試次數(TermFreq)。
如果地區比較有心,還會另一份為Term建那個一檔案(第二Slice連結串列)用於記錄,每個次考試的情況,如果班級(Position),座位號(Offset),以及成績(Payload)。這些資料是Term成長過程的產生的,經歷一次記一次。關於每次考試的情況,第一次考試Lucene直接把它寫入ByteBlockPool的Slice連結串列上,同時會記一會在PostingsArrays用於下次記錄計算增量。為了節省記憶體空間,Position/Offset第二次及以後都用VInt來記錄它的增量。
這就是為什麼PostingsArrays需要記錄lastPosition/lastOffset的原因,關於lastDocID和lastTermFreq除了因為需要算增量之外,還因為Term每次出現都累加,所以先在PostingsArrays記錄。此外則是每個資訊在Slice中是沒有索引,不方便回溯和修改。
構建索引的過程,實際上就是ByteBlockPool(IntBlockPool)、BytesRefHash和PostingsArrays三者之間的協作。當它們同框之後,結構的美感體驗淋漓盡致。
這裡為了簡化流程,圖中將IntBlockPool簡化成為int[],也就是說它也是Slice的方式實現連續的連結串列。
PostingsArrays的byteStarts[TermID]
記錄Term的倆個連結串列的表頭在ByteBlockPool的絕對位置,intStarts[TermID]
記錄下次要寫的位置,則textStarts[TermID]
則是記錄BytesRefHash把Term儲存在ByteBlockPool的哪個位置上。
為什麼byteStart和intStart需要先指向IntBlockPool呢? 主要是因為TermID對應可能會有兩條Slice連結串列,以TermID為索引的陣列不方便儲存。通過IntBlockPool可以方便處理,僅需要IntBlockPool連續兩個位置,下一個位置用於儲存第兩個Slice連結串列。當然IntBlockPool的引入讓這個過程變得更復雜了,也體現Lucene的設計之精湛和巧妙。
將表尾是為了方便寫入資料,表頭將是了方便遍歷了。在索引提交的時候,Lucene將這兩個Slice連結串列的資料通過PostingsEnum遍歷出來,交由BlockTreeTermsWriter完成索引檔案的產出。構建索引過程也就走向終點,同時新一輪的索引構建開始。