Elasticsearch 中為什麼選擇倒排索引而不選擇 B 樹索引

雙子孤狼發表於2021-10-26

前言

索引可能大家都不陌生,在用關係型資料庫時,一些頻繁用作查詢條件的欄位我們都會去建立索引來提升查詢效率。在關係型資料庫中,我們一般都採用 B 樹索引進行儲存,所以 B 樹索引也是我們接觸比較多的一種索引資料結構,然而在 es 中,進行全文搜尋的時候卻並沒有選擇使用 B 樹 索引,而是採用的倒排索引。本文就讓我們來看看 es 中的倒排索引是如何儲存和檢索的吧。

為什麼全文索引不使用 B+ 樹進行儲存

關係型資料庫,如 MySQL,其選擇的是 B+ 樹索引,如下圖就是一顆簡單的的 B+ 樹示例:

上圖中藍色的表示索引值,白色的表示指標,最底層葉子節點除了儲存索引值還會儲存整條資料(InnoDB 引擎),而根節點和枝節點不會儲存資料,B+ 樹之所以這麼設計就是為了使得根節點和枝節點能夠儲存更多的節點,因為搜尋的時候從根節點開始搜尋,每查詢一個節點就是一次 IO 操作,所以一個節點能儲存更多的索引值能減少磁碟 IO 次數。

如果有想更詳細瞭解 B+ 樹的,可以點選這裡

那麼到這裡我們就可以思考這個問題了,假如索引值本身就很大,那麼 B+ 樹是不是效能會急劇下降呢?答案是肯定的,因為當索引值很大的話,一個節點能儲存的資料會大大減少(一個節點預設是 16kb 大小),B+ 樹就會變得更深,每次查詢資料所需要的 IO 次數也會更多。而且全文索引就是需要支援對大文字進行索引的,從空間上來說 B+ 樹不適合作為全文索引,同時 B+ 樹因為每次搜尋都是從根節點開始往下搜尋,所以會遵循最左匹配原則,而我們使用全文搜尋時,往往不會遵循最左匹配原則,所以可能會導致索引失效。

總結起來 B+ 樹不適合作為全文搜尋索引主要有以下兩個原因:

  • 全文索引的文字欄位通常會比較長,索引值本身會佔用較大空間,從而會加大 B+ 樹的深度,影響查詢效率。
  • 全文索引往往需要全文搜尋,不遵循最左匹配原則,使用 B+ 樹可能導致索引失效。

全文檢索

在全文檢索當中,我們需要對文件進行切詞處理,切好之後再將切出來的詞和文件進行關聯,並進行索引,那麼這時候我們應該如何儲存關鍵字和文件的對應關係呢?

正排索引

可能大家都知道,在全文檢索中(比如:Elasticsearch)用的是倒排索引,那麼既然有倒排索引,自然就有正排索引。

正排索引又稱之為前向索引(forward index)。我們以一篇文件為例,那麼正排索引可以理解成他是用文件 id 作為索引關鍵字,同時記錄了這篇文件中有哪些詞(經過分詞器處理),每個詞出現的次數已經每個詞在文件中的位置。

但是我們平常在搜尋的時候,都是輸入一個詞然後要得到文件,所以很顯然,正排索引並不適合於做這種查詢,所以一般我們的全文檢索用的都是倒排索引,但是倒排索引卻並不適合用於聚合運算,所以其實在 es 中的聚合運算用的是正排索引。

倒排索引

倒排索引又稱之為反向索引(inverted index)。和正排索引相反,倒排索引使用的是詞來作為索引關鍵字,並同時記錄了哪些文件中有這個詞。

在這裡我們以一個英文文件為例子,之所以選擇用英文文件是因為英文分詞比較簡單,直接以空格進行分詞即可,而中文分詞相對比較複雜。

我們以 Elasticsearch 官網中下面兩句話作為兩位文件來分析:

Elasticsearch is the distributed search and analytics engine at the heart of the Elastic Stack.
Elasticsearch provides near real-time search and analytics for all types of data.

根據上面兩句話,假設我們可以得到下面這樣的一個索引結構:

term index term dictionary Posting list TF
term 索引 elasticsearch [1,2]
term 索引 search [1,2]
term 索引 elastic [1]
term 索引 provides [2]

其中:

  • term index:顧名思議,這個是為 term(經過分詞後的每個詞) 建立的索引,也就是通過這個索引可以快速找到當前 term 的位置,從而找到對應的 Posting list。因為在 es 中,會為每個欄位都建立索引(預設儲存在記憶體中),所以當我們的資料量非常大的時候,就需要能快速定位到這個詞對應的索引所在的記憶體位置,所以就單獨為每個 term 建立了索引,這個索引一般可以選擇雜湊表或者 B+ 樹進行索引儲存。
  • term dictionary:記錄了文件中去重後的所有詞(經過分詞器處理)。
  • Posting list TF:記錄了含有當前詞的文件以及當前詞出現在文件的位置(偏移量),該項資訊是一個陣列,上面表格中為了簡單隻列舉了文件 id,實際上這裡會儲存很多資訊。

這時候假如我們搜尋 Elasticsearch Elastic 這樣的關鍵字,那麼會經過以下步驟:

  1. 對輸入的關鍵字進行分詞處理,得到兩個詞:elasticsearchelastic(經過分詞器之後大寫字母都會轉化成小寫字母)。

  1. 然後分別用這兩個詞進行搜尋,搜尋之後,發現 elasticsearch 在兩個文件中都有出現,而 elastic 只在文件一中出現。
  2. 最終的搜尋結果就是文件一和文件二都返回,但是因為文件一兩個詞都命中了,所以相關度(分數)更高,於是文件一會排在文件二前面,這就是算分的過程。不過需要注意的是,實際的這種相關度分數演算法不會這麼簡單,而是有專門的演算法來計算,命中詞多的並不一定會出現在前面。

倒排索引如何儲存資料

知道了倒排索引的搜尋過程,那麼倒排索引的資料又是如何儲存的呢?

回答這個問題之前我們先來看另一個問題,那就是建立索引的目的是什麼?最直接的目的肯定是為了加快檢索速度,而為了達到這個目的,那麼在不考慮其他因素的情況下,必然是需要佔用的空間越少越好,而為了減少佔用空間,可能就需要壓縮之後再進行儲存,而壓縮之後又涉及到解壓縮,所以採用的壓縮演算法也需要能達到快速壓縮和解壓的目的。

FOR 壓縮

FOR 壓縮演算法即 Frame Of Reference。這種演算法比較簡單,也有一定的侷限性,因為其對儲存的文件 id 有一定要求。

假設現在有一億個文件,對應的文件 id 就是從 1 開始自增。假設現在關鍵字 elasticsearch 存在於 1000W 個文件中,而這 1000W 個文件恰好就是從 11000W,那麼假如不採用任何壓縮演算法,直接進行儲存需要佔用多少空間?

int 型別佔用了 4 個位元組,而 1000W 這個數量級需要 224 次方,也就是說如果用二進位制來儲存,在不考慮符號位的情況下也需要 24bit 才能儲存,而因為 Posting list TF 是一個陣列,所以為了能解析出資料,文件 id=1 的資料也需要用 24bit 來進行儲存,這樣就會極大的浪費了空間。

為了解決這個問題,我們就需要使用 FOR 演算法,FOR 演算法並不直接儲存文件 id,而是儲存差值,像這種這麼規律的文件 id,差值都是 1,而 1 轉成二進位制就可以只使用 1bit 進行儲存,這樣就只需要 1000Wbit 的空間來進行儲存就夠了,相比較直接儲存原始文件 id 的情況下,這種場景採用 FOR 演算法大大減少了空間。

