Elasticsearch調優篇 01 - Elasticsearch 倒排索引這一篇足夠了


1、為什么需要倒排索引

  倒排索引,也是索引。

  索引,初衷都是為了快速檢索到你要的數據。

  每種數據庫都有自己要解決的問題(或者說擅長的領域),對應的就有自己的數據結構,而不同的使用場景和數據結構,需要用不同的索引,才能起到最大化加快查詢的目的。

  對 Mysql 來說,是 B+ 樹,對 Elasticsearch/Lucene 來說,是倒排索引。

Elasticsearch 是建立在全文搜索引擎庫 Lucene 基礎上的搜索引擎,它隱藏了 Lucene 的復雜性,取而代之的提供一套簡單一致的 RESTful API,不過掩蓋不了它底層也是 Lucene 的事實。
Elasticsearch 的倒排索引,其實就是 Lucene 的倒排索引。

2、倒排索引內部結構

  首先,在數據生成的時候,比如爬蟲爬到一篇文章,這時我們需要對這篇文章進行分析,將文本拆解成一個個單詞。

  這個過程很復雜,比如 “生存還是死亡”,你要如何讓分詞器自動將它分解為 “生存”、“還是”、“死亡”三個詞語,然后把“還是”這個無意義的詞語干掉。這里不展開,具體涉及到分詞相關知識,后續我會單獨寫一系列分詞相關的文章。

  接着,把這兩個詞語以及它對應的文檔id存下來:

    word documentId

     生存   1

     死亡   1

  接着爬蟲繼續爬,又爬到一個含有“生存”的文檔,於是索引變成:

    word documentId

     生存   1,2

     死亡   1

  下次搜索 “生存”,就會返回文檔ID是 1、2兩份文檔。

  基本原理是這樣的,但是離實際情況還差的很遠。

  想想看,你有上百萬 或 上億 的文檔,分詞后的 word 何其之多,要查找一個詞你要全局遍歷,先不說是否可以遍歷完畢,光內存就放不下這么多東西。

  於是有了排序,我們需要對單詞進行排序,像 B+ 樹一樣,可以在頁里實現二分查找。

  光排序還不行,你單詞都放在磁盤呢,磁盤 IO 慢的不得了,所以 Mysql 特意把索引緩存到了內存。

  還是想我所說的問題一樣,那么多詞都放到內存肯定會爆炸的。

  所以,elasticsearch 其實就是 Lucene 底層存儲如下圖所示:

   

  

我們知道倒排索引是針對 per field 的,一個字段有一個自己的倒排索引。

Term Dictionary

為了能快速找到某個term,將所有的term排個序,二分法查找term,logN的查找效率,就像通過字典查找一樣,這就是Term Dictionary。現在再看起來,似乎和傳統數據庫的方式類似啊,為什么說查詢更快呢?

Term Index

這樣我們可以用二分查找的方式,比全遍歷更快地找出目標的 term。這個就是 term dictionary。有了 term dictionary 之后,可以用 logN 次磁盤查找得到目標。但是磁盤的隨機讀操作仍然是非常昂貴的(一次 random access 大概需要 10ms 的時間)。所以盡量少的讀磁盤,有必要把一些數據緩存到內存里。但是整個 term dictionary 本身又太大了,無法完整地放到內存里。於是就有了 term index。term index 有點像一本字典的大的章節表。

Lucene 的倒排索,增加了最左邊的一層「字典樹」term index,它不存儲所有的單詞,只存儲單詞前綴,通過字典樹找可以很快速的定位到 term dictionary 的某個 offset,也就是單詞的大概位置,然后再在塊里二分查找,找到對應的單詞,再找到單詞對應的文檔列表。

問題:為什么 Elasticsearch/Lucene 檢索可以比 MySQL 快?

  Mysql 只有 term dictionary 這一層,是以 B+樹 的方式存儲在磁盤上的。檢索一個term需要若干次的 random access 的磁盤操作。而 Lucene 在 term dictionary 的基礎上添加了 term index 來加速檢索,term index 以樹的形式緩存在內存中。從 term index 查到對應的 term dictionary 的 block 位置之后,再去磁盤上找 term,大大減少了磁盤的 random access 次數。

  當然,內存寸土寸金,能省則省,所以 term index 在內存中是以 FST(Finite State Transducers)對它進一步壓縮來存儲的。

  值得匯總的精華:

    1. term index 在內存中是以 FST 壓縮存儲的

    2. term dictionary 在磁盤上是以 分 block 的方式存儲的,一個 block 內部利用公共前綴壓縮,例如都是 Ab 開頭的單詞就可以把 Ab 省去。這樣 term dictionary 可以更節約磁盤空間

    3. Posting List 采用 增量編碼壓縮,將大數變小數,按字節按需存儲;且為了更好的求交集,采用動態 bitset 按位交集 和 skiplist 求交優化性能。

