Lucene 高效能索引之道
在 ofollow,noindex">Lucene倒排索引原理探祕(1) 和 Lucene倒排索引原理探祕(2) 兩篇文章中詳細介紹了Lucene的倒排索引檔案組織結構,這為高效的搜尋過程奠定了良好的基礎。
我們已經知道,Lucene的倒排索引有兩種格式:一種是用於搜尋的Postings ;另一種是TermsVectors 。這兩種索引的構建過程基本相同,只是寫檔案時在編排方式上有所差異,在《 番外篇:Lucene索引流程與倒排索引實現 》一文中已經做了詳細介紹。
在構建倒排索引的過程中,Lucene需要收集每個Term在整個Segment中的相關資訊(DocID、TermFreq、Position、Offset、Payload),並且將這些資訊儲存下來,如下圖所示。因此,在索引階段,如何高效的收集這些資訊,顯得至關重要,本文將以Postings索引格式為例,詳細探討Lucene索引過程中所採用的資料結構與技術手段。
1 資料結構
Lucene設計了一系列記憶體高效的資料結構,通過 物件複用 和 記憶體分頁 的思想,來優化Java GC問題。這部分內容將圍繞 ByteBlockPool 、 ByteRefHash 、 PostingsArray 三個結構展開。
ByteBlockPool
ByteBlockPool是Lucene實現 高效的可變長的基本型別陣列 ,但實際上陣列一旦初始化之後長度是固定的,因為陣列申請的記憶體必須是連續分配的,以致能夠提供快速隨機訪問的能力。那麼ByteBlockPool是如何實現的?
Buffer結構
在JDK中,以陣列為底層儲存的資料結構,如ArrayList/HashMap,實現可增長時都需要花費一定的代價。陣列增長的過程分兩步,首先申請更大陣列,然後將原陣列複製到新陣列中。即使JVM已經對陣列拷貝做了很多優化,但隨著資料量的不斷增大,拷貝的效能開銷問題也會越來越凸顯,同時,陣列的頻繁建立也都會加大JVM的GC負擔。
ByteBlockPool是一個可動態增長的結構,如下圖所示:
ByteBlockPool是一個由多個Buffer構成的陣列,如上圖右側所示,左側的數字則代表了每一個Buffer在這個二維陣列中的Index位置。Buffer的長度是固定的,當一個Buffer被寫滿以後,需要申請一個新的Buffer,如果這個Buffer陣列要擴充套件,僅僅是將已有Buffer的引用拷貝了一次,而不需要拷貝資料本身。Buffer本質上是一個byte[],因此,ByteBlockPool其實是一個byte的二維陣列,用Buffer陣列來表達則更易理解。
Slice連結串列
索引構建過程需要為每個Term分配一塊相對獨立的空間來儲存Posting資訊。一個Term可能會出現在幾個文件中,而且在每個文件出現的次數和位置都無法確定,所以Lucene無法預知Term需要多大的陣列來儲存Posting資訊。
為此Lucene在ByteBlockPool之上設計了可變長的邏輯結構,這結構就是 Slice連結串列 。它的節點稱之為Slice,Lucene將Slice分成十個級別,逐層遞增,十層之後長度恆定。Slice的最後一個位置用於儲存下個節點Offset,對於最後一個Slice,則儲存了下個Slice的層級數。
Slice節點可以跨越多個Buffer, Slice連結串列為我們提供了一個邏輯上連續的記憶體塊 。如果將Slice連結串列理解成類分散式檔案系統上的檔案,每個Slice則是檔案的資料塊,不過檔案系統的資料塊的大小是固定的,而Slice的長度則是分層級的。
這種設計方案的一個好處:Buffer是相對比較緊湊的結構,能夠更高效的利用Buffer記憶體。按Zipf定律可知一個單詞出現的次數與它在頻率表裡的排名成反比,也就是說可能會有很多Term的頻率是很低的,同樣也有小部分Term的頻率則非常高,Slice的設計正是考慮到了這一分佈特點。
ByteBlockPool與IntBlockPool設計思想完全一樣,IntBlockPool只能儲存int,ByteBlockPool儲存byte,這裡我們不再贅述。Lucene僅實現這兩種基礎的資料型別,其它的型別可以通過編碼之後用ByteBlockPool來儲存。
BytesRefHash
Lucene在構建Postings的時候, 採用一種類似HashMap結構,這個類HashMap的結構便是BytesRefHash,它是一個非通用的Map實現。 它的非通用性表現在BytesRefHash儲存的鍵值對分別是Term和TermID,其次它並沒有實現Map介面,也沒有實現Map的相關操作。
Term在Lucene中通常會被表示為BytesRef,而BytesRef的內部是一個byte[],這是一個可以複用的物件。當通過TermID在BytesRefHash獲取詞元的時候,便將ByteBlockPool的byte[]拷貝到BytesRef的byte[],同時指定有效長度。整個BytesRefHash生存週期中僅持有一個BytsRef,所以該BytesRef的byte[]長度是詞元的長度。
BytesRefHash用來儲存Term和TermID之間的對映關係,如果Term已經存在,返回對應TermID;否則將Term儲存並且生成TermID後返回。Term在儲存過程BytesRefHash將BytesRef的有效資料拷貝在ByteBlockPool上,從而實現緊湊的key值儲存。TermID是從0開始自增長的連續數值,儲存在int[]上。BytesRefHash非雜湊雜湊表,從而TermID的儲存也是緊湊的。
因為BytesRefHash為了儘可能避免用到物件型別,所以直接採用int[]儲存TermID,實際上也就很難直接採用散列表的資料結構來解決HashCode衝突的問題。
Lucene構建倒排索引的過程分了兩步操作,構建Postings和TermVectors。它們倆過程共享一個ByteBlockPool,也就是在每個 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是連續分配的整數),對應的值便是記錄TermID上一次出現的各種資訊。
Lucene為了能夠直接使用基本型別資料,所以才有了PostingsArrays結構。方便理解你可以理解成是Postings[],每個Postings物件含有DocId,TermFreq,intStarts,lastPosition等屬性。
2
索引構建過程
在索引構過程中,Term由TextField經過TokenStream處理之後產生,它由一個可複用物件BytesRef表示。在建構索引的鏈路上,Lucene更多是用TermID來表示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連結串列上,同時會記錄增量資訊,為了節省記憶體空間,Position/Offset第二次及以後都用VInt來記錄這個增量值。
這就是PostingsArrays需要記錄lastPosition/lastOffset的原因,而lastDocID和lastTermFreq不僅可用計算增量,還可以用來計算Term每次出現後TermFreq的累加值。需要說明的一點: 每個資訊在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完成索引檔案的輸出。至此,已經完成了一輪構建索引的流程。
關於"NoSQL漫談"
NoSQL主要泛指一些分散式的 非關係型資料儲存 技術,這其實是一個 非常廣泛 的定義,可以說涉及到分散式系統技術的方方面面。隨著 人工智慧 、 物聯網 、 大資料 、 雲端計算 以及 區塊鏈 技術的不斷普及, NoSQL 技術將會發揮越來越大的價值。
請長按下面的二維碼關注我們
更多NoSQL技術分享,敬請期待!