ElasticSearch爲什麼檢索快?對比Mysql分析

1. ES一定快嗎?

1.1. 數據庫的索引是B+tree結構

主鍵是聚合索引 其他索引是非聚合索引,見下圖

如果是一般搜索,一般從非聚集索樹上搜索出id,然後再到聚集索引樹上搜索出需要的內容。

 

1.2.  elasticsearch倒排索引原理

Term Index 以樹的形式保存在內存中,運用了FST+壓縮公共前綴方法極大的節省了內存,通過Term Index查詢到Term Dictionary所在的block再去磁盤上找term減少了IO次數

Term Dictionary 排序後通過二分法將檢索的時間複雜度從原來N降低爲logN

1.3.  兩者對比

對於倒排索引,要分兩種情況:

1.3.1. 基於分詞後的全文檢索

這種情況是es的強項,而對於mysql關係型數據庫而言完全是災難

因爲es分詞後,每個字都可以利用FST高速找到倒排索引的位置,並迅速獲取文檔id列表

但是對於mysql檢索中間的詞只能全表掃(如果不是搜頭幾個字符)

1.3.2. 精確檢索

這種情況我想兩種相差不大,有些情況下mysql的可能會更快些

如果mysql的非聚合索引用上了覆蓋索引,無需回表,則速度可能更快

es還是通過FST找到倒排索引的位置並獲取文檔id列表,再根據文檔id獲取文檔並根據相關度算分進行排序,但es還有個殺手鐗,即天然的分佈式使得在大數據量面前可以通過分片降低每個分片的檢索規模,並且可以並行檢索提升效率

用filter時更是可以直接跳過檢索直接走緩存

1.3.3. 多條件查詢

mysql除非使用聯合索引,將每個查詢的字段都建立聯合索引,如果是兩個字段上有兩個不同的索引,那麼mysql將會選擇一個索引使用,然後將得到的結果寫入內存中使用第二個條件過濾後,得到最終的答案

es,可以進行真正的聯合查詢,將兩個字段上查詢出來的結果進行“並”操作或者“與”操作,如果是filter可以使用bitset,如果是非filter使用skip list進行

2. ES如何快速檢索

籠統的來說查詢數據的快慢和什麼有關,首先是查詢算法的優劣,其次是數據傳輸的快慢,以及查詢數據的數據量大小。

一般的機械硬盤的讀性能在100MB/s左右,再不升級高級硬件的情況下,想要優化查詢性能,得從查詢算法以及數據量大小兩個方面做優化

首先看下es的倒排索引結構,從中分析es爲了提高檢索性能做了那些的努力

es會爲每個term做一個倒排索引

docId

年齡

性別

1 18
2 20
3 18

年齡

Posting List

18 【1,3】
20 【2】

性別

Posting List

【1,2】
【3】

可以看到,倒排索引是per field的,一個字段由一個自己的倒排索引。18,20這些叫做 term,而[1,3]就是posting list。Posting list就是一個int的數組,存儲了所有符合某個term的文檔id。

我們可以發現,只要找到term,我們就能找到對應的文檔id集合。類似與字典的功能

那麼問題來了,海量的數據中我們怎麼尋找對應的term?

2.1. Term Index

怎麼實現一個字典呢?我們馬上想到排序數組,即term字典是一個已經按字母順序排序好的數組,數組每一項存放着term和對應的倒排文檔id列表。每次載入索引的時候只要將term數組載入內存,通過二分查找即可。這種方法查詢時間複雜度爲Log(N),N指的是term數目,佔用的空間大小是O(N*str(term))。排序數組的缺點是消耗內存,即需要完整存儲每一個term,當term數目多達上千萬時,佔用的內存將不可接受。

常用的字典數據結構

數據結構

優缺點

