ElasticSearch 是什么?工作原理是怎么樣的?
現(xiàn)在有三段文本,id 分別是 0、1、2,你需要快速找到哪段文本里含有關(guān)鍵詞"xiaobai".
I like xiaobai (點贊)
I follow xiaobai (關(guān)注)
I forward the video (轉(zhuǎn)發(fā))
我們很容易想到,可以依次遍歷這三段文本,匹配文本內(nèi)是否含有"xiaobai",最終將符合條件的文本 ID 輸出。
在數(shù)據(jù)量小的時候,問題不大,但如果我有上百億條這樣的數(shù)據(jù)呢?
如果依次遍歷,這一把執(zhí)行下去,比你喜歡的女生回你消息的速度,還要慢。
像這種在海量數(shù)據(jù)中,通過關(guān)鍵詞檢索出有效信息的場景非常常見,比如我們網(wǎng)購用的某寶和某東的站內(nèi)商品搜索。那么問題就來了,怎么處理類似的搜索場景呢?
好辦,沒有什么是加一層中間層不能解決的,如果有,那就再加一層。
這次我們要加的中間層是 elasticSearch。
什么是 elasticSearch
elastic Search, 也就是 es,是一個開源的搜索引擎。
它介于應用和數(shù)據(jù)之間,只要將數(shù)據(jù)寫入 es,應用就可以通過一些關(guān)鍵詞搜索到數(shù)據(jù)。效果就像某度搜索一樣。
那它是怎么做到的呢?我們從倒排索引聊起。
什么是倒排索引
回到文章開頭的例子。依次遍歷文本匹配是否含有"xiaobai",確實低效。那有更高效的解法嗎?有,我們可以對文本進行切分,比如"I like xiaobai"切分為"I"、"like"、"xiaobai"三部分,這個操作叫分詞,分詞后的每部分,我們稱為一個詞項,也就是 term。記錄詞項和文本 id 的關(guān)系,于是上面的三段文本就變成了下面這樣。
term | 文本 id |
I | 0, 1, 2 |
like | 0 |
xiaobai | 0, 1 |
follow | 1 |
forward | 2 |
the | 2 |
video | 2 |
當我們想要搜索 xiaobai 的時候,只需要匹配到 xiaobai 詞項,就可以立馬得到它所在的文檔 id 是 0 和 1。但這有個問題,短短三句話,就已經(jīng)有這么多詞項了,要是換成三篇文檔,那詞項就會多得離譜,怎么在這么多的詞項里匹配出 xiaobai 呢?挨個遍歷的話,時間復雜度就是 O(N), 太低效了。
怎么辦呢?我們可以將詞項按字典序從小到大排序,通過二分查找的方法,直接將時間復雜度優(yōu)化為 O(lgN)。就像下面這樣。
term | 文檔 id |
follow | 1 |
forward | 2 |
I | 0, 1, 2 |
like | 0 |
the | 2 |
video | 2 |
xiaobai | 0, 1 |
我們將這堆排好序的詞項,稱為Term Dictionary,而詞項對應的文檔 id 等信息的集合,就叫 Posting List。它們共同構(gòu)成了一個用于搜索的數(shù)據(jù)結(jié)構(gòu),它就是**倒排索引(Inverted Index)**。
注意,Posting List 其實還包含詞頻和詞項在文本里的偏移量等信息,但為了方便理解,我在上圖中去掉了這部分內(nèi)容。
但倒排索引還有個問題,Term Dictionary 數(shù)據(jù)量很大,放內(nèi)存并不現(xiàn)實,因此必須放在磁盤中。但查詢磁盤是個較慢的過程。有優(yōu)化手段嗎?有,我們聊下 Term Index。
Term Index 是什么
我們可以發(fā)現(xiàn),詞項和詞項之間,有些前綴是一致的,比如 follow 和 forward 前面的 fo 是一致的,如果我們將部分 term 前綴提取出來,像下圖一樣,就可以用更少的空間表達更多的 term。基于這個原理,我們可以將 Term Dictionary 的部分詞項提取出來,用這些 詞項 的前綴信息,構(gòu)建出一個精簡的目錄樹。目錄樹的節(jié)點中存放這些詞項在磁盤中的偏移量,也就是指向磁盤中的位置。這個目錄樹結(jié)構(gòu),體積小,適合放內(nèi)存中,它就是所謂的 Term Index??梢杂盟鼇砑铀偎阉?。
當我們需要查找某個詞項的時候,只需要搜索 Term Index,就能快速獲得詞項在 Term Dictionary 中的大概位置。再跳轉(zhuǎn)到 Term Dictionary,通過少量的檢索,定位到詞項內(nèi)容。
Stored Fields 是什么
到這里,搜索功能就有了。但有個問題,前面提到的倒排索引,搜索到的是文檔 id,我們還需要拿著這個 id 找到文檔內(nèi)容本身,才能返回給用戶。因此還需要有個地方,存放完整的文檔內(nèi)容,它就是 Stored Fields(行式存儲)。
Doc Values 是什么
有了 id,我們就能從 Stored Fields 中取出文檔內(nèi)容。
但用戶經(jīng)常需要根據(jù)某個字段排序文檔,比如按時間排序或商品價格排序。但問題就來了,這些字段散落在文檔里。也就是說,我們需要先獲取 Stored Fields 里的文檔,再提取出內(nèi)部字段進行排序。也不是說不行。但其實有更高效的做法。我們可以用空間換時間的思路,再構(gòu)造一個列式存儲結(jié)構(gòu),將散落在各個文檔的某個字段,集中存放,當我們想對某個字段排序的時候,就只需要將這些集中存放的字段一次性讀取出來,就能做到針對性地進行排序。這個列式存儲結(jié)構(gòu),就是所謂的 Doc Values。
segment
在上文中,我們介紹了四種關(guān)鍵的結(jié)構(gòu):倒排索引用于搜索,Term Index 用于加速搜索,Stored Fields 用于存放文檔的原始信息,以及 Doc Values 用于排序和聚合。這些結(jié)構(gòu)共同組成了一個復合文件,也就是所謂的"segment", 它是一個具備完整搜索功能的最小單元。
lucene 是什么
我們可以用多個文檔生成一份 segment,如果新增文檔時,還是寫入到這份 segment,那就得同時更新 segment 內(nèi)部的多個數(shù)據(jù)結(jié)構(gòu),這樣并發(fā)讀寫時性能肯定會受影響。那怎么辦呢?我們定個規(guī)矩。segment 一旦生成,則不能再被修改。如果還有新的文檔要寫入,那就生成新的 segment。這樣老的 segment 只需要負責讀,寫則生成新的 segment。同時保證了讀和寫的性能。
但 segment 變多了,我們怎么知道要搜索的數(shù)據(jù)在哪個 segment 里呢?問題不大,并發(fā)同時讀多個 segment 就好了。
但這也引入了新問題,隨著數(shù)據(jù)量增大,segment 文件越寫越多,文件句柄被耗盡那是指日可待啊。是的,但這個也好解決,我們可以不定期合并多個小 segment,變成一個大 segment,也就是段合并(segment merging)。這樣文件數(shù)量就可控了。
到這里,上面提到的多個 segment,就共同構(gòu)成了一個單機文本檢索庫,它其實就是非常有名的開源基礎(chǔ)搜索庫 lucene。不少知名搜索引擎都是基于它構(gòu)建的,比如我們今天介紹的 ES。但這個 lucene 屬實過于簡陋,像什么高性能,高擴展性,高可用,它是一個都不沾。我們來看下怎么優(yōu)化它。
高性能
lucene 作為一個搜索庫,可以寫入大量數(shù)據(jù),并對外提供搜索能力。多個調(diào)用方同時讀寫同一個 lucene 必然導致爭搶計算資源。搶不到資源的一方就得等待,這不純純浪費時間嗎!有解決方案嗎?有!首先是對寫入 lucene 的數(shù)據(jù)進行分類,比如體育新聞和八卦新聞數(shù)據(jù)算兩類,每一類是一個 Index Name,然后根據(jù) Index Name 新增 lucene 的數(shù)量,將不同類數(shù)據(jù)寫入到不同的 lucene 中,讀取數(shù)據(jù)時,根據(jù)需要搜索不同的 Index Name 。這就大大降低了單個 lucene 的壓力。
但單個 Index Name 內(nèi)數(shù)據(jù)依然可能過多,于是可以將單個 Index Name 的同類數(shù)據(jù),拆成好幾份,每份是一個 shard 分片,每個 shard 分片本質(zhì)上就是一個獨立的 lucene 庫。這樣我們就可以將讀寫操作分攤到多個 分片 中去,大大降低了爭搶,提升了系統(tǒng)性能。
高擴展性
隨著 分片 變多,如果 分片 都在同一臺機器上的話,就會導致單機 cpu 和內(nèi)存過高,影響整體系統(tǒng)性能。
于是我們可以申請更多的機器,將 分片 分散部署在多臺機器上,這每一臺機器,就是一個 Node。我們可以通過增加 Node 緩解機器 cpu 過高帶來的性能問題。
高可用
到這里,問題又又來了,如果其中一個 Node 掛了,那 Node 里所有分片 都無法對外提供服務了。我們需要保證服務的高可用。有解決方案嗎?有,我們可以給 分片 多加幾個副本。將 分片 分為 Primary shard 和 Replica shard,也就是主分片和副本分片 。主分片會將數(shù)據(jù)同步給副本分片,副本分片既可以同時提供讀操作,還能在主分片掛了的時候,升級成新的主分片讓系統(tǒng)保持正常運行,提高性能的同時,還保證了系統(tǒng)的高可用。
Node 角色分化
搜索架構(gòu)需要支持的功能很多,既要負責管理集群,又要存儲管理數(shù)據(jù),還要處理客戶端的搜索請求。如果每個 Node 都支持這幾類功能,那當集群有數(shù)據(jù)壓力,需要擴容 Node 時,就會順帶把其他能力也一起擴容,但其實其他能力完全夠用,不需要跟著擴容,這就有些浪費了。因此我們可以將這幾類功能拆開,給集群里的 Node 賦予角色身份,不同的角色負責不同的功能。比如負責管理集群的,叫主節(jié)點(Master Node), 負責存儲管理數(shù)據(jù)的,叫數(shù)據(jù)節(jié)點(Data Node), 負責接受客戶端搜索查詢請求的叫協(xié)調(diào)節(jié)點(Coordinate Node)。集群規(guī)模小的時候,一個 Node 可以同時充當多個角色,隨著集群規(guī)模變大,可以讓一個 Node 一個角色。
去中心化
上面提到了主節(jié)點,那就意味著還有個選主的過程,但現(xiàn)在每個 Node 都是獨立的,需要有個機制協(xié)調(diào) Node 間的數(shù)據(jù)。我們很容易想到,可以像 kafka 那樣引入一個中心節(jié)點 Zookeeper,但如果不想引入新節(jié)點,還有其他更輕量的方案嗎?有,去中心化。我們可以在 Node 間引入?yún)f(xié)調(diào)模塊,用類似一致性算法 Raft 的方式,在節(jié)點間互相同步數(shù)據(jù),讓所有 Node 看到的集群數(shù)據(jù)狀態(tài)都是一致的。這樣,集群內(nèi)的 Node 就能參與選主過程,還能了解到集群內(nèi)某個 Node 是不是掛了等信息。
ES 是什么?
好了,到這里,當初那個簡陋的 lucene,就成了一個高性能,高擴展性,高可用,支持持久化的分布式搜索引擎,它就是我們常說的 elastic search,簡稱 ES。它對外提供 http 接口,任何語言的客戶端都可以通過 HTTP 接口接入 es,實現(xiàn)對數(shù)據(jù)的增刪改查。從架構(gòu)角度來看,es 給了一套方案,告訴我們?nèi)绾巫屢粋€單機系統(tǒng) lucene 變成一個分布式系統(tǒng)。
按這個思路,是不是也可以將 lucene 改成其他單機系統(tǒng),比如 mysql 數(shù)據(jù)庫,或者專門做向量檢索的單機引擎 faiss?那以后再來個 elastic mysql 或者 elastic faiss 是不是就不那么意外了,大廠內(nèi)卷晉升或者下一個明星開源大項目的小提示就給到這里了。
看完 es 的架構(gòu),是不是覺得有些似曾相識?沒錯,我說的就是我前兩期聊過的 kafka。
ES 和 Kafka 的架構(gòu)差異
如果你不了解 kakfa 的架構(gòu),可以看下我之前寫的《Kafka 是什么?》。然后你就會發(fā)現(xiàn):
- ? es 中用于分類消息的 Index Name,其實就是 kafka 的 topic。
- ? es 中用于對 Index Name 數(shù)據(jù)分片的 Shard,其實就是 kafka 中拆分 topic 數(shù)據(jù)的 Partition。
- ? es 中用于分散部署多個 shard 的 Node, 其實就相當于 kafka 中的 broker。
es 的架構(gòu)跟 kafka 以及我們上期聊過的 rocketMQ 都非常相似,果然優(yōu)秀的架構(gòu)都是相似的,丑陋的架構(gòu)各有各的丑陋。學一套優(yōu)秀架構(gòu),就等于弄通了好幾個中間件原理,簡直血賺!
現(xiàn)在我們了解完 es 的架構(gòu),再來用兩個實際例子將這些概念串起來,淺看下它的工作原理。
ES 的寫入流程
- ? 當客戶端應用發(fā)起數(shù)據(jù)寫入請求,請求會先發(fā)到集群中協(xié)調(diào)節(jié)點。
- ? 協(xié)調(diào)節(jié)點根據(jù) hash 路由,判斷數(shù)據(jù)該寫入到哪個數(shù)據(jù)節(jié)點里的哪個分片(Shard),找到主分片并寫入。分片底層是 lucene,所以最終是將數(shù)據(jù)寫入到 lucene 庫里的 segment 內(nèi),將數(shù)據(jù)固化為倒排索引和 Stored Fields 以及 Doc Values 等多種結(jié)構(gòu)。
- ? 主分片 寫入成功后會將數(shù)據(jù)同步給 副本分片。
- ? 副本分片 寫入完成后,主分片會響應協(xié)調(diào)節(jié)點一個 ACK,意思是寫入完成。
- ? 最后,協(xié)調(diào)節(jié)點響應客戶端應用寫入完成。
ES 的搜索流程
ES 的搜索流程分為兩個階段:分別是查詢階段(Query Phase)和獲取階段(Fetch Phase) 我們分別看下。
查詢階段。
- 當客戶端應用發(fā)起搜索請求,請求會先發(fā)到集群中的協(xié)調(diào)節(jié)點。
- 協(xié)調(diào)節(jié)點根據(jù) index name 的信息,可以了解到 index name 被分為了幾個 分片,以及這些分片 分散哪個數(shù)據(jù)節(jié)點上,將請求轉(zhuǎn)發(fā)到這些數(shù)據(jù)節(jié)點的 分片 上面。
- 搜索請求到達分片后,分片 底層的 lucene 庫會并發(fā)搜索多個 segment,利用每個 segment 內(nèi)部的倒排索引獲取到對應文檔 id,并結(jié)合 doc values 獲得排序信息。分片將結(jié)果聚合返回給協(xié)調(diào)節(jié)點。
- 協(xié)調(diào)節(jié)點對多個分片中拿到的數(shù)據(jù)進行一次排序聚合,舍棄大部分不需要的數(shù)據(jù)。
獲取階段。
- 協(xié)調(diào)節(jié)點再次拿著文檔 id 請求數(shù)據(jù)節(jié)點里的 分片,分片 底層的 lucene 庫會從 segment 內(nèi)的 Stored Fields 中取出完整文檔內(nèi)容,并返回給協(xié)調(diào)節(jié)點。
- 協(xié)調(diào)節(jié)點最終將數(shù)據(jù)結(jié)果返回給客戶端。完成整個搜索過程。
現(xiàn)在大家通了嗎?
總結(jié)
- lucene 是 es 底層的單機文本檢索庫,它由多個 segment 組成,每個 segment 其實是由倒排索引、Term Index、Stored Fields 和 Doc Values 組成的具備完整搜索功能的最小單元。
- 將數(shù)據(jù)分類,存儲在 es 內(nèi)不同的 Index Name 中。
- 為了防止 Index Name 內(nèi)數(shù)據(jù)過多,引入了 Shard 的概念對數(shù)據(jù)進行分片。提升了性能。
- 將多個 shard 分布在多個 Node 上,根據(jù)需要對 Node 進行擴容,提升擴展性。
- 將 shard 分為主分片和副本分片,主分片掛了之后由副本分片頂上,提升系統(tǒng)的可用性。
- 對 Node 進行角色分化,提高系統(tǒng)的性能和資源利用率,同時簡化擴展和維護。
- es 和 kafka 的架構(gòu)非常像,可以類比學習。