Lucene8.0新特徵 DocValues改進

一、前言

IndexedDISI是DocValues核心存儲結構之一,主要用於存儲DocValues中的DocIdSet數據,它的性能直接影響DocValues的整體使用體驗。DocIdSet是一種非常特殊的數據集,它是Lucene的DocID集合,所以這是有序的整型數。同時它除了用於存儲DocIdSet之外,還必須能夠將DocId查找到對應有Value,實現DocID與Value的映射關係從而找到對應的值。

DocValues是通過DocId能快速找到對應Value的數據結構,所以它的功能就是維護DocID到Value之間的關係。關於DocValues索引存儲採用結構和格式,在《Lucene DocValues索引文件詳解》中有詳細的介紹,也簡單的介紹了IndexedDISI。這裏針對IndexedDISI的實現細節繼續展開,然後再看Lucene-8585做哪些改進,所以配合一起食用味道更佳。

二、IndexedDISI設計

在一個完整的Segment中,DocID當然是有序且連續的,但由於某些文檔的DocValues字段都可能存在缺省情況。當某個文檔DocValues字段缺省時,在DocValues中自然也不會記錄該文檔的DocID了,從而造成DocIdSet可能不連續,甚至非常稀疏。

當然,即使DocIdSet的數據分佈十分稀疏同樣可以使用BitSet來存儲(Lucene7.0之前就是這麼實現的),但會非常浪費空間,也會影響讀寫的性能。因此開始着手優化BitSet的底層存儲方式,最終Lucene借用Roaring Bitmaps的思想設計了IndexedDISI(其中DISI是DocIdSetIterator的縮寫)。

下面是7.0改進之後IndexedDISI的結構示意圖,名字也是Lucene7新起的。

在Numeric類型,Values與DocIdSet有相同的順序,也是說DocIdSet的第一個DocID對應的Value在Values的第一個位置。其它的類型則通過記錄中間變量Address轉化,DocIdSet的第一個DocID對應Address的第一個值是DocId對應的Value在Values的指針。所以我們可以簡單的理解爲DocIDSet與Values同序,DocIdSet中第n個DocID對應的Value在Values中的第n個位置。

通過NumOfDocs可以容易算得每個Slice的第一個Doc對應的Value在Values的位置(StartDoc),下文我們可以認爲StartDoc是直接記錄在Slice上的已知參數。

1. 分片規則

IndexedDISI將DocIdSet分片,讓每個分片的數據的分佈特徵更明顯,針對每個分片的分佈特徵進行優化。這是Roaring Bitmaps優化BitSet的核心思想,也是一種壓縮的手段。

分片的邏輯是將整型數值DocID的高16位作爲分片的ID,低16位作爲該DocID在分片中的第幾個位置(這裏暫且就叫Doc)。兩部分合起來就是32位,正是一個整型數值的表示範圍,也就說將一個Intger對半拆分成兩個short,分別表示SliceId和Doc。

簡單說,將一個整型數(DocID)一分爲二,左邊16個Bits表示分片編碼(SliceID),右邊的16個Bits表示DocID在分片裏的位置(這裏就用Doc表示吧)。

2. 數據分佈特點

分片是爲了讓每個數據塊的特徵更明顯,從而針對每個分片設計獨特的存儲結構。大家都知道16個Bits能表示65536個數值,即0-65535,說明每個分片最多能存儲65536個DocID。Lucene按DocID最終落在同某Slice的數量,將Slice分成四種類型,分別是ALLDENSESPARSENONE

  1. ALL : 表示該Slice中的每個Doc都存在。
  2. NONE : 與ALL相反,表示整個Slice中一個Doc都沒有。
  3. DENSE : Slice擁有4096個文檔,IndexedDISI採用BitSet來存儲。
  4. SPARSE : 表示Slice擁有的文檔數不足4092個,IndexedDISI採用short[]作爲底層存儲。

ALL/NONE是特殊情況,要麼都有,要麼都沒有,IndexedDISI針對這兩種特殊的情況都沒有存儲對應的DocIdSet。當是ALL類型時,IndexedDISI會寫一個標識表示該Slice屬於ALL類型。而對於NONE而言,IndexedDISI則直接略過完全記錄。

3. DocID與Value對應關係

爲了能維繫DocValues中DocID與Value的對應關係,Lucene的主要思想是讓DocID存儲在IndexedDISI的位置通過簡單計算就能得到它對應Value在Values的位置index(後面都用index來表示DocID對應的Value在Values的位置)。換言之,是讓DocID與Value之間有相同相對位移。

在ALL和NONE結構是比較容易能對應的,其計算關係是index=StartDoc+Doc。而對SAPRSE/DENSE可能還有些許差別,下面將展開來討論。

3.1 ALL/NONE

這兩種類型是比較特殊的情況,因爲它們都不需要記錄DocID。如果是ALL僅會記錄是第幾個SliceID和Slice的類型(即說是明屬於ALL類型)。對於NONE則完全不需要處理,直接略過。因爲它們要麼都存在,要麼都不存在。對於全存在的情況,doc在Slice位置,也是value在Values的相對位置。當然對NONE類型來說,本身就不存在,更不存在Value了。所以兩者沒有,也就都不需要考慮了。

3.2 SPARSE

