為什麼ElasticSearch比MySQL更適合全文索引

程式設計師歷小冰發表於2021-02-20

熟悉 MySQL 的同學一定都知道,MySQL 對於複雜條件查詢的支援並不好。MySQL 最多使用一個條件涉及的索引來過濾,然後剩餘的條件只能在遍歷行過程中進行記憶體過濾,對這個過程不瞭解的同學可以先行閱讀一下《MySQL複雜where條件分析》

上述這種處理複雜條件查詢的方式因為只能通過一個索引進行過濾,所以需要進行大量的 I/O 操作來讀取行資料,並消耗 CPU 進行記憶體過濾,導致查詢效能的下降。

而 ElasticSearch 因其特性,十分適合進行復雜條件查詢,是業界主流的複雜條件查詢場景解決方案,廣泛應用於訂單和日誌查詢等場景。

下面我們就一起來看一下,為什麼 ElasticSearch 適合進行復雜條件查詢。

ElasticSearch 簡介

Elasticsearch 是開源的實時分散式搜尋分析引擎,內部使用 Lucene 做索引與搜尋。它提供"準實時搜尋"能力,並且能動態叢集規模,彈性擴容。

Elasticsearch 使用 Lucene 作為其全文搜尋引擎,用於處理純文字的資料,但 Lucene 只是一個庫,提供建立索引、執行搜尋等介面,但不包含分散式服務,這些正是 Elasticsearch 做的。

下面,我們來介紹一下 ElasticSearch 的相關概念。為了便於初學者理解,我們先將 ElasticSearch 中的概念和 MySQL 中的概念大致地進行對應。但是二者在具體細節上還是有很多差異的,大家深入瞭解 ElasticSearch 就會將二者區分清楚,不能強行對比等同。

  • ElasticSearch 中的索引 Index 類似於 MySQL 中的資料庫 Database;
  • ElasticSearch 中的型別 Type 類似於 MySQL 中的表 Table;需要注意,這個概念在 7.x 版本中被完全刪除,而且概念上和 Table 也有較大差異;
  • ElasticSearch 中的文件 Document 類似於 MySQL 中的資料行 Row,每個文件由多個欄位 Filed 組成,這個Filed 就類似於 MySQL 的 Column;
  • ElasticSearch 中的對映 Mapping 是對索引庫中的索引欄位及其資料型別進行定義,類似於關係型資料庫中的表結構 Schema;
  • ElasticSearch 使用自己的領域語言 Query DSL 來進行增刪改查,而 MySQL 使用 SQL 語言進行上訴操作。

ElasticSearch 還有一系列有關其分散式特性的概念,我們這裡就暫不介紹了,等後續學習到其分散式特性時在進行介紹。

倒排索引

MySQL 有 B+ 樹索引,而 ElasticSearch 則是倒排索引 (Inverted Index),它通過倒排索引來實現比 MySQL 更快的過濾和複雜條件的查詢,此外,全文搜尋功能也是依賴倒排索引才能實現。下面,我們就具體來看一下何為倒排索引。

倒排索引按照維基百科的描述,是儲存文件內容到文件位置對映關係的資料庫索引結構。不過只看定義,我是有點迷惑,這不是和 MySQL 的非主鍵索引類似嘛,為什麼要叫它“倒排”呢?這個問題我目前也為搞清楚,可能要等到後續瞭解了其具體實現才能理解。

我們還是以書籍檢索為例,假設有以下資料,每一行就是一個 Document,每個 Document 由 id,ISBN 號,作者名稱和評分組成。

給上述資料按照 ISBN 和 Author 建立的倒排索引如下所示。倒排索引是每個欄位分開建立的,相互獨立。有兩個專門的術語,分別是索引 Term 和倒排表 Posting List。欄位的值就是 Term,比如 N0007,而 Term 對應的文件 ID 的列表就是 Posting List,對應圖中紅色的部分。

一般 Term 都是按照順序排序的,比如 Author 名稱就是按照字母序進行了排序,排序之後,當我們搜尋某一個 Term 時,就不需要從頭遍歷,而是採用二分查詢。一系列排序後的 Term 就組成了索引表 Term Dictionary。

但是 Term Dictionary 往往很大,無法完整放入記憶體,這是為了更快的查詢,還需要再給它建立索引,也就是 Term Index 。

ElasticSearch 使用 Burst-Trie 結構來實現 Term Index,它是一種字首樹 Trie 的一種變種,它主要是將字尾進行了壓縮,降低了Trie的高度,從而獲取更好查詢效能。

Term Index 並不需要像 MySQL 的索引一樣,包含所有的 Term,而是包含的是這些 Term 的字首。它就類似於字典的查詢目錄,可以進行快速定位到 Term Dictionary 的某一位置,然後再從這個位置向後查詢。

綜上, Alice,Alf,Arlan,Bob,Tom 等詞的倒排索引如下所示。綠色部分是 Term Index,藍色部分是 Term Dictionary,紅色部分是 Posting List。

一般來說,Term Index 都是全部快取在記憶體中,查詢時,先通過其快速定位到 Term Dictionary 對應的大致範圍,然後再進行磁碟讀取查詢對應的 Term,這樣就大大減少了磁碟 I/O 的次數。

聯合索引查詢

瞭解了 ElasticSearch 的倒排索引後,我們再來看看其如何處理複雜的聯合索引查詢。比如上述書籍例子中,我們需要查詢評分等於2.2並且作者名稱叫 Tom的書籍。

理論上,我們只需要分別按照 Score 和 Author 欄位的倒排索引進行查詢,獲取響應的 Posting List,再將其做交集合並即可。

這裡又要吐槽一下 MySQL,它是不支援這個合併操作的,它只能按照一個欄位的索引進行查詢,然後根據另外一個欄位的條件做記憶體過濾。順便說一下,MySQL 的 join 功能也弱爆了,感興趣的同學可以瞭解一下這篇文章

而 ElasticSearch 則支援使用跳錶 Skip List和 Bitset 的方式將資料集進行合併。

  • 使用 Skip List 結構,同時遍歷 Score 和 Author 查詢出來的 Posting List,利用其 Skip List 結構,相互跳躍對比,得出合集。
  • 使用 Bitset 結構,對 Score 和 Author 查詢出來的 Posting List 的值計算出各自的 Bitset,然後進行 AND 操作。

跳錶合併策略

ElasticSearch 在儲存 Posting List 資料時,就儲存了對應的多級跳錶結構響應的資料,這也體現了其空間換時間的基本思想。

這裡先介紹一下跳錶的基本概念,它其實是一種可以進行二分查詢的有序連結串列。跳錶在原有的有序連結串列上面增加了多級索引,通過索引來實現快速查詢。首先在最高階索引上查詢最後一個小於當前查詢元素的位置,然後再跳到次高階索引繼續查詢,直到跳到最底層為止,通過這種方式,加快了查詢的速度。

比如,按照 Score 查出來的 Posting List 為[2,3,4,5,7,9,10,11],按照 Author 查出來的結果為 [3,8,9,12,13],則二者的跳錶結構如下圖所示。

具體合併過程則是先選最短的 posting list,也就是 Author 的結果集,從其最小的一個 id 開始,將其作為當前最大值。然後依次剩餘 posting list 中查詢大於或等於該值的位置。

比如上述結果集中,先去 Score 結果集中查詢 3,找到後,就表明 3是二者的合集元素之一;然後再重新開啟一輪,選取 Author 結果集中 3 的下一個值 8 ,去 Score 結果集查詢 8,發現了大於等於 8 的最小的值是 9 ,所以不可能有共同的值 8,然後再去 Author 結果集查詢 9 ,發現其大於等於 9 的最小值是 12,所以再去 Score 結果集中查詢大於等於 12的值,發現並不存在;最終得出二者的合集就只有[3]。

在查詢過程中,每個 posting list 都可以根據當前 id 通過 skip list 快速跳過不符合的 id 值,加速整個合併取交集的過程。

ElasticSearch 對於較長的 posting list 也會使用 Frame Of Reference 進行壓縮編碼,減少了磁碟佔用,減少了索引尺寸。有關具體儲存結構的實現我們後續再進行細聊。

Bitset 合併策略

ElasticSearch除了使用 skipList 來進行資料磁碟讀取時的合併操作外,還會將一些查詢條件對應的結果集 posting list 進行記憶體快取,也就是所謂的 Filter Cache,為了後續再次複用。

為了減少記憶體快取所消耗的記憶體空間大小,ElasticSearch 沒有使用單純的陣列和 bitset 來儲存 posting list,而是使用要壓縮效率更高的 Roaring Bitmap。

我們可以先來講一下單純陣列或 bitset 資料結構為什麼並不使用。比如如下一道較為常見的面試題目:

給定含有40億個不重複的位於[0, 2^32 - 1]區間內的整數的集合,如何快速判定某個數是否在該集合內?

如果我們要使用 unsigned long 陣列來儲存它的話,也就需要消耗 40億 * 32 位 = 160 Byte,大致是 16000 MB。

如果要使用點陣圖 Bitset 來儲存的話,即某個數位於原集合內,就將它對應的點陣圖內的位元置為1,否則保持為0。這樣只需要消耗 2 ^ 32 位 = 512 MB,這可只有原來的 3.2 % 左右

但是,Bitset 也有其缺陷,也就是稀疏儲存的問題,比如上述集合並不是 40億,而是隻有2,3個,那麼 Bitset 中只有少數幾位是1,其他位都是 0,但是它仍然佔用了 512 MB。

而 RoaringBitmap 就是為了解決稀疏儲存的問題。下圖就是 RoaringBitmap 的基本原理示意圖。

首先,如上圖所示,計算出32位無符號整數和 65536 的除數和餘數。其含義表示,將32位無符號整數按照高16位分桶,即最多可能有2^16=65536個桶,術語懲治為 container。儲存資料時,按照資料的高16位找到 container(找不到就會新建一個),再將低16位放入container中。也就是說,一個 RoaringBitmap 就是很多container的集合。

然後 container 內具體的儲存結構要根據存入其內資料的基數來決定。

  • 基數小於 2 ^ 12 次方即 4096時,使用unsigned short型別的有序陣列來儲存,最大消耗空間就是 8 KB。

  • 基數大於 4096 時,則使用大小為 2 ^ 16 次方的普通 bitset 來儲存,固定消耗 8 KB。當然,有些時候也會對 bitset 進行行程長度編碼(RLE)壓縮,進一步減少空間佔用。

ElasticSearch 就是使用 Roaring Bitmap 來快取不同條件查詢出來的 posting list,然後再進行與操作計算出最終結果集。

後記

至此,我們也算了解了 ElasticSearch 為什麼比 MySQL 更適合複雜條件查詢,但是有好就有弊,因為為了查詢做了這麼多的準備工作,ElasticSearch 的插入速度就會慢於 MySQL,而且資料存入ES後並不是立馬就能檢索到

歡迎持續關注歷小冰,後續繼續為大家分享 MySQL、Redis 和 ElasticSearch 等資料庫相關的原理和實踐經驗。

參考文章

相關文章