Elasticsearch 底層數據結構


Elasticsearch 底層數據結構

介紹

最近組內做了個ES底層數據結構的分享,遂記錄之。

基本概念

Elasticsearch is a highly scalable open-source full-text search and analytics engine. It allows you to store, search, and analyze big volumes of data quickly and in near real time. It is generally used as the underlying engine/technology that powers applications that have complex search features and requirements.

Elasticsearch 是一個高可擴展、開源、全文本的搜索與數據分析引擎。它使您可以近實時地快速存儲、搜索和分析大規模數據。它通常用作支持具有復雜搜索功能和要求的應用程序的底層引擎/技術。

Elasticsearch 是 面向文檔 的,意味着它存儲整個對象或 文檔。Elasticsearch 不僅存儲文檔,而且 索引 每個文檔的內容,使之可以被檢索。在 Elasticsearch 中,我們對文檔進行索引、檢索、排序和過濾—而不是對行列數據。這是一種完全不同的思考數據的方式,也是 Elasticsearch 能支持復雜全文檢索的原因。Elasticsearch 使用 JavaScript Object Notation(或者 JSON)作為文檔的序列化格式。

下面這個 JSON 文檔代表了一個 user 對象:

 {
     "name":      "John",
     "age":       25,
     "sex":       "Male",
     "info": {
         "bio":         "Eco-warrior and defender of the weak",
         "tel":         18612344321,
         "interests": [ "dolphins", "whales" ]
    },
     "join_date": "2014/05/01"
 }

用Mysql這樣的數據庫存儲就會容易想到建立一張User表,有balabala的字段等,在Elasticsearch里這就是一個文檔,當然這個文檔會屬於一個User的類型,各種各樣的類型存在於一個索引當中。這里有一份簡易的將Elasticsearch和關系型數據術語對照表:

一個 Elasticsearch 集群可以包含多個索引(數據庫),也就是說其中包含了很多類型(表)。這些類型中包含了很多的文檔(行),然后每個文檔中又包含了很多的字段(列)。

倒排索引

Elasticsearch最關鍵的就是提供強大的索引能力,為了提高搜索的性能,Elasticsearch 使用一種稱為 倒排索引 的結構,它適用於快速的全文搜索。一個倒排索引由文檔中所有不重復詞的列表構成,對於其中每個詞,有一個包含它的文檔列表。