排序列表Array/List 使用二分法查找,不平衡
HashMap/TreeMap 性能高,內存消耗大,幾乎是原始數據的三倍
Skip List 跳躍表,可快速查找詞語,在lucene、redis、Hbase等均有實現。相對於TreeMap等結構,特別適合高併發場景
Trie 適合英文詞典,如果系統中存在大量字符串且這些字符串基本沒有公共前綴,則相應的trie樹將非常消耗內存
Double Array Trie 適合做中文詞典,內存佔用小,很多分詞工具均採用此種算法
Ternary Search Tree 三叉樹,每一個node有3個節點,兼具省空間和查詢快的優點
Finite State Transducers (FST) 一種有限狀態轉移機,Lucene 4有開源實現,並大量使用

可以看出在Lucene中使用的是FST,FST有兩個優點:1)空間佔用小。通過對詞典中單詞前綴和後綴的重複利用,壓縮了存儲空間;2)查詢速度快。O(len(str))的查詢時間複雜度。

FST的性能如何

FST壓縮率一般在3倍~20倍之間,相對於TreeMap/HashMap的膨脹3倍,內存節省就有9倍到60倍

數據結構

HashMap

TreeMap

FST

數據量100w(100w次查詢) 12ms

9ms

377ms

性能雖不如TreeMap和HashMap,但也算良好,能夠滿足大部分應用的需求

 

雖然,我們使用了FST壓縮Term,但是在ES中term還是太多,是不可能將全部的term拖入內存中的。Term Index保存的不是完整的term,而是保存term一些前綴,可以通過這個前綴快速的定位到磁盤上的block

這樣,整個term index的大小可以只有所有term的幾十分之一,使得用內存緩存整個term index變成可能

 

2.2. Term Dictionary

通過term index,我們已經可以拿到需要查詢的term的前綴所指向的block地址,進行一次random access(一次random access大概需要10ms的時間)就可以讀取這個block中的信息

剩下我們查詢對應的term只需要遍歷即可,但是如果是海量數據,那麼這個時間複雜度O(N)也不能接受。並且可能所要查詢的term不在這個block上(可能在下塊block上),那麼O(N) 我們將N次random access

爲了提高term Dictionary的查詢效率,term Dictionary是排序好了的結構,這樣我們可以在其做二分查找將時間複雜度降低爲LogN,那麼IO的次數也將大大消耗

上面提到了,提高查詢效率,除了改進算法,還有降低數據量大小,Term dictionary在磁盤上是以分block的方式保存的,一個block內部利用公共前綴壓縮,比如都是Ab開頭的單詞就可以把Ab省去。

每個Block最多存48個Term, 如果相同前綴的Term很多的話,Block會分出一個子Block,很顯然父Block的公共前綴是子Block公共前綴的前綴。

這樣一次讀取磁盤的傳輸數據量大小也將大大降低

這樣我們就根據term得到了文檔id集合,然後通過路由算法去指定分片中查詢內容

3. ES如何做聯合查詢

在MySQL中,除非使用聯合索引,將每個查詢的字段都建立聯合索引,如果是兩個字段上有兩個不同的索引,那麼mysql將會選擇一個索引使用,然後將得到的結果寫入內存中使用第二個條件過濾後,得到最終的答案

而在es中, 可以使用真正的聯合查詢

查詢過濾條件 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 操作。

如果查詢的filter緩存到了內存中,使用bitset形式,否則用skip list形式遍歷在磁盤上的posting list

3.1. Skip List

以上是三個 posting list。我們現在需要把它們用 AND 的關係合併,得出 posting list 的交集。首先選擇最短的posting list,然後從小到大遍歷。遍歷的過程可以跳過一些元素,比如我們遍歷到綠色的13的時候,就可以跳過藍色的3了,因爲3比13要小。

最後得出的交集是 [13,98],所需的時間比完整遍歷三個 posting list 要快得多。但是前提是每個 list 需要指出 Advance 這個操作,快速移動指向的位置。什麼樣的 list 可以這樣 Advance 往前做蛙跳?skip list

但是這裏我們又要考慮一個問題,如果這個posting太長怎麼辦? 

從概念上來說,對於一個很長的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編碼。