上面舉的這個例子是比較理想的情況,然而實際上這種概率是比較小的,那我們再來看下面這一組文件 id

1,9,15,45,68,323,457

這個陣列計算差值後得到下面這個陣列:

8,6,30,23,255,134

這個時候如果還是直接用普通差值的演算法,雖然也能節省空間,但是卻並不是最優的一種解決方案,那麼這個時候有沒有一種更高效的方法來進行儲存呢?

我們觀察下這個差值陣列,發現這個陣列可以進一步拆分成兩組:

  • [8,6,30,23]:這一組最大值為 30,只需要 5 個位元就能進行儲存。
  • [255,134]:這一組最大值為 255,需要 8 個位元就能儲存。

這麼拆分之後,原始資料需要用 32*7=224 個位元(原始資料直接用 int 儲存),普通差值需要 8*6=48 個位元,而經過分組差值拆分之後只需要 5*4+8*2=36 個位元,進一步壓縮了空間,這種優勢隨著資料量的增加會更加明顯。

但是不管採用哪種方案都有一個問題,那就是進行差值或者拆分之後,怎麼還原資料,解壓的時候怎麼知道差值陣列內的元素佔用空間大小?

所以對每一個資料,還需要一塊一個位元組的空間大小來儲存當前陣列內元素佔用的位元數,所以分組並不是越細越好,假如對每一個差值元素都單獨儲存,那麼反而會比不分組更浪費空間,反之,如果每個分組內的元素足夠多,那麼儲存佔用空間的這一個位元組反帶來的影響就會更小或者忽略不計。

RBM 壓縮

上面例子中介紹的差值都不會大相徑庭,那麼假如我們差值計算之後得到的陣列,其每個元素差別都很大呢?比如說下面這個文件 id 陣列:

1000,62101,131385,132052,191173,196658

這個陣列大家可以去計算一下差值,計算之後會發現一個大一個小,兩個差值之間差距很大,所以這種方式就不適合於用 FOR 壓縮,所以我們就需要有另外的壓縮演算法來提升效率,這就是 RBM 壓縮。

RBM 壓縮演算法即 Roaring Bitmap,是在 2016 年由 S. Chambi、D. Lemire、O. Kaser 等人在論文《Better bitmap performance with Roaring bitmaps》《Consistently faster and smaller compressed bitmaps with Roaring》中提出來的。

RBM 壓縮演算法的核心思想是:將 32 位無符號整數按照高 16 位進行劃分容器,即最多可能有 65536container。因為 65536 實際上就是 216 次方,而一個無符號 int 型別正好是需要 32 位進行儲存,劃分為高低位正好兩邊都是 16 位,也就是最多 65536 個。

劃分之後根據高 16 位去找 container(比如高 16 位計算的結果是 1 就去找 container_12 就去找 container_2,依次類推),找到之後如果發現容器不存在,那麼就會新建一個容器,並且把低 16 位存入容器內,如果容器存在,就直接將低 16 位存入容器。

這樣就會出現一個現象:那就是容器最多有 65536 個,而每個容器內的元素也恰好最多是 65536 個元素

也就是上面的陣列經過計算就會得到以下容器(container_1 沒有元素):

如果說大家覺得上面的高低 16 位不好理解,那麼可以這麼理解,我們把陣列中的元素全部除以 65536,對其取模,每得到一個模就建立一個容器,而其餘數就放入對應的模所對應的容器中。因為一個 int 型別就是 232 次方,正好是 65536 的平方。

經過運算之後得到容器,那麼容器中的元素又該如何進行儲存呢?可以選擇直接儲存,也可以選擇其他更高效的儲存方式。在 RBM 演算法中,總共有三種容器型別,分別採用不同的方法來儲存容器中的元素:

  • ArrayContainer

ArrayContainer 採用 short 陣列來進行儲存,因為每個容器中的元素最大值就是 65535,採用 2 個位元組進行儲存。這種儲存方式的特點是隨著元素個數的增多,所需空間會一直增大。

  • BitmapContainer