繼續上面的例子,假設有這么幾條數據(為了簡單,去掉info, join_date這兩個field:

ID Name Age Sex
1 Kate 24 Female
2 John 24 Male
3 Bill 29 Male

ID是Elasticsearch自建的文檔id,那么Elasticsearch建立的索引如下:

Name:

Term Posting List
Kate 1
John 2
Bill 3

Age:

Term Posting List
24 [1, 2]
29 3

Sex:

Term Posting List
Female 1
Male [2, 4]

Posting list

Elasticsearch分別為每個field都建立了一個倒排索引,一個字段有一個自己的倒排索引。Kate, John, 24, Female這些叫term,而[1,2]就是Posting List。Posting list就是一個int的數組,存儲了所有符合某個term的文檔id。

那么什么是 term dictionary 和 term index?

Term Dictionary

假設我們有很多個 term,比如:

Carla,Sara,Elin,Ada,Patty,Kate,Selena

如果按照這樣的順序排列,找出某個特定的 term 一定很慢,因為 term 沒有排序,需要全部過濾一遍才能找出特定的 term。排序之后就變成了:

Ada,Carla,Elin,Kate,Patty,Sara,Selena

這樣我們可以用二分查找的方式,比全遍歷更快地找出目標的 term。這個就是 term dictionary。

有了 term dictionary 之后,可以用 logN 次磁盤查找得到目標。但是磁盤的隨機讀操作仍然是非常昂貴的(一次 random access 大概需要 10ms 的時間)。所以為了盡量少的讀磁盤,有必要把一些數據緩存到內存里。但是整個 term dictionary 本身又太大了,無法完整地放到內存里。於是就有了 term index。

Term index

term index 有點像一本字典的大的章節表。比如:

A 開頭的 term ……………. Xxx 頁

C 開頭的 term ……………. Xxx 頁

E 開頭的 term ……………. Xxx 頁

如果所有的 term 都是英文字符的話,可能這個 term index 就真的是 26 個英文字符表構成的了。但是實際的情況是,term 未必都是英文字符,term 可以是任意的 byte 數組。而且 26 個英文字符也未必是每一個字符都有均等的 term,比如 x 字符開頭的 term 可能一個都沒有,而 s 開頭的 term 又特別多。實際的 term index 是一棵 trie 樹:

img

例子是一個包含 “A”, “to”, “tea”, “ted”, “ten”, “i”, “in”, 和 “inn” 的 trie 樹。這棵樹不會包含所有的 term,它包含的是 term 的一些前綴。通過 term index 可以快速地定位到 term dictionary 的某個 offset,然后從這個位置再往后順序查找。再加上一些壓縮技術(搜索 Lucene Finite State Transducers) term index 的尺寸可以只有所有 term 的尺寸的幾十分之一,使得用內存緩存整個 term index 變成可能。

整體上來說就是這樣的效果:

img

現在我們可以回答“為什么 Elasticsearch/Lucene 檢索可以比 Mysql 快了。Mysql 只有 term dictionary 這一層,是以 b-tree 排序的方式存儲在磁盤上的。檢索一個 term 需要若干次的 random access 的磁盤操作。而 Lucene 在 term dictionary 的基礎上添加了 term index 來加速檢索,term index 以樹的形式緩存在內存中。從 term index 查到對應的 term dictionary 的 block 位置之后,再去磁盤上找 term,大大減少了磁盤的 random access 次數。

term index 在內存中是以 FST(finite state transducers)的形式保存的,其特點是非常節省內存。Term dictionary 在磁盤上是以分 block 的方式保存的,一個 block 內部利用公共前綴壓縮,比如都是 Ab 開頭的單詞就可以把 Ab 省去。這樣 term dictionary 可以比 b-tree 更節約磁盤空間。

Weighted Finite-State Transducer

前面我們說到,term index 在內存中是以 FST(finite state transducers)的形式保存的,那到底FST是什么呢?

FSM(Finite State Machines)有限狀態機: 表示有限個狀態(State)集合以及這些狀態之間轉移和動作的數學模型。其中一個狀態被標記為開始狀態,0個或更多的狀態被標記為final狀態。一個FSM同一時間只處於1個狀態。

FST有兩個優點:

1)空間占用小。通過對詞典中單詞前綴和后綴的重復利用,壓縮了存儲空間;

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

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

我們假設創建以下一組映射:Key → Value

 “cat”  - > 5,
 “deep” - > 10,
 “do”   - > 15
 “dog”  - > 2,
 “dogs” - > 8,  

對於經典FST算法來說,要求Key必須按字典序從小到大加入到FST中,原因主要是因為在處理大數據的情況下,我們不太可能把整個FST數據結構都同時放在內存中,而是要邊建圖邊將建好的圖存儲在外部文件中,以便節省內存。所以我們第一步要對所有的Key排序,對於我給這個例子來說,已經保證了字典序的順序。

根據此例子的輸入我們可以建立下圖所示的FST: 

從上圖可以看出,每條邊有兩個屬性,一個表示label(key的元素),另一個表示Value(out)。注意Value不一定是數字,還可一是另一個字符串,但要求Value必須滿足疊加性,如這里的正整數2 + 8 = 10。字符串的疊加行為: aa + b = aab。

建完這個圖之后,我們就可以很容易的查找出任意一個key的Value了。例如:查找dog,我們查找的路徑為:0 → 4 → 8 → 9。 其權值和為: 2 + 0 + 0 + 0 = 2。其中最后一個零表示 node[9].finalOut = 0。所以“dog”的Value為2。

到這里,我們已經對FST有了一個感性的認識,下面我們詳細討論FST的建圖過程:

 1,   建一個空節點,表示FST的入口,所有的Key都從這個入口開始。
 2, 如果還有未處理的Key,則枚舉Key的每一個label。
  處理流程如下:
  如果當前節點存在含此label的邊,則
  如果Value包含該邊的out值,則
  Value = Value – out
  否則
  令temp=out–Value;
  out =Value並使下一個節點的所有邊out都加上temp。
  如果下一節點是Final節點 則FinalOut += temp
  進入下一個節點
  否則: 新建一個節點另其out = Value, Value = 0。

如果你看不懂,沒關系,我們將用例子演示一遍概算法:

 

 

 

 

 

BKD-Tree

 


之前我們介紹的都是對term進行壓縮的方法,但是對於 posting list 也需要壓縮。

