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