BitmapContainer 採用點陣圖的方式進行儲存,也就是固定建立一個 65536 長度的容器,容器中每個元素只用一個位元進行儲存,某一個位置有元素則儲存 1,沒有元素則儲存為 0。這種儲存方式的特點是空間固定就是佔用 65536 個位元,也就是大小固定為 8kb

  • RunContainer

RunContainer 比較特殊,在特定場景下會使用,比如文件 id1-100 是連續的,那麼採用這種容器就可以直接存 1,99,表示 1 後面有 99 個連續的數字,再比如 1,2,3,4,5,6,10,11,12,13 可以被壓縮為 1,5,10,3,表示 1 後面有 5 個連續數字,10 後面有 3 個連續數字。

至於每次儲存採用什麼容器,需要進行一下判定,比如 ArrayContainer,當儲存的元素少於 4096 個時,他會比 BitmapContainer 佔用更少空間,而當大於 4096 個元素時,採用 ArrayContainer 所需要的空間就會大於 8kb,那麼採用 BitmapContainer 就會佔用更少空間。

倒排索引如何儲存

前面我們講了 es 中的倒排索引採用的是什麼壓縮演算法進行壓縮,那麼壓縮之後的資料是如何落地到磁碟的呢?採用的是什麼資料結構呢?

字典樹(Tria Tree)

字典樹又稱之為字首樹(Prefix Tree),是一種雜湊樹的變種,可以用於搜尋時的自動補全、拼寫檢查、最長字首匹配等。

字典樹有以下三個特點:

  1. 根節點不包含字元,除根節點外的其餘每個節點都只包含一個字元。
  2. 從根節點到某一節點,將路徑上經過的所有字元連線起來,即為該節點對應的字串。
  3. 每個節點的所有子節點包含的字元都不相同。

下圖所示就是在資料結構網站上依次輸入以下單詞(AFGCC、AFG、ABP、TAGCC)後生成的一顆字典樹:

上圖中可以發現根節點沒有字母,除了根節點之外其餘節點有白色和綠色兩種顏色之分,這兩種顏色的節點有什麼區別呢?

綠色的節點表示當前節點是一個 Final 節點,也就是說當前節點是某一個單詞的結束節點,搜尋的時候當發現末尾節點是一個 Final 節點則表示當前字母存在,否則表示不存在。

比如我現在搜尋 ABP,從根節點往下找的時候,最後發現 P 是一個 Final 節點,那就表示當前樹中存在字串 ABP,如果搜尋 AFGC,雖然也能找到這些字母,但是 C 並不是一個 Final 節點,所以字串 AFGC 並不存在。

不過字典樹存在一個問題,上圖中就可以體現出來,比如第二列中的字尾 FGCC 和 第三列中的 GCC 其實最後三個字元是重複的,但是這些重複的字串都單獨儲存了,並沒有被複用,也就是說字典樹沒有解決字尾共用問題,只解決了字首共用(這也是字典樹又被稱之為字首樹的原因)。當資料量達到一定級別的時候,只共享字首不共享字尾也會帶來很多空間的浪費,那麼如何來解決這個問題呢?

FST

要解決上面字典樹的缺陷其實思路也很簡單,就是除了利用字串的字首,同時也將相同的字尾進行利用,這就是 FST,在瞭解 FST 之前,我們先了解另一個概念,那就是 FSM,即:Finite State Transducer。

FSM

FSM,即 Finite State Machine,翻譯為:有限狀態機。如果大家有了解過設計模式中的狀態模式的話,那麼應該會對狀態機有一定了解。有限狀態機顧明思議就是狀態可以全部被列舉出來,然後隨著不同的操作在不同的狀態之間流程。

如下圖所示就是一個簡易的有限狀態機(假設一個人一天做的事就是下面的所有狀態,那麼狀態之間可以切換流轉,下圖中的數字表示狀態的轉換條件):

有限狀態機主要有以下兩個特點:

  1. 狀態是有限的,可以被全部列舉出來。
  2. 狀態與狀態之間可以流轉。

而我們今天所需要學習的 FST,其實就是通過 FSM 演化而來。

繼續回到我們上面的那顆字典樹,那麼假如現在我們換成 FST 來儲存,會得到如下的資料結構:

上面這幅圖是怎麼得到的呢?字母后的數字又代表了什麼含義呢?有些節點有數字,有些是空白又有什麼區別呢?這幅圖又是如何區分 Final 節點呢?接下來我們就一步步來來構建一個 FST

構建 FST

首先我們知道,既然現在講的是儲存索引,所以除了 key 之外自然得有 value,否則是沒有意義的,所以上圖中其實字母就代表了索引關鍵字,也就是 key,而後面的數字代表了儲存的文件 id(最終會轉換成二進位制儲存),然而這個 每個數字代表的 id 又可能是不完整的,這個我們下面會解釋原因。

  1. 首先我們收到第一個儲存索引的的鍵值對 AFGCC/5,得到如下圖:

上圖中紅色代表開始節點,深灰色代表結束節點,加粗的線條代表其後面的節點是一個 Final 節點。這裡有一個問題,那就是 5 為什麼要儲存在第一條線(沒有儲存數字的線上實際上是一個 null 值),實際上我儲存在後面的任意一條線都可以,因為最終搜尋的時候會把整條線路上所有的數字加起來得到最終的 value,這也就是上面我為什麼說每一條線上的 value 可能是不完整的,因為一個 value 可能會被拆成好幾個數字相加,並且儲存在不同的線上。

首先這個 5 為什麼要儲存在第一段其實是為了提高複用率,因為越往前複用的機會可能就會越大。

  1. 繼續儲存第二個索引鍵值對 AFG/10,這時候得到下圖:

這時候我們發現,G 後面的節點儲存了一個 5,其他線段上並沒有儲存數字,這是為什麼呢?因為 10=5+5,而前面第一段已經儲存了一個 5,後面一個 5 儲存在任何一段線上都會影響到我們的第一個鍵值對 AFGCC/5,所以這時候就只能把他儲存在當前索引 key 所對應的 Final 節點上(原始碼中有一個屬性 output),因為搜尋的時候,如果路過不屬於自己的 Final 節點上的 value,是不會相加的,所以當我們搜尋第一個索引值 AFGCC 的時候,是不會把 G 後面的 Final 節點中的 value 取出來相加的。

  1. 接下來繼續儲存第三個索引鍵值對 ABP/2,這時候得到下圖:

這時候因為 ABP 字串和前面共用了 A,而 A 對應的 value5,已經比 2 大了,所以只要共用 A,那麼是無論如何也無法儲存成功的,所以就只能把第一個節點 5 拆成 2+3,原先 A 的位置儲存 2,那麼後面的 3 遵循前面的原則,越靠前儲存複用的概率越大,所以存在第二段線也就是字元 F 對應的位置,這時候就都滿足條件了。

  1. 最後我們來儲存最後一個索引鍵值對 TAGCC/6,最終得到如下圖:

這時候因為 GCC 這個字尾和前面是共用的,而恰好 GCC 之後的線上都沒有儲存 value,所以直接把這個 6 儲存在第一段線即可,注意,如果這裡再次發生衝突,那麼就需要再次重新分配每一段 value,到這裡我們就得到和上圖中網站內生成的一樣的 FST 了。

總結

本文主要講解了在 Elasticsearch 中是如何利用倒排索引來進行資料檢索的,並講述了倒排索引中的 FORRBM 兩種壓縮演算法的原理以及使用場景,最後對比了字典樹(字首樹)和 FST 兩種資料結構儲存的區別,並最終得出了為什麼 es 中選擇 FST 而不是選擇字典樹來進行儲存索引資料的原因。

相關文章