比如說對於最開始的例子,如果Elasticsearch需要對同學的性別進行索引(這時傳統關系型數據庫已經哭暈在廁所……),會怎樣?如果有上千萬個同學,而世界上只有男/女這樣兩個性別,每個posting list都會有至少百萬個文檔id。

Frame Of Reference

因此為了能夠有效地計算交集和並集,我們需要對這些 posting list 進行排序。這個決定的一個很好的副作用是可以使用增量編碼(delta-encoding)壓縮 posting list.

例如,假設 posting list 是 [73, 300, 302, 332, 343, 372],則增量列表將是 [73, 227, 2, 30, 11, 29]。這里有趣的是,所有的增量都在 0 到 255 之間,所以每個值只需要一個字節。這是 Lucene 用於在磁盤上編碼倒排索引的技術:posting list 被分成包含 256 個文檔 ID 的塊,然后使用增量編碼和位打包(bit packing)分別壓縮每個塊:Lucene 計算最大位數需要在塊中存儲增量,將此信息添加到塊頭,然后使用此位數對塊的所有增量進行編碼。這種編碼技術在文獻中被稱為Frame Of Reference (FOR),從 Lucene 4.1 開始使用。

這是一個塊大小為 3(而不是實際中的 256)的示例:

img

 step 1:對posting list進行壓縮時進行了正序排序。
 step 2:切分成blocks。具體是怎么做的呢?Lucene是規定每個block是256個delta,這里為了簡化一下,搞成3個delta。
 step 3:看下每個block最大的delta是多少。上圖的第一個block,最大的delta是227,最接近的2次冪是256(8bits),於是規定這個block里都用8bits來編碼(看綠色的header就是8),第二個block,最大的delta是30,最接近的2次冪是32(5bits),於是規定這個block里都用5bit來編碼(看綠色的header就是5)

 

很多文章沒有說清楚 bitmap 和 roaring bitmaps 跟 frame of reference 有什么區別,應用場景是什么,這里說明一下。

首先需要明白過濾情況(filtering context)和查詢情況(query context)的區別。

Elasticsearch 使用的查詢語言(DSL)擁有一套查詢組件,這些組件可以以無限組合的方式進行搭配。這套組件可以在以下兩種情況下使用:過濾情況(filtering context)和查詢情況(query context)。

當使用於 過濾情況 時,查詢被設置成一個“不評分”或者“過濾”查詢。即,這個查詢只是簡單的問一個問題:“這篇文檔是否匹配?”。回答也是非常的簡單,yes 或者 no ,二者必居其一。

當使用於 查詢情況 時,查詢就變成了一個“評分”的查詢。和不評分的查詢類似,也要去判斷這個文檔是否匹配,同時它還需要判斷這個文檔匹配的有 多好(匹配程度如何)。

過濾查詢(Filtering queries)只是簡單的檢查包含或者排除,這就使得計算起來非常快。考慮到至少有一個過濾查詢(filtering query)的結果是 “稀少的”(很少匹配的文檔),並且經常使用不評分查詢(non-scoring queries),結果會被緩存到內存中以便快速讀取,所以有各種各樣的手段來優化查詢結果。

相反,評分查詢(scoring queries)不僅僅要找出匹配的文檔,還要計算每個匹配文檔的相關性,計算相關性使得它們比不評分查詢費力的多。同時,查詢結果並不緩存。

過濾(filtering)的目標是減少那些需要通過評分查詢(scoring queries)進行檢查的文檔。

由於我們只緩存常用的過濾器,壓縮率並不像倒排索引那么重要,倒排索引需要為每個可能的詞條編碼匹配文檔。但是,我們需要緩存過濾器比重新執行過濾器更快,因此使用良好的數據結構很重要。

很長一段時間以來,Lucene 一直使用 bitmap 來將過濾器緩存到內存中。然而,在 Lucene 5 中,我們切換到 Daniel Lemire 的 roaring bitmaps

綜上,cached filters是保存在內存的,倒排索引是典型的保存在磁盤的。

Bitmap

Bitmap是一種數據結構,假設有某個posting list:

[1,3,4,7,10]

對應的bitmap就是:

[1,0,1,1,0,0,1,0,0,1]

非常直觀,用0/1表示某個值是否存在,比如10這個值就對應第10位,對應的bit值是1,這樣用一個字節就可以代表8個文檔id,舊版本(5.0之前)的Lucene就是用這樣的方式來壓縮的,但這樣的壓縮方式仍然不夠高效,如果有1億個文檔,那么需要12.5MB的存儲空間,這僅僅是對應一個索引字段(我們往往會有很多個索引字段)。於是有人想出了Roaring bitmaps這樣更高效的數據結構。