比如一個詞對應的文檔id列表爲[73, 300, 302, 332,343, 372] ,id列表首先要從小到大排好序;第一步增量編碼就是從第二個數開始每個數存儲與前一個id的差值,即300-73=227,302-300=2。。。,一直到最後一個數;第二步就是將這些差值放到不同的區塊,Lucene使用256個區塊;第三步位壓縮,計算每組3個數中最大的那個數需要佔用bit位數,比如30、11、29中最大數30最小需要5個bit位存儲,這樣11、29也用5個bit位存儲,這樣才佔用15個bit,不到2個字節,壓縮效果很好

考慮到頻繁出現的term(所謂low cardinality的值),比如gender裏的男或者女。如果有1百萬個文檔,那麼性別爲男的posting list裏就會有50萬個int值。用Frame of Reference編碼進行壓縮可以極大減少磁盤佔用。這個優化對於減少索引尺寸有非常重要的意義。當然mysql b-tree裏也有一個類似的posting list的東西,是未經過這樣壓縮的。

因爲這個Frame of Reference的編碼是有解壓縮成本的。利用skip list,除了跳過了遍歷的成本,也跳過解壓全部這些壓縮過的block的過程,從而節省了cpu(相當於只要解壓skip list查詢到的那些block)

 

3.2. BitSet合併

Frame Of Reference壓縮算法對於倒排表來說效果很好,但對於需要存儲在內存中的Filter緩存等不太合適,兩者之間有很多不同之處:倒排表存儲在磁盤,針對每個詞都需要進行編碼,而Filter等內存緩存只會存儲那些經常使用的數據,而且針對Filter數據的緩存就是爲了加速處理效率,對壓縮算法要求更高。

緩存之前執行結果的目的就是爲了加速響應,本質上是對一系列doc id進行合理的壓縮存儲然後解碼並進行與、或、亦或等邏輯運算

  • 方案一:首先一個簡單的方案是使用數組存儲,這樣每個doc id佔用4個字節,如果有100M個文檔,大約需要400M的內存,比較佔用資源,不是一個很好的方式。
  • 方案二:BitMap位圖方式,用一個bit位(0或者1)來代表一個doc id的存在與否(JDK也有內置的位圖類即BitSet);與數組方式類似,只不過這裏只用1個bit代表一個文檔,節約了存儲(100M bits = 12.5MB),這是一種很好的方式。BitSet進行and,or操作,十分方便容易,位運算即可

但是BitSet有一個缺陷:對於一個比較稀疏的文檔列表就浪費了很多的存儲空間,極端情況段內doc id範圍0~2^31-1,一個位圖就佔用(2^31-1)/8/1024/1024=256M的空間,有壓縮改進的空間

從Lucene 5 開始採用了一種改進的位圖方式,即Roaring BitMaps,它是一個壓縮性能比bitmap更好的位圖實現

  1. 針對每個文檔id,得到其對應的元組,即括號中第一個數爲除以65535的值,第二個數時對65535取餘的值
  2. 按照除以65535的結果將元組劃分到特定的區塊中,示例中有0、2、3三個區塊
  3. 對於每個區塊,如果元素個數大於4096個,採用BitSet編碼,否則對於區塊中每個元素使用2個字節編碼(取餘之後最大值65535使用2個字節即可表示,選擇數組存儲)

爲什麼使用4096作爲一個閾值,經驗證超過4096個數後,使用BitMap方式要比使用數組方式要更高效。

3.2.1. 性能對比

y軸越大,性能越好;x軸越小,文檔越稀疏

3.2.2. 內存佔用

4. ES如何做聚合排序查詢

現在我們想一下,es如何做聚合排序查詢的

首先我們拿mysql做例子,如果沒有建立索引,我們需要全遍歷一份,到內存進行排序,如果有索引,會在索引樹上進行進行範圍查詢(因爲索引是排序了的)

那麼在es中,如果是做排序,lucene會查詢出所有的文檔集合的排序字段,然後再次構建出一個排序好的文檔集合。es是面向海量數據的,這樣一來內存爆掉的可能性是十分大的