SPARSE類型的Slice會直接採用short[]存儲Doc,因此它的下標跟Value在Values的下標相差StartDoc的距離,即通過簡單的計算便能獲取index。實際上跟ALL基本一樣,因爲它們都是緊湊數據結構,因此SPARSE類型的Slice也可以通過公式index=StartDoc+Doc計算。

3.3 DENSE

DENSE的存儲結構是BitSet,它存儲不連續的數據集時,BitSet也會是稀疏結構。也就說如何解決不連續的稀疏結構BitSet與緊湊的Values結構之間的對齊問題。

BitSet在邏輯上可以理解爲是一個bit[],DocIdSet表現在這個bit[]上就是將Doc作爲bit[]的下標,其值置爲1表示該Doc存在。所以它與Values的關係表示如下:

那麼問題可以轉化爲如何統計Doc所有位置之前有多少個1,而1的數量便是Value在Values的相對位置。

已知BitSet的底層存儲結構是long[],每個long也稱word,有64個位表示能存儲64個值。通過對target除於64得到wordNum,表示target在BitSet中的long[]的下標;對target取64的模能得到docInWord,表示target在word的第幾個位上。計算公式如下:

    wordNum = target / 64;
    docInWord = target % 64;

遍歷long數組的前wordNum個元素,統計元素含多少個值爲1的位並累加,然後再減去word中第docInWord之後出現1的數目即可。

三、改進之後

通過前面的介紹,對IndexedDISI已經有整體的印象了。知道IndexedDISI在跨Block查找時,需要通過遍歷才能找到對應的Block;其次是在DENSE結構的查找問題,同樣需要在BitSet的所有word,並將統計所有有值doc個數(即1的個數)。

LUCENE-8585是Toke提出來的,意在優化IndexedDISI的讀性能的問題。這個ISSUE主要針對上面提及的兩個遍歷的情況進行優化,通過在索引時構建IndexedDIS的同樣多建一份索引。這是典型的以空間換時間的做法,將搜索時間的下推至構建索引過程。

關於整個優化思路,Toke在ISSUE中有詳細解釋說明,建議可以仔細讀一下。

經LUCENE-8585改進之後IndexedDISI的結構也有比較大改進,它除了原本IndexedDISI部分數據,後面多加JumpTable。在Slice內部也多一個RankEntry,它是在DENSE類型的Slice內部的索引。

1. Jump Table

Jump Table是一個long[],記錄每個Slice的起始位置的索引,避免在查找過程中需要遍歷Slices從而加速Slice定位。Jump Table將原來Slice查找的時間雜度O(n)下降爲O(1)。實際上這種做法在Lucene構建索引過程處處可見,在《Lucene DocValues索引文件詳解》文中介紹Term’s Index的時候就有類似的做法。

雖然JumpEntry只是一個原子類型long,但實際上它代表兩個參數,分別是Slice的文件指針,到此前Jump/Slice共出現多少個DocID。由於long高32位表示Slice的文件指針,低32位表示DocID的個數(Lucene規則每個Segment的文檔數目不能超過32位,也就一個Integer的長度)。

JumpTable是Slices的索引,但與之不同的是Slices中的Slice並不是連續,即當Slice沒有DocID存在的時候(NONE類型),Slices並不記錄。但是JumpTable會記錄它,爲的是能夠通過SliceID作爲JumpTable的下標,擁有訪機訪問的能力。

2. Rank Table

IndexedDISI提供兩個功能,一是驗證DocID存在與否,其次是找到其值的位置(即Index)。對於驗證DocID是否存在是BitSet非常擅長的,效率也極高。關於後面則有些費勁,需遍歷long[],計算Long的64位中幾個1。這複雜過程就留下可優化的空間了。

Rank Table是避免DENSE結構下Index計算的時候需要遍歷long[]而性能消耗而設計的結構,JumpTable是Slice的索引,而Rank Table則是在一個Slice內的索引。

我們知道DENSE類型的存儲結構是BitSet,所以實際在BitSet查找的時間複雜度本來就是O(1),並沒有優化的空間。但我們前面介紹過找到DocID之後,還需要計算DocID對應Value在Values中的位置,這就是Rank Entry優化方向。

一個Slice最多能擁有65536個文檔,即BitSet需要1024個long才能完全存儲(1024 * 64 = 65536)。

Slice擁有固定長度的BitSet,它由1024個long的數組組成,轉換成bit就有65536個bits,這也是BitSet的容量。如果爲BitSet的每個Bit都創建一個索引來存儲其值的Index的話,需要存儲的代價很高。因此Lucene是爲每8個long/512個bit創建一個索引,這是性能和存儲的折中方案。

第一個RankEntry記錄前8個word的所有DocID個數,也統計前8個word出現1的個數。第二個RankEntry記錄前16個word的DocID個數,如此類推。爲此IndexedDISI需要額外花費256個Bytes(每個RankEntry需要2個Bytes才能夠表示)存儲DocID的索引。

RankEntry作法與DocValues的TermsIndex有些相似。

四、結論

簡單說整個LUCENE-8585只做兩做件事情,先是給每個block加索引,然後優化稠密結構的Slice的Index計算。兩者都是爲了提高DocValues隨機訪問的性能。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章