Bitmap的缺點是存儲空間隨着文檔個數線性增長。

Roaring bitmaps

它首先根據 16 個最高位將發布列表分成塊。 這意味着,例如,第一個塊將編碼介於 0 和 65535 之間的值,第二個塊將編碼介於 65536 和 131071 之間的值,等等。然后在每個塊中我們獨立編碼最低的 16 位:如果每塊的個數少於 4096,將使用數組表示每個數字,否則使用bitmap。 在此階段需要注意的重要一點是,雖然我們過去使用上述數組編碼每個值需要 4 個字節,但這里的數組只需要為每個值存儲 2 個字節,因為塊 ID 隱含地為我們提供了 16 個最高位。

img

注意:如果一塊超過了4096 個值,直接用bitset存,2個字節就用個簡單的數組存放好了,比如short[]。

為什么它使用 4096 作為閾值? 僅僅因為這個塊中的文檔數量超過這個數,位圖變得比數組更節省內存:

img

這就是 roaring bitmaps 有趣的原因:它們基於兩種具有非常不同的壓縮特性的快速編碼技術,並根據內存效率動態決定使用哪種。

Roaring bit maps 有很多特性,但在 Lucene 的上下文中,真正讓我們感興趣的只有兩個:

  1. 迭代所有匹配的文檔。如果您在緩存過濾器上運行 constant_score 查詢,則通常會使用它。

  2. 前進到集合中包含的第一個大於或等於給定整數的文檔 ID。如果您將過濾器與查詢相交,這通常會被使用。

聯合索引

所以給定查詢過濾條件 age=18 的過程就是先從 term index 找到 18 在 term dictionary 的大概位置,然后再從 term dictionary 里精確地找到 18 這個 term,然后得到一個 posting list 或者一個指向 posting list 位置的指針。然后再查詢 gender= 女 的過程也是類似的。最后得出 age=18 AND gender= 女 就是把兩個 posting list 做一個“與”的合並。

這個理論上的“與”合並的操作可不容易。對於 mysql 來說,如果你給 age 和 gender 兩個字段都建立了索引,查詢的時候只會選擇其中最 selective 的來用,然后另外一個條件是在遍歷行的過程中在內存中計算之后過濾掉。那么要如何才能聯合使用兩個索引呢?有兩種辦法:

  • 使用 skip list 數據結構。同時遍歷 gender 和 age 的 posting list,互相 skip;

  • 使用 bitset 數據結構,對 gender 和 age 兩個 filter 分別求出 bitset,對兩個 bitset 做 AN 操作。

PostgreSQL 從 8.4 版本開始支持通過 bitmap 聯合使用兩個索引,就是利用了 bitset 數據結構來做到的。當然一些商業的關系型數據庫也支持類似的聯合索引的功能。Elasticsearch 支持以上兩種的聯合索引方式,如果查詢的 filter 緩存到了內存中(以 bitset 的形式),那么合並就是兩個 bitset 的 AND。如果查詢的 filter 沒有緩存,那么就用 skip list 的方式去遍歷兩個 on disk 的 posting list。

Skip list

跳躍表具有以下性質:

  1. 由多層有序鏈表組成。

  2. 最底層Level 1的鏈表包含所有的其他鏈表的元素。

  3. 如果一個元素在鏈表Level n中存在,那么他在Level n以下的所有鏈表中都存在。

  4. 每個節點都包含連個指針,分別指同Level鏈表的下一個元素和下一層的元素。

img

從概念上來說,對於一個很長的 posting list,比如:

[1,3,13,101,105,108,255,256,257]

我們可以把這個 list 分成三個 block:

[1,3,13] [101,105,108] [255,256,257]

然后可以構建出 skip list 的第二層:

[1,101,255]

1,101,255 分別指向自己對應的 block。這樣就可以很快地跨 block 的移動指向位置了。

Lucene 自然會對這個 block 再次進行壓縮。其壓縮方式就是之前介紹的 Frame Of Reference 編碼。

利用 Bitset 合並

Bitset 是一種很直觀的數據結構,對應 posting list 如:

[1,3,4,7,10]

對應的 bitset 就是:

[1,0,1,1,0,0,1,0,0,1]