我們可以從上面看出,es的倒排索引其實是不利於做排序的。因爲存儲的是字段→文檔id的關係。無法得到需要排序字段的全部值。

爲此。es採用Doc Value解決排序,聚合等操作的處理

4.1. Doc Value

DocValues 是一種按列組織的存儲格式,這種存儲方式降低了隨機讀的成本。傳統的按行存儲是這樣的:

1 和 2 代表的是 docid。顏色代表的是不同的字段。

改成按列存儲是這樣的:

按列存儲的話會把一個文件分成多個文件,每個列一個。對於每個文件,都是按照 docid 排序的。這樣一來,只要知道 docid,就可以計算出這個 docid 在這個文件裏的偏移量。也就是對於每個 docid 需要一次隨機讀操作。

那麼這種排列是如何讓隨機讀更快的呢?祕密在於 Lucene 底層讀取文件的方式是基於 memory mapped byte buffer 的,也就是 mmap。這種文件訪問的方式是由操作系統去緩存這個文件到內存裏。這樣在內存足夠的情況下,訪問文件就相當於訪問內存。那麼隨機讀操作也就不再是磁盤操作了,而是對內存的隨機讀。

那麼爲什麼按行存儲不能用 mmap 的方式呢?因爲按行存儲的方式一個文件裏包含了很多列的數據,這個文件尺寸往往很大,超過了操作系統的文件緩存的大小。而按列存儲的方式把不同列分成了很多文件,可以只緩存用到的那些列,而不讓很少使用的列數據浪費內存。

按列存儲之後,一個列的數據和前面的 posting list 就差不多了。很多應用在 posting list 上的壓縮技術也可以應用到 DocValues 上。這不但減少了文件尺寸,而且提高數據加載的速度。因爲我們知道從磁盤到內存的帶寬是很小的,普通磁盤也就每秒 100MB 的讀速度。利用壓縮,我們可以把數據以壓縮的方式讀取出來,然後在內存裏再進行解壓,從而獲得比讀取原始數據更高的效率。

DocValue將隨機讀取變成了順序讀取,隨機讀的時候也是按照 DocId 排序的。所以如果讀取的 DocId 是緊密相連的,實際上也相當於把隨機讀變成了順序讀了。Random_read(100), Random_read(101), Random_read(102) 就相當於 Scan(100~102) 了。

在es中,因爲分片的存在,數據被拆分成多份,放在不同機器上。但是給用戶體驗卻只有一個庫一樣

對於聚合查詢,其處理是分兩階段完成的:

  • Shard 本地的 Lucene Index 並行計算出局部的聚合結果;
  • 收到所有的 Shard 的局部聚合結果,聚合出最終的聚合結果。

這種兩階段聚合的架構使得每個 shard 不用把原數據返回,而只用返回數據量小得多的聚合結果。這樣極大的減少了網絡帶寬的消耗

 

 

5. 總結

ElasticSearch爲了提高檢索性能,無所不用的壓縮數據,減少磁盤的隨機讀,以及及其苛刻的使用內存,使用各種快速的搜索算法等手段

追求更快的檢索算法 — 倒排,二分

更小的數據傳輸--- 壓縮數據(FST,ROF,公共子前綴壓縮)

更少的磁盤讀取--- 順序讀(DocValue順序讀)

 

 

PS:爲什麼Mysql不使用真正的聯合查詢呢?

第一:MySQL沒有爲每個字段都創建索引,如果是真正的聯合查詢,可能存在A字段有索引,B字段沒有索引,那麼根據B字段查詢時候就是全表掃描了

第二:MySQL的量沒有ES大,ES面向的是海量數據,MySQL做聯合查詢的時候,可能根據A字段得到的數據只有100或者1000行,在執行B字段過濾,效率很高。但是ES可能經過A字段的搜索有1千萬,2千萬的數據,執行遍歷的過濾效率底下。

 

每一種技術實現方案都有存在的意義。合理的思考纔是最重要的

 

 

如果您覺得這篇文章對您有用,歡迎各位關注我的公衆號【Java程序喵】

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