Elasticsearch?為什么能做到快速檢索?—?倒排索引的秘密
"All problems in computer science can be solved by another level of indirection.”– David J. Wheeler“計算機(jī)世界就是 trade-off 的藝術(shù)”
| 前言
最近接觸的幾個項目都使用到了 Elasticsearch (以下簡稱 ES ) 來存儲數(shù)據(jù)和對數(shù)據(jù)進(jìn)行搜索分析,就對 ES 進(jìn)行了一些學(xué)習(xí)。本文整理自我自己的一次技術(shù)分享。本文不會關(guān)注 ES 里面的分布式技術(shù)、相關(guān) API 的使用,而是專注分享下?”ES 如何快速檢索“?這個主題上面。這個也是我在學(xué)習(xí)之前對 ES 最感興趣的部分。本文大致包括以下內(nèi)容:1) 關(guān)于搜索? ? ???- 傳統(tǒng)關(guān)系型數(shù)據(jù)庫和 ES 的差別
- 搜索引擎原理
- 倒排索引具體是個什么樣子的(posting list -> term dic -> term index)
- 關(guān)于 postings list 的一些巧技 (FOR、Roaring Bitmaps)
- 如何快速做聯(lián)合查詢?
二、關(guān)于搜索
先設(shè)想一個關(guān)于搜索的場景,假設(shè)我們要搜索一首詩句內(nèi)容中帶“前”字的古詩,用傳統(tǒng)關(guān)系型數(shù)據(jù)庫和 ES 實(shí)現(xiàn)會有什么差別?如果用像 MySQL 這樣的 RDBMS 來存儲古詩的話,我們應(yīng)該會去使用這樣的 SQL 去查詢。
select?name?from?poems?where?content?like?"%前%";
這種我們稱為順序掃描法,需要遍歷所有的記錄進(jìn)行匹配。不但效率低,而且不符合我們搜索時的期望,比如我們在搜索“ABCD"這樣的關(guān)鍵詞時,通常還希望看到"A","AB","CD",“ABC”的搜索結(jié)果。于是乎就有了專業(yè)的搜索引擎,比如我們今天的主角 -- ES。搜索引擎原理
搜索引擎的搜索原理簡單概括的話可以分為這么幾步,- 內(nèi)容爬取,停頓詞過濾比如一些無用的像"的",“了”之類的語氣詞/連接詞
- 內(nèi)容分詞,提取關(guān)鍵詞
- 根據(jù)關(guān)鍵詞建立倒排索引
- 用戶輸入關(guān)鍵詞進(jìn)行搜索
如果你了解 ES 應(yīng)該知道,ES 可以說是對 Lucene 的一個封裝,里面關(guān)于倒排索引的實(shí)現(xiàn)就是通過 lucene 這個 jar 包提供的 API 實(shí)現(xiàn)的,所以下面講的關(guān)于倒排索引的內(nèi)容實(shí)際上都是 lucene 里面的內(nèi)容。
三、倒排索引
首先我們還不能忘了我們之前提的搜索需求,先看下建立倒排索引之后,我們上述的查詢需求會變成什么樣子。這樣我們一輸入“前”,借助倒排索引就可以直接定位到符合查詢條件的古詩。當(dāng)然這只是一個很大白話的形式來描述倒排索引的簡要工作原理。在 ES 中,這個倒排索引是具體是個什么樣的,怎么存儲的等等,這些才是倒排索引的精華內(nèi)容。
1. 幾個概念
在進(jìn)入下文之前,先描述幾個前置概念。term
關(guān)鍵詞這個東西是我自己的講法,在 ES 中,關(guān)鍵詞被稱為?term。postings list
還是用上面的例子,{靜夜思, 望廬山瀑布}
是 "前" 這個 term 所對應(yīng)列表。在 ES 中,這些被描述為所有包含特定 term 文檔的 id 的集合。由于整型數(shù)字 integer 可以被高效壓縮的特質(zhì),integer 是最適合放在 postings list 作為文檔的唯一標(biāo)識的,ES 會對這些存入的文檔進(jìn)行處理,轉(zhuǎn)化成一個唯一的整型 id。再說下這個 id 的范圍,在存儲數(shù)據(jù)的時候,在每一個 shard 里面,ES 會將數(shù)據(jù)存入不同的 segment,這是一個比 shard 更小的分片單位,這些 segment 會定期合并。在每一個 segment 里面都會保存最多 2^31 個文檔,每個文檔被分配一個唯一的 id,從0
到(2^31)-1
。相關(guān)的名詞都是 ES 官方文檔給的描述,后面參考材料中都可以找到出處。
2. 索引內(nèi)部結(jié)構(gòu)
上面所描述的倒排索引,僅僅是一個很粗糙的模型。真的要在實(shí)際生產(chǎn)中使用,當(dāng)然還差的很遠(yuǎn)。在實(shí)際生產(chǎn)場景中,比如 ES 最常用的日志分析,日志內(nèi)容進(jìn)行分詞之后,可以得到多少的 term?那么如何快速的在海量 term 中查詢到對應(yīng)的 term 呢?遍歷一遍顯然是不現(xiàn)實(shí)的。term dictionary
于是乎就有了?term dictionary,ES 為了能快速查找到 term,將所有的 term 排了一個序,二分法查找。是不是感覺有點(diǎn)眼熟,這不就是 MySQL 的索引方式的,直接用 B 樹建立索引詞典指向被索引的數(shù)據(jù)。term index
但是問題又來了,你覺得 Term Dictionary 應(yīng)該放在哪里?肯定是放在內(nèi)存里面吧?磁盤 io 那么慢。就像 MySQL 索引就是存在內(nèi)存里面了。但是如果把整個 term dictionary 放在內(nèi)存里面會有什么后果呢?內(nèi)存爆了...別忘了,ES 默認(rèn)可是會對全部 text 字段進(jìn)行索引,必然會消耗巨大的內(nèi)存,為此 ES 針對索引進(jìn)行了深度的優(yōu)化。在保證執(zhí)行效率的同時,盡量縮減內(nèi)存空間的占用。于是乎就有了?term index。Term index 從數(shù)據(jù)結(jié)構(gòu)上分類算是一個“Trie 樹”,也就是我們常說的字典樹。這是一種專門處理字符串匹配的數(shù)據(jù)結(jié)構(gòu),用來解決在一組字符串集合中快速查找某個字符串的問題。這棵樹不會包含所有的 term,它包含的是 term 的一些前綴(這也是字典樹的使用場景,公共前綴)。通過 term index 可以快速地定位到 term dictionary 的某個 offset,然后從這個位置再往后順序查找。就像右邊這個圖所表示的。(怎么樣,像不像我們查英文字典,我們定位 S 開頭的第一個單詞,或者定位到 Sh 開頭的第一個單詞,然后再往后順序查詢)lucene 在這里還做了兩點(diǎn)優(yōu)化,一是 term dictionary 在磁盤上面是分 block 保存的,一個 block 內(nèi)部利用公共前綴壓縮,比如都是 Ab 開頭的單詞就可以把 Ab 省去。二是 term index 在內(nèi)存中是以 FST(finite state transducers)的數(shù)據(jù)結(jié)構(gòu)保存的。FST 有兩個優(yōu)點(diǎn):- 空間占用小。通過對詞典中單詞前綴和后綴的重復(fù)利用,壓縮了存儲空間
- 查詢速度快。O(len(str)) 的查詢時間復(fù)雜度。
四、關(guān)于 postings list 的一些巧技
在實(shí)際使用中,postings list 還需要解決幾個痛點(diǎn),- postings list 如果不進(jìn)行壓縮,會非常占用磁盤空間,
- 聯(lián)合查詢下,如何快速求交并集(intersections and unions)
1. 壓縮
Frame of Reference
在 lucene 中,要求 postings lists 都要是有序的整形數(shù)組。這樣就帶來了一個很好的好處,可以通過 增量編碼(delta-encode)這種方式進(jìn)行壓縮。比如現(xiàn)在有 id 列表?[73, 300, 302, 332, 343, 372]
,轉(zhuǎn)化成每一個 id 相對于前一個 id 的增量值(第一個 id 的前一個 id 默認(rèn)是 0,增量就是它自己)列表是[73, 227, 2, 30, 11, 29]
。在這個新的列表里面,所有的 id 都是小于 255 的,所以每個 id 只需要一個字節(jié)存儲。實(shí)際上 ES 會做的更加精細(xì),它會把所有的文檔分成很多個 block,每個 block 正好包含 256 個文檔,然后單獨(dú)對每個文檔進(jìn)行增量編碼,計算出存儲這個 block 里面所有文檔最多需要多少位來保存每個 id,并且把這個位數(shù)作為頭信息(header)放在每個 block 的前面。這個技術(shù)叫?Frame of Reference。上圖也是來自于 ES 官方博客中的一個示例(假設(shè)每個 block 只有 3 個文件而不是 256)。FOR 的步驟可以總結(jié)為:經(jīng)過最后的位壓縮之后,整型數(shù)組的類型從固定大小 (8,16,32,64 位)4 種類型,擴(kuò)展到了[1-64] 位共 64 種類型。
通過以上的方式可以極大的節(jié)省 posting list 的空間消耗,提高查詢性能。不過 ES 為了提高 filter 過濾器查詢的性能,還做了更多的工作,那就是緩存。
Roaring Bitmaps (for filter cache)
在 ES 中,可以使用 filters 來優(yōu)化查詢,filter 查詢只處理文檔是否匹配與否,不涉及文檔評分操作,查詢的結(jié)果可以被緩存。對于 filter 查詢,es 提供了 filter cache 這種特殊的緩存,filter cache 用來存儲 filters 得到的結(jié)果集。緩存 filters 不需要太多的內(nèi)存,它只保留一種信息,即哪些文檔與 filter 相匹配。同時它可以由其它的查詢復(fù)用,極大地提升了查詢的性能。我們上面提到的 Frame Of Reference 壓縮算法對于 postings list 來說效果很好,但對于需要存儲在內(nèi)存中的 filter cache 等不太合適。filter cache 會存儲那些經(jīng)常使用的數(shù)據(jù),針對 filter 的緩存就是為了加速處理效率,對壓縮算法要求更高。對于這類 postings list,ES 采用不一樣的壓縮方式。那么讓我們一步步來。首先我們知道 postings list 是 Integer 數(shù)組,具有壓縮空間。假設(shè)有這么一個數(shù)組,我們第一個壓縮的思路是什么?用位的方式來表示,每個文檔對應(yīng)其中的一位,也就是我們常說的位圖,bitmap。它經(jīng)常被作為索引用在數(shù)據(jù)庫、查詢引擎和搜索引擎中,并且位操作(如 and 求交集、or 求并集)之間可以并行,效率更好。但是,位圖有個很明顯的缺點(diǎn),不管業(yè)務(wù)中實(shí)際的元素基數(shù)有多少,它占用的內(nèi)存空間都恒定不變。也就是說不適用于稀疏存儲。業(yè)內(nèi)對于稀疏位圖也有很多成熟的壓縮方案,lucene 采用的就是roaring bitmaps。我這里用簡單的方式描述一下這個壓縮過程是怎么樣,將 doc id 拆成高 16 位,低 16 位。對高位進(jìn)行聚合 (以高位做 key,value 為有相同高位的所有低位數(shù)組),根據(jù)低位的數(shù)據(jù)量 (不同高位聚合出的低位數(shù)組長度不相同),使用不同的 container(數(shù)據(jù)結(jié)構(gòu)) 存儲。
- len<4096 ArrayContainer 直接存值
- len>=4096 BitmapContainer 使用 bitmap 存儲
2^16=65536
. 假設(shè)以 bitmap 方式存儲需要?65536bit=8kb
,而直接存值的方式,一個值 2 byte,4K 個總共需要2byte*4K=8kb
。所以當(dāng) value 總量 <4k 時,使用直接存值的方式更節(jié)省空間。空間壓縮主要體現(xiàn)在:- 高位聚合 (假設(shè)數(shù)據(jù)中有 100w 個高位相同的值,原先需要?
100w*2byte
,現(xiàn)在只要?1*2byte
) - 低位壓縮
這就是 trade-off 呀。平衡的藝術(shù)。
2. 聯(lián)合查詢
講完了壓縮,我們再來講講聯(lián)合查詢。先講簡單的,如果查詢有 filter cache,那就是直接拿 filter cache 來做計算,也就是說位圖來做 AND 或者 OR 的計算。如果查詢的 filter 沒有緩存,那么就用 skip list 的方式去遍歷磁盤上的 postings list。以上是三個 posting list。我們現(xiàn)在需要把它們用 AND 的關(guān)系合并,得出 posting list 的交集。首先選擇最短的 posting list,逐個在另外兩個 posting list 中查找看是否存在,最后得到交集的結(jié)果。遍歷的過程可以跳過一些元素,比如我們遍歷到綠色的 13 的時候,就可以跳過藍(lán)色的 3 了,因為 3 比 13 要小。用 skip list 還會帶來一個好處,還記得前面說的嗎,postings list 在磁盤里面是采用 FOR 的編碼方式存儲的
會把所有的文檔分成很多個 block,每個 block 正好包含 256 個文檔,然后單獨(dú)對每個文檔進(jìn)行增量編碼,計算出存儲這個 block 里面所有文檔最多需要多少位來保存每個 id,并且把這個位數(shù)作為頭信息(header)放在每個 block 的前面。因為這個 FOR 的編碼是有解壓縮成本的。利用 skip list,除了跳過了遍歷的成本,也跳過了解壓縮這些壓縮過的 block 的過程,從而節(jié)省了 cpu。
五、總結(jié)
下面我們來做一個技術(shù)總結(jié)(感覺有點(diǎn)王剛老師的味道)- 為了能夠快速定位到目標(biāo)文檔,ES 使用倒排索引技術(shù)來優(yōu)化搜索速度,雖然空間消耗比較大,但是搜索性能提高十分顯著。
- 為了能夠在數(shù)量巨大的 terms 中快速定位到某一個 term,同時節(jié)約對內(nèi)存的使用和減少磁盤 io 的讀取,lucene 使用 "term index -> term dictionary -> postings list" 的倒排索引結(jié)構(gòu),通過?FST?壓縮放入內(nèi)存,進(jìn)一步提高搜索效率。
- 為了減少 postings list 的磁盤消耗,lucene 使用了?FOR(Frame of Reference)技術(shù)壓縮,帶來的壓縮效果十分明顯。
- ES 的 filter 語句采用了?Roaring Bitmap?技術(shù)來緩存搜索結(jié)果,保證高頻 filter 查詢速度的同時降低存儲空間消耗。
- 在聯(lián)合查詢時,在有 filter cache 的情況下,會直接利用位圖的原生特性快速求交并集得到聯(lián)合查詢結(jié)果,否則使用?skip list?對多個 postings list 求交并集,跳過遍歷成本并且節(jié)省部分?jǐn)?shù)據(jù)的解壓縮 cpu 成本
Elasticsearch 的索引思路
將磁盤里的東西盡量搬進(jìn)內(nèi)存,減少磁盤隨機(jī)讀取次數(shù) (同時也利用磁盤順序讀特性),結(jié)合各種壓縮算法,用及其苛刻的態(tài)度使用內(nèi)存。所以,對于使用 Elasticsearch 進(jìn)行索引時需要注意:
- 不需要索引的字段,一定要明確定義出來,因為默認(rèn)是自動建索引的
- 同樣的道理,對于 String 類型的字段,不需要 analysis 的也需要明確定義出來,因為默認(rèn)也是會 analysis 的
- 選擇有規(guī)律的 ID 很重要,隨機(jī)性太大的 ID(比如 Java 的 UUID) 不利于查詢
希望本篇文章能給你帶來一些收獲~參考文檔:
- https://www.elastic.co/cn/blog/frame-of-reference-and-roaring-bitmaps
- https://www.elastic.co/cn/blog/found-elasticsearch-from-the-bottom-up
- http://blog.mikemccandless.com/2014/05/choosing-fast-unique-identifier-uuid.html
- https://www.infoq.cn/article/database-timestamp-02
- https://zhuanlan.zhihu.com/p/137574234
ickname="架構(gòu)師社區(qū)" data-alias="devabc" data-signature="架構(gòu)師社區(qū),專注分享架構(gòu)師技術(shù)干貨,架構(gòu)師行業(yè)秘聞,匯集各類奇妙好玩的架構(gòu)師話題和流行的架構(gòu)師動向!" data-from="0">