每個文檔按照文檔 id 排序對應其中的一個 bit。Bitset 自身就有壓縮的特點,其用一個 byte 就可以代表 8 個文檔。所以 100 萬個文檔只需要 12.5 萬個 byte。但是考慮到文檔可能有數十億之多,在內存里保存 bitset 仍然是很奢侈的事情。而且對於個每一個 filter 都要消耗一個 bitset,比如 age=18 緩存起來的話是一個 bitset,18<=age<25 是另外一個 filter 緩存起來也要一個 bitset。

所以秘訣就在於需要有一個數據結構:

  • 可以很壓縮地保存上億個 bit 代表對應的文檔是否匹配 filter;

  • 這個壓縮的 bitset 仍然可以很快地進行 AND 和 OR 的邏輯操作。

Lucene 使用的這個數據結構就是之前介紹的 Roaring Bitmap。

如何減少文檔數?

一種常見的壓縮存儲時間序列的方式是把多個數據點合並成一行。Opentsdb支持海量數據的一個絕招就是定期把很多行數據合並成一行,這個過程叫compaction。類似的vivdcortext使用mysql存儲的時候,也把一分鍾的很多數據點合並存儲到mysql的一行里以減少行數。

這個過程可以示例如下:

12:05:00 10
12:05:01 15
12:05:02 14
12:05:03 16

合並之后就變成了:

12:05 10 15 14 16

可以看到,行變成了列了。每一列可以代表這一分鍾內一秒的數據。

Elasticsearch有一個功能可以實現類似的優化效果,那就是Nested Document。我們可以把一段時間的很多個數據點打包存儲到一個父文檔里,變成其嵌套的子文檔。示例如下:

 {timestamp:12:05:01, idc:sz, value1:10,value2:11}
 {timestamp:12:05:02, idc:sz, value1:9,value2:9}
 {timestamp:12:05:02, idc:sz, value1:18,value:17}

可以打包成:

 {
 max_timestamp:12:05:02, min_timestamp: 1205:01, idc:sz,
 records: [
  {timestamp:12:05:01, value1:10,value2:11}
  {timestamp:12:05:02, value1:9,value2:9}
  {timestamp:12:05:02, value1:18,value:17}
  ]
 }

這樣可以把數據點公共的維度字段上移到父文檔里,而不用在每個子文檔里重復存儲,從而減少索引的尺寸。

img

在存儲的時候,無論父文檔還是子文檔,對於 Lucene 來說都是文檔,都會有文檔 Id。但是對於嵌套文檔來說,可以保存起子文檔和父文檔的文檔 id 是連續的,而且父文檔總是最后一個。有這樣一個排序性作為保障,那么有一個所有父文檔的 posting list 就可以跟蹤所有的父子關系。也可以很容易地在父子文檔 id 之間做轉換。把父子關系也理解為一個 filter,那么查詢時檢索的時候不過是又 AND 了另外一個 filter 而已。前面我們已經看到了 Elasticsearch 可以非常高效地處理多 filter 的情況,充分利用底層的索引。

使用了嵌套文檔之后,對於 term 的 posting list 只需要保存父文檔的 doc id 就可以了,可以比保存所有的數據點的 doc id 要少很多。如果我們可以在一個父文檔里塞入 50 個嵌套文檔,那么 posting list 可以變成之前的 1/50。

總結

綜上,ES底層數據結構主要就是針對 term 和 posting list 進行壓縮處理,使搜索速度更快。

You know, for search...

 

參考鏈接

1. https://zhuanlan.zhihu.com/p/285251157
2. https://blog.csdn.net/yizishou/article/details/78342499
3. https://blog.csdn.net/tonywu1992/article/details/104746214/?utm_medium=distribute.pc_relevant.none-task-blog-2~default~baidujs_baidulandingword~default-0.control&spm=1001.2101.3001.4242
4. https://www.infoq.cn/article/database-timestamp-02/?utm_source=infoq&utm_medium=related_content_link&utm_campaign=relatedContent_articles_clk
5. https://blog.csdn.net/hdm314/article/details/107862471?utm_medium=distribute.pc_relevant.none-task-blog-2~default~baidujs_baidulandingword~default-1.essearch_pc_relevant&spm=1001.2101.3001.4242
6. https://blog.csdn.net/qq_36289377/article/details/82993160
7. https://cloud.tencent.com/developer/article/1586257?from=article.detail.1730204
8. https://cs.nyu.edu/~mohri/pub/fla.pdf
9. https://www.elastic.co/cn/blog/frame-of-reference-and-roaring-bitmaps


免責聲明!

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



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