3、FST

   lucene從4開始大量使用的數據結構是FST(Finite State Transducer)

  FST有兩個優點:

    1)空間占用小。通過對詞典中單詞前綴和后綴的重復利用,壓縮了存儲空間。使 Term Index 小到可以放進內存,不過相對的也會占用更多的cpu資源。

    2)查詢速度快。O(len(str))的查詢時間復雜度。

     下面簡單描述下FST的構造過程(工具演示:http://examples.mikemccandless.com/fst.py?terms=&cmd=Build+it%21

  我們對“cat”、 “deep”、 “do”、 “dog” 、“dogs”這5個單詞進行插入構建FST(注:必須已排序)

1)插入“cat”

     插入cat,每個字母形成一條邊,其中t邊指向終點。

2)插入“deep”

    與前一個單詞“cat”進行最大前綴匹配,發現沒有匹配則直接插入,P邊指向終點。

3)插入“do”

    與前一個單詞“deep”進行最大前綴匹配,發現是d,則在d邊后增加新邊o,o邊指向終點。

4)插入“dog”

    與前一個單詞“do”進行最大前綴匹配,發現是do,則在o邊后增加新邊g,g邊指向終點。

5)插入“dogs”

     與前一個單詞“dog”進行最大前綴匹配,發現是dog,則在g后增加新邊s,s邊指向終點。

     最終我們得到了如上一個有向無環圖。利用該結構可以很方便的進行查詢,如給定一個term “dog”,我們可以通過上述結構很方便的查詢存不存在,甚至我們在構建過程中可以將單詞與某一數字、單詞進行關聯,從而實現key-value的映射。

  假設給定真實的帶有權重的詞插入:mop/0、moth/1、pop/2、star/3、stop/4、top/5,結果如下所示:

  

 

   上面的字母/數字表示狀態變化和權重,將單詞分成單個字母通過 ⭕️ 和 –> 表示出來,0 權重不顯示。如果 ⭕️ 后面出現分支,就標記權重,最后整條路徑上的權重加起來就是這個單詞對應的序號。FST 以字節的方式存儲所有的 term,這種壓縮方式可以有效的縮減存儲空間,使得 term index 足以放進內存,但這種方式也會導致查找時需要更多的CPU資源。

4、Posting List

  原生的 Posting List 有兩個痛點:

  1.  如何壓縮以節省磁盤空間
  2.  如何快速求取交並集

 4.1、壓縮

  我們來簡化下 Lucene 要面對的問題,假設有這樣一個數組:

  [73, 300, 302, 332, 343, 372]

  如何把它進行盡可能的壓縮?

  Lucene 里,數據是按 Segment 存儲的,每個 Segment 最多存 65536 個文檔 ID, 所以文檔 ID 的范圍,從 0 到 2^16-1,所以如果不進行任何處理,那么每個元素都會占用 2 bytes ,對應上面的數組,就是 6 * 2 = 12 bytes。

  這里要有一個思考:為什么每個塊要以 65536 為界限呢?

  因為它 = 2^16-1,正好是用 2 個字節能表示的最大數,一個 short 的存儲單位。

  怎么壓縮呢?壓縮的原則是什么?

  壓縮,就是盡可能降低每個數據占用的空間,同時又能讓信息不失真,能夠還原回來。

  Setp1:Delta-encode(增量編碼)

我們只記錄元素與元素之間的增量,於是數組變成了:

[73, 227, 2, 30, 11, 29]

  Step2:Split into blocks(分割成塊)

 Lucene里每個塊是 256 個文檔 ID,這樣可以保證每個塊,增量編碼后,每個元素都不會超過 256(1 byte).

為了方便演示,我們假設每個塊是 3 個文檔 ID:

[73, 227, 2], [30, 11, 29]

  Step3:Bit packing(按需分配空間)

對於第一個塊,[73, 227, 2],最大元素是227,需要 8 bits,好,那我給你這個塊的每個元素,都分配 8 bits的空間。

但是對於第二個塊,[30, 11, 29],最大的元素才30,只需要 5 bits,那我就給你每個元素,只分配 5 bits 的空間,足矣。

這一步,可以說是把吝嗇發揮到極致,精打細算,按需分配。

  以上三個步驟,共同組成了一項編碼技術:

    

 4.2、如何快速求交集

  在 Lucene 中查詢,通常不只有一個查詢條件,比如我們想搜索:

  • 含有“生存”相關詞語的文檔
  • 文檔發布時間在最近一個月
  • 文檔發布者是平台的特約作者

  這樣就需要根據三個字段,去三棵倒排索引里去查,當然,磁盤里的數據,上面提到過,用了相關算法進行壓縮,所以我們要把數據進行反向處理,即解壓,才能還原成原始的文檔 ID,然后把這三個文檔 ID 數組在內存中做一個交集。

  注意即使沒有多條件查詢,Lucene 也需要頻繁求交集,因為 Lucene 是分配存儲的。

  同樣,我們把 Lucene 遇到的問題,簡化成一道算法題。

  假設有下面三個數組,求它們的交集:

    [64, 300, 303, 343] 、 [73, 300, 302, 303, 343, 372] 、 [303, 311, 333, 343]

  方案一:Integer 數組

直接用原始的文檔 ID ,可能你會說,那就逐個數組遍歷一遍吧,遍歷完就知道交集是什么了。

其實對於有序的數組,用跳表(skip table)可以更高效,這里就不展開了,因為不管是從性能,還是空間上考慮,Integer 數組都不靠譜,假設有100M 個文檔 ID,每個文檔 ID 占 2 bytes,那已經是 200 MB,而這些數據是要放到內存中進行處理的,把這么大量的數據,從磁盤解壓后丟到內存,內存肯定撐不住。

  方案二:Bitmap

  假設有這樣一個數組:[3,6,7,10]

  那么我們可以這樣來表示:[0,0,1,0,0,1,1,0,0,1],即 3 是在第三個位置標記為 1,6 在第六個位置標記為 1,以此類推......

  看出來了么,對,我們用 0 表示角標對應的數字不存在,用 1 表示存在。

  這樣帶來了兩個好處:

  • 節省空間:既然我們只需要0和1,那每個文檔 ID 就只需要 1 bit,還是假設有 100M 個文檔,那只需要 100M bits = 100M * 1/8 bytes = 12.5 MB,比之前用 Integer 數組 的 200 MB,優秀太多
  • 運算更快:0 和 1,天然就適合進行位運算,求交集,「與」一下,求並集,「或」一下,一切都回歸到計算機的起點

  方案三:Roaring Bitmaps

細心的你可能發現了,bitmap 有個硬傷,就是不管你有多少個文檔,你占用的空間都是一樣的,之前說過,Lucene Posting List 的每個 Segement 最多放 65536 個文檔ID,舉一個極端的例子,有一個數組,里面只有兩個文檔 ID:

  [0, 65535]

  用 Bitmap,要怎么表示?

  [1,0,0,0,….(超級多個0),…,0,0,1]

  你需要 65536 個 bit,也就是 65536/8 = 8192 bytes,而用 Integer 數組,你只需要 2 * 2 bytes = 4 bytes

  呵呵,死板的 bitmap。可見在文檔數量不多的時候,使用 Integer 數組更加節省內存。

  我們來算一下臨界值,很簡單,無論文檔數量多少,bitmap都需要 8192 bytes,而 Integer 數組則和文檔數量成線性相關,每個文檔 ID 占 2 bytes,所以:

  8192 / 2 = 4096

  當文檔數量少於 4096 時,用 Integer 數組,否則,用 bitmap。

 怎么做呢?可以詳細點嗎?

  1. 當使用數組 list 的形式,使用 skiplist 數據結構,同時遍歷 包含生存關鍵詞、最近一個月 和 簽約作者 的 posting list,互相 skip 即可

  2. 當使用 bitmap 數據結構的時候,分別對 同時遍歷 包含生存關鍵詞、最近一個月 和 簽約作者 的 posting list 求出對應的 bitmap,然后直接 按位與 操作即可。

 


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM