掌握它才說明你真正懂Elasticsearch
原創【51CTO.com原創稿件】Elasticsearch 基于 Lucene,隱藏其復雜性,并提供簡單易用的 Restful API接口、Java API 接口。所以理解 ES 的關鍵在于理解 Lucene 的基本原理。
Lucene 簡介
Lucene 是一種高性能、可伸縮的信息搜索(IR)庫,在 2000 年開源,最初由鼎鼎大名的 Doug Cutting 開發,是基于 Java 實現的高性能的開源項目。
Lucene 采用了基于倒排表的設計原理,可以非常高效地實現文本查找,在底層采用了分段的存儲模式,使它在讀寫時幾乎完全避免了鎖的出現,大大提升了讀寫性能。
核心模塊
Lucene 的寫流程和讀流程如下圖所示:
圖 1:Lucene 的寫流程和讀流程
其中,虛線箭頭(a、b、c、d)表示寫索引的主要過程,實線箭頭(1-9)表示查詢的主要過程。
Lucene 中的主要模塊及模塊說明如下:
- analysis:主要負責詞法分析及語言處理,也就是我們常說的分詞,通過該模塊可最終形成存儲或者搜索的最小單元 Term。
- index 模塊:主要負責索引的創建工作。
- store 模塊:主要負責索引的讀寫,主要是對文件的一些操作,其主要目的是抽象出和平臺文件系統無關的存儲。
- queryParser 模塊:主要負責語法分析,把我們的查詢語句生成 Lucene 底層可以識別的條件。
- search 模塊:主要負責對索引的搜索工作。
- similarity 模塊:主要負責相關性打分和排序的實現。
核心術語
下面介紹 Lucene 中的核心術語:
- Term:是索引里最小的存儲和查詢單元,對于英文來說一般是指一個單詞,對于中文來說一般是指一個分詞后的詞。
- 詞典(Term Dictionary,也叫作字典):是 Term 的集合。詞典的數據結構可以有很多種,每種都有自己的優缺點。
比如:排序數組通過二分查找來檢索數據:HashMap(哈希表)比排序數組的檢索速度更快,但是會浪費存儲空間。
FST(finite-state transducer)有更高的數據壓縮率和查詢效率,因為詞典是常駐內存的,而 FST 有很好的壓縮率,所以 FST 在 Lucene 當前版本中有非常多的使用場景,也是默認的詞典數據結構。
- 倒排序(Posting List):一篇文章通常由多個詞組成,倒排表記錄的是某個詞在哪些文章中出現過。
- 正向信息:原始的文檔信息,可以用來做排序、聚合、展示等。
- 段(Segment):索引中最小的獨立存儲單元。一個索引文件由一個或者多個段組成。在 Luence 中的段有不變性,也就是說段一旦生成,在其上只能有讀操作,不能有寫操作。
Lucene 的底層存儲格式如下圖所示,由詞典和倒排序兩部分組成,其中的詞典就是 Term 的集合:
圖 2:Lucene 的底層存儲格式
詞典中的 Term 指向的文檔鏈表的集合,叫做倒排表。詞典和倒排表是 Lucene 中很重要的兩種數據結構,是實現快速檢索的重要基石。
詞典和倒排表是分兩部分存儲的,在倒排序中不但存儲了文檔編號,還存儲了詞頻等信息。
在上圖所示的詞典部分包含三個詞條(Term):Elasticsearch、Lucene 和 Solr。詞典數據是查詢的入口,所以這部分數據是以 FST 的形式存儲在內存中的。
在倒排表中,“Lucene”指向有序鏈表 3,7,15,30,35,67,表示字符串“Lucene”在文檔編號為3、7、15、30、35、67的文章中出現過,Elasticsearch 和 Solr 同理。
檢索方式
在 Lucene 的查詢過程中的主要檢索方式有以下四種:
①單個詞查詢
指對一個 Term 進行查詢。比如,若要查找包含字符串“Lucene”的文檔,則只需在詞典中找到 Term“Lucene”,再獲得在倒排表中對應的文檔鏈表即可。
②AND
指對多個集合求交集。比如,若要查找既包含字符串“Lucene”又包含字符串“Solr”的文檔,則查找步驟如下:
- 在詞典中找到 Term “Lucene”,得到“Lucene”對應的文檔鏈表。
- 在詞典中找到 Term “Solr”,得到“Solr”對應的文檔鏈表。
- 合并鏈表,對兩個文檔鏈表做交集運算,合并后的結果既包含“Lucene”也包含“Solr”。
③OR
指多個集合求并集。比如,若要查找包含字符串“Luence”或者包含字符串“Solr”的文檔,則查找步驟如下:
- 在詞典中找到 Term “Lucene”,得到“Lucene”對應的文檔鏈表。
- 在詞典中找到 Term “Solr”,得到“Solr”對應的文檔鏈表。
- 合并鏈表,對兩個文檔鏈表做并集運算,合并后的結果包含“Lucene”或者包含“Solr”。
④NOT
指對多個集合求差集。比如,若要查找包含字符串“Solr”但不包含字符串“Lucene”的文檔,則查找步驟如下:
- 在詞典中找到 Term “Lucene”,得到“Lucene”對應的文檔鏈表。
- 在詞典中找到 Term “Solr”,得到“Solr”對應的文檔鏈表。
- 合并鏈表,對兩個文檔鏈表做差集運算,用包含“Solr”的文檔集減去包含“Lucene”的文檔集,運算后的結果就是包含“Solr”但不包含“Lucene”。
通過上述四種查詢方式,我們不難發現,由于 Lucene 是以倒排表的形式存儲的。
所以在 Lucene 的查找過程中只需在詞典中找到這些 Term,根據 Term 獲得文檔鏈表,然后根據具體的查詢條件對鏈表進行交、并、差等操作,就可以準確地查到我們想要的結果。
相對于在關系型數據庫中的“Like”查找要做全表掃描來說,這種思路是非常高效的。
雖然在索引創建時要做很多工作,但這種一次生成、多次使用的思路也是非常高明的。
分段存儲
在早期的全文檢索中為整個文檔集合建立了一個很大的倒排索引,并將其寫入磁盤中,如果索引有更新,就需要重新全量創建一個索引來替換原來的索引。
這種方式在數據量很大時效率很低,并且由于創建一次索引的成本很高,所以對數據的更新不能過于頻繁,也就不能保證實效性。
現在,在搜索中引入了段的概念(將一個索引文件拆分為多個子文件,則每個子文件叫做段),每個段都是一個獨立的可被搜索的數據集,并且段具有不變性,一旦索引的數據被寫入硬盤,就不可修改。
在分段的思想下,對數據寫操作的過程如下:
- 新增:當有新的數據需要創建索引時,由于段段不變性,所以選擇新建一個段來存儲新增的數據。
- 刪除:當需要刪除數據時,由于數據所在的段只可讀,不可寫,所以 Lucene 在索引文件新增一個 .del 的文件,用來專門存儲被刪除的數據 id。
當查詢時,被刪除的數據還是可以被查到的,只是在進行文檔鏈表合并時,才把已經刪除的數據過濾掉。被刪除的數據在進行段合并時才會被真正被移除。
- 更新:更新的操作其實就是刪除和新增的組合,先在.del文件中記錄舊數據,再在新段中添加一條更新后的數據。
段不可變性的優點如下:
- 不需要鎖:因為數據不會更新,所以不用考慮多線程下的讀寫不一致情況。
- 可以常駐內存:段在被加載到內存后,由于具有不變性,所以只要內存的空間足夠大,就可以長時間駐存,大部分查詢請求會直接訪問內存,而不需要訪問磁盤,使得查詢的性能有很大的提升。
- 緩存友好:在段的聲明周期內始終有效,不需要在每次數據更新時被重建。
- 增量創建:分段可以做到增量創建索引,可以輕量級地對數據進行更新,由于每次創建的成本很低,所以可以頻繁地更新數據,使系統接近實時更新。
段不可變性的缺點如下:
- 刪除:當對數據進行刪除時,舊數據不會被馬上刪除,而是在 .del 文件中被標記為刪除。而舊數據只能等到段更新時才能真正地被移除,這樣會有大量的空間浪費。
- 更新:更新數據由刪除和新增這兩個動作組成。若有一條數據頻繁更新,則會有大量的空間浪費。
- 新增:由于索引具有不變性,所以每次新增數據時,都需要新增一個段來存儲數據。當段段數量太多時,對服務器的資源(如文件句柄)的消耗會非常大,查詢的性能也會受到影響。
- 過濾:在查詢后需要對已經刪除的舊數據進行過濾,這增加了查詢的負擔。
為了提升寫的性能,Lucene 并沒有每新增一條數據就增加一個段,而是采用延遲寫的策略,每當有新增的數據時,就將其先寫入內存中,然后批量寫入磁盤中。
若有一個段被寫到硬盤,就會生成一個提交點,提交點就是一個用來記錄所有提交后的段信息的文件。
一個段一旦擁有了提交點,就說明這個段只有讀的權限,失去了寫的權限;相反,當段在內存中時,就只有寫數據的權限,而不具備讀數據的權限,所以也就不能被檢索了。
從嚴格意義上來說,Lucene 或者 Elasticsearch 并不能被稱為實時的搜索引擎,只能被稱為準實時的搜索引擎。
寫索引的流程如下:
- 新數據被寫入時,并沒有被直接寫到硬盤中,而是被暫時寫到內存中。Lucene 默認是一秒鐘,或者當內存中數據量達到一定階段時,再批量提交到磁盤中。
當然,默認的時間和數據量的大小是可以通過參數控制的。通過延時寫的策略,可以減少數據往磁盤上寫的次數,從而提升整體的寫入性能,如圖 3。
- 在達到出觸發條件以后,會將內存中緩存的數據一次性寫入磁盤中,并生成提交點。
- 清空內存,等待新的數據寫入,如下圖所示。
圖 3:Elasticsearch 寫索引
從上述流程可以看出,數據先被暫時緩存在內存中,在達到一定的條件再被一次性寫入硬盤中,這種做法可以大大提升數據寫入的書單。
但是數據先被暫時存放在內存中,并沒有真正持久化到磁盤中,所以如果這時出現斷電等不可控的情況,就會丟失數據,為此,Elasticsearch 添加了事務日志,來保證數據的安全。
段合并策略
雖然分段比每次都全量創建索引有更高的效率,但是由于在每次新增數據時都會新增一個段,所以經過長時間的的積累,會導致在索引中存在大量的段。
當索引中段的數量太多時,不僅會嚴重消耗服務器的資源,還會影響檢索的性能。
因為索引檢索的過程是:查詢所有段中滿足查詢條件的數據,然后對每個段里查詢的結果集進行合并,所以為了控制索引里段的數量,我們必須定期進行段合并操作。
但是如果每次合并全部的段,則會造成很大的資源浪費,特別是“大段”的合并。
所以 Lucene 現在的段合并思路是:根據段的大小將段進行分組,再將屬于同一組的段進行合并。
但是由于對于超級大的段的合并需要消耗更多的資源,所以 Lucene 會在段的大小達到一定規模,或者段里面的數據量達到一定條數時,不會再進行合并。
所以 Lucene 的段合并主要集中在對中小段的合并上,這樣既可以避免對大段進行合并時消耗過多的服務器資源,也可以很好地控制索引中段的數量。
段合并的主要參數如下:
- mergeFactor:每次合并時參與合并的最少數量,當同一組的段的數量達到此值時開始合并,如果小于此值則不合并,這樣做可以減少段合并的頻率,其默認值為 10。
- SegmentSize:指段的實際大小,單位為字節。
- minMergeSize:小于這個值的段會被分到一組,這樣可以加速小片段的合并。
- maxMergeSize:若有一段的文本數量大于此值,就不再參與合并,因為大段合并會消耗更多的資源。
段合并相關的動作主要有以下兩個:
- 對索引中的段進行分組,把大小相近的段分到一組,主要由 LogMergePolicy1 類來處理。
- 將屬于同一分組的段合并成一個更大的段。
在段合并前對段的大小進行了標準化處理,通過 logMergeFactorSegmentSize 計算得出。
其中 MergeFactor 表示一次合并的段的數量,Lucene 默認該數量為 10;SegmentSize 表示段的實際大小。通過上面的公式計算后,段的大小更加緊湊,對后續的分組更加友好。
段分組的步驟如下:
①根據段生成的時間對段進行排序,然后根據上述標準化公式計算每個段的大小并且存放到段信息中,后面用到的描述段大小的值都是標準化后的值,如圖 4 所示:
圖 4:Lucene 段排序
②在數組中找到段,然后生成一個由段的標準化值作為上限,減去 LEVEL_LOG_SPAN(默認值為 0.75)后的值作為下限的區間,小于等于上限并且大于下限的段,都被認為是屬于同一組的段,可以合并。
③在確定一個分組的上下限值后,就需要查找屬于這個分組的段了,具體過程是:創建兩個指針(在這里使用指針的概念是為了更好地理解)start 和 end。
start 指向數組的第 1 個段,end 指向第 start+MergeFactor 個段,然后從 end 逐個向前查找落在區間的段。
當找到第 1 個滿足條件的段時,則停止,并把當前段到 start 之間的段統一分到一個組,無論段的大小是否滿足當前分組的條件。
如圖 5 所示,第 2 個段明顯小于該分組的下限,但還是被分到了這一組。
圖 5:Lucene 段分組
這樣做的好處如下:
- 增加段合并的概率,避免由于段的大小參差不齊導致段難以合并。
- 簡化了查找的邏輯,使代碼的運行效率更高。
④在分組找到后,需要排除不參加合并的“超大”段,然后判斷剩余的段是否滿足合并的條件。
如圖 5 所示,mergeFactor=5,而找到的滿足合并條件的段的個數為 4,所以不滿足合并的條件,暫時不進行合并,繼續找尋下一個組的上下限。
⑤由于在第 4 步并沒有找到滿足段合并的段的數量,所以這一分組的段不滿足合并的條件,繼續進行下一分組段的查找。
具體過程是:將 start 指向 end,在剩下的段中尋找大的段,在找到大的值后再減去 LEVEL_LOG_SPAN 的值,再生成一下分組的區間值。
然后把 end 指向數組的第 start+MergeFactor 個段,逐個向前查找第 1 個滿足條件的段:重復第 3 步和第 4 步。
⑥如果一直沒有找到滿足合并條件的段,則一直重復第 5 步,直到遍歷完整個數組,如圖 6 所示:
圖 6:Lucene 段分組二
⑦在找到滿足條件的 mergeFactor 個段時,就需要開始合并了。但是在滿足合并條件的段大于 mergeFactor 時,就需要進行多次合并。
也就是說每次依然選擇 mergeFactor 個段進行合并,直到該分組的所有段合并完成,再進行下一分組的查找合并操作。
⑧通過上述幾步,如果找到了滿足合并要求的段,則將會進行段的合并操作。
因為索引里面包含了正向信息和反向信息,所以段合并的操作分為兩部分:
- 一個是正向信息合并,例如存儲域、詞向量、標準化因子等。
- 一個是反向信息的合并,例如詞典、倒排表等。
在段合并時,除了需要對索引數據進行合并,還需要移除段中已經刪除的數據。
Lucene 相似度打分
我們在前面了解到,Lucene 的查詢過程是:首先在詞典中查找每個 Term,根據 Term 獲得每個 Term 所在的文檔鏈表;然后根據查詢條件對鏈表做交、并、差等操作,鏈表合并后的結果集就是我們要查找的數據。
這樣做可以完全避免對關系型數據庫進行全表掃描,可以大大提升查詢效率。
但是,當我們一次查詢出很多數據時,這些數據和我們的查詢條件又有多大關系呢?其文本相似度是多少?
本節會回答這個問題,并介紹 Lucene 最經典的兩個文本相似度算法:基于向量空間模型的算法和基于概率的算法(BM25)。
如果對此算法不太感興趣,那么只需了解對文本相似度有影響的因子有哪些,哪些是正向的,哪些是逆向的即可,不需要理解每個算法的推理過程。但是這兩個文本相似度算法有很好的借鑒意義。
Elasticsearch 簡介
Elasticsearch 是使用 Java 編寫的一種開源搜索引擎,它在內部使用 Luence 做索引與搜索,通過對 Lucene 的封裝,提供了一套簡單一致的 RESTful API。
Elasticsearch 也是一種分布式的搜索引擎架構,可以很簡單地擴展到上百個服務節點,并支持 PB 級別的數據查詢,使系統具備高可用和高并發性。
核心概念
- Elasticsearch 的核心概念如下:
- Cluster:集群,由一個或多個 Elasticsearch 節點組成。
- Node:節點,組成 Elasticsearch 集群的服務單元,同一個集群內節點的名字不能重復。通常在一個節點上分配一個或者多個分片。
- Shards:分片,當索引上的數據量太大的時候,我們通常會將一個索引上的數據進行水平拆分,拆分出來的每個數據庫叫作一個分片。
在一個多分片的索引中寫入數據時,通過路由來確定具體寫入那一個分片中,所以在創建索引時需要指定分片的數量,并且分片的數量一旦確定就不能更改。
分片后的索引帶來了規模上(數據水平切分)和性能上(并行執行)的提升。每個分片都是 Luence 中的一個索引文件,每個分片必須有一個主分片和零到多個副本分片。
- Replicas:備份也叫作副本,是指對主分片的備份。主分片和備份分片都可以對外提供查詢服務,寫操作時先在主分片上完成,然后分發到備份上。
當主分片不可用時,會在備份的分片中選舉出一個作為主分片,所以備份不僅可以提升系統的高可用性能,還可以提升搜索時的并發性能。但是若副本太多的話,在寫操作時會增加數據同步的負擔。
- Index:索引,由一個和多個分片組成,通過索引的名字在集群內進行標識。
- Type:類別,指索引內部的邏輯分區,通過 Type 的名字在索引內進行標識。在查詢時如果沒有該值,則表示在整個索引中查詢。
- Document:文檔,索引中的每一條數據叫作一個文檔,類似于關系型數據庫中的一條數據通過 _id 在 Type 內進行標識。
- Settings:對集群中索引的定義,比如一個索引默認的分片數、副本數等信息。
- Mapping:類似于關系型數據庫中的表結構信息,用于定義索引中字段(Field)的存儲類型、分詞方式、是否存儲等信息。Elasticsearch 中的 Mapping 是可以動態識別的。
如果沒有特殊需求,則不需要手動創建 Mapping,因為 Elasticsearch 會自動根據數據格式識別它的類型,但是當需要對某些字段添加特殊屬性(比如:定義使用其他分詞器、是否分詞、是否存儲等)時,就需要手動設置 Mapping 了。一個索引的 Mapping 一旦創建,若已經存儲了數據,就不可修改了。
- Analyzer:字段的分詞方式的定義。一個 Analyzer 通常由一個 Tokenizer、零到多個 Filter 組成。
比如默認的標準 Analyzer 包含一個標準的 Tokenizer 和三個 Filter:Standard Token Filter、Lower Case Token Filter、Stop Token Filter。
Elasticsearch 的節點的分類如下:
①主節點(Master Node):也叫作主節點,主節點負責創建索引、刪除索引、分配分片、追蹤集群中的節點狀態等工作。Elasticsearch 中的主節點的工作量相對較輕。
用戶的請求可以發往任何一個節點,并由該節點負責分發請求、收集結果等操作,而并不需要經過主節點轉發。
通過在配置文件中設置 node.master=true 來設置該節點成為候選主節點(但該節點不一定是主節點,主節點是集群在候選節點中選舉出來的),在 Elasticsearch 集群中只有候選節點才有選舉權和被選舉權。其他節點是不參與選舉工作的。
②數據節點(Data Node):數據節點,負責數據的存儲和相關具體操作,比如索引數據的創建、修改、刪除、搜索、聚合。
所以,數據節點對機器配置要求比較高,首先需要有足夠的磁盤空間來存儲數據,其次數據操作對系統 CPU、Memory 和 I/O 的性能消耗都很大。
通常隨著集群的擴大,需要增加更多的數據節點來提高可用性。通過在配置文件中設置 node.data=true 來設置該節點成為數據節點。
③客戶端節點(Client Node):就是既不做候選主節點也不做數據節點的節點,只負責請求的分發、匯總等,也就是下面要說到的協調節點的角色。
其實任何一個節點都可以完成這樣的工作,單獨增加這樣的節點更多地是為了提高并發性。
可在配置文件中設置該節點成為數據節點:
- node.master=false
- node.data=false
④部落節點(Tribe Node):部落節點可以跨越多個集群,它可以接收每個集群的狀態,然后合并成一個全局集群的狀態。
它可以讀寫所有集群節點上的數據,在配置文件中通過如下設置使節點成為部落節點:
- tribe:
- one:
- cluster.name: cluster_one
- two:
- cluster.name: cluster_two
因為 Tribe Node 要在 Elasticsearch 7.0 以后移除,所以不建議使用。
⑤協調節點(Coordinating Node):協調節點,是一種角色,而不是真實的 Elasticsearch 的節點,我們沒有辦法通過配置項來配置哪個節點為協調節點。集群中的任何節點都可以充當協調節點的角色。
當一個節點 A 收到用戶的查詢請求后,會把查詢語句分發到其他的節點,然后合并各個節點返回的查詢結果,返回一個完整的數據集給用戶。
在這個過程中,節點 A 扮演的就是協調節點的角色。由此可見,協調節點會對 CPU、Memory 和 I/O 要求比較高。
集群的狀態有 Green、Yellow 和 Red 三種,如下所述:
- Green:綠色,健康。所有的主分片和副本分片都可正常工作,集群 100% 健康。
- Yellow:預警。所有的主分片都可以正常工作,但至少有一個副本分片是不能正常工作的。此時集群可以正常工作,但是集群的高可用性在某種程度上被弱化。
- Red:紅色,集群不可正常使用。集群中至少有一個分片的主分片及它的全部副本分片都不可正常工作。
這時雖然集群的查詢操作還可以進行,但是也只能返回部分數據(其他正常分片的數據可以返回),而分配到這個分片上的寫入請求將會報錯,最終會導致數據的丟失。
3C 和腦裂
①共識性(Consensus)
共識性是分布式系統中最基礎也最主要的一個組件,在分布式系統中的所有節點必須對給定的數據或者節點的狀態達成共識。
雖然現在有很成熟的共識算法如 Raft、Paxos 等,也有比較成熟的開源軟件如 Zookeeper。
但是 Elasticsearch 并沒有使用它們,而是自己實現共識系統 zen discovery。
Elasticsearch 之父 Shay Banon 解釋了其中主要的原因:“zen discovery是 Elasticsearch 的一個核心的基礎組件,zen discovery 不僅能夠實現共識系統的選擇工作,還能夠很方便地監控集群的讀寫狀態是否健康。當然,我們也不保證其后期會使用 Zookeeper 代替現在的 zen discovery”。
zen discovery 模塊以“八卦傳播”(Gossip)的形式實現了單播(Unicat):單播不同于多播(Multicast)和廣播(Broadcast)。節點間的通信方式是一對一的。
②并發(Concurrency)
Elasticsearch 是一個分布式系統。寫請求在發送到主分片時,同時會以并行的形式發送到備份分片,但是這些請求的送達時間可能是無序的。
在這種情況下,Elasticsearch 用樂觀并發控制(Optimistic Concurrency Control)來保證新版本的數據不會被舊版本的數據覆蓋。
樂觀并發控制是一種樂觀鎖,另一種常用的樂觀鎖即多版本并發控制(Multi-Version Concurrency Control)。
它們的主要區別如下:
- 樂觀并發控制(OCC):是一種用來解決寫-寫沖突的無鎖并發控制,認為事務間的競爭不激烈時,就先進行修改,在提交事務前檢查數據有沒有變化,如果沒有就提交,如果有就放棄并重試。樂觀并發控制類似于自選鎖,適用于低數據競爭且寫沖突比較少的環境。
- 多版本并發控制(MVCC):是一種用來解決讀-寫沖突的無所并發控制,也就是為事務分配單向增長的時間戳,為每一個修改保存一個版本,版本與事務時間戳關聯,讀操作只讀該事務開始前的數據庫的快照。
這樣在讀操作不用阻塞操作且寫操作不用阻塞讀操作的同時,避免了臟讀和不可重復讀。
③一致性(Consistency)
Elasticsearch 集群保證寫一致性的方式是在寫入前先檢查有多少個分片可供寫入,如果達到寫入條件,則進行寫操作,否則,Elasticsearch 會等待更多的分片出現,默認為一分鐘。
有如下三種設置來判斷是否允許寫操作:
- One:只要主分片可用,就可以進行寫操作。
- All:只有當主分片和所有副本都可用時,才允許寫操作。
- Quorum(k-wu-wo/reng,法定人數):是 Elasticsearch 的默認選項。當有大部分的分片可用時才允許寫操作。其中,對“大部分”的計算公式為 int((primary+number_of_replicas)/2)+1。
Elasticsearch 集群保證讀寫一致性的方式是,為了保證搜索請求的返回結果是當前版本的文檔,備份可以被設置為 Sync(默認值),寫操作在主分片和備份分片同時完成后才會返回寫請求的結果。
這樣,無論搜索請求至哪個分片都會返回文檔。但是如果我們的應用對寫要求很高,就可以通過設置 replication=async 來提升寫的效率,如果設置 replication=async,則只要主分片的寫完成,就會返回寫成功。
④腦裂
在 Elasticsearch 集群中主節點通過 Ping 命令來檢查集群中的其他節點是否處于可用狀態,同時非主節點也會通過 Ping 來檢查主節點是否處于可用狀態。
當集群網絡不穩定時,有可能會發生一個節點 Ping 不通 Master 節點,則會認為 Master 節點發生了故障,然后重新選出一個 Master 節點,這就會導致在一個集群內出現多個 Master 節點。
當在一個集群中有多個 Master 節點時,就有可能會導致數據丟失。我們稱這種現象為腦裂。
事務日志
我們在上面了解到,Lucene 為了加快寫索引的速度,采用了延遲寫入的策略。
雖然這種策略提高了寫入的效率,但其弊端是,如果數據在內存中還沒有持久化到磁盤上時發生了類似斷電等不可控情況,就可能丟失數據。
為了避免丟失數據,Elasticsearch 添加了事務日志(Translog),事務日志記錄了所有還沒有被持久化磁盤的數據。
Elasticsearch 寫索引的具體過程如下:首先,當有數據寫入時,為了提升寫入的速度,并沒有數據直接寫在磁盤上,而是先寫入到內存中,但是為了防止數據的丟失,會追加一份數據到事務日志里。
因為內存中的數據還會繼續寫入,所以內存中的數據并不是以段的形式存儲的,是檢索不到的。
總之,Elasticsearch 是一個準實時的搜索引擎,而不是一個實時的搜索引擎。
此時的狀態如圖 7 所示:
圖 7:Elasticsearch 寫數據的過程
然后,當達到默認的時間(1 秒鐘)或者內存的數據達到一定量時,會觸發一次刷新(Refresh)。
刷新的主要步驟如下:
- 將內存中的數據刷新到一個新的段中,但是該段并沒有持久化到硬盤中,而是緩存在操作系統的文件緩存系統中。雖然數據還在內存中,但是內存里的數據和文件緩存系統里的數據有以下區別。
內存使用的是 JVM 的內存,而文件緩存系統使用的是操作系統的內存;內存的數據不是以段的形式存儲的,并且可以繼續向內存里寫數據。文件緩存系統中的數據是以段的形式存儲的,所以只能讀,不能寫;內存中的數據是搜索不到,文件緩存系統中的數據是可以搜索的。
- 打開保存在文件緩存系統中的段,使其可被搜索。
- 清空內存,準備接收新的數據。日志不做清空處理。
此時的狀態如圖 8 所示:
圖 8:Elasticsearch 寫數據的過程
刷新(Flush)。當日志數據的大小超過 512MB 或者時間超過 30 分鐘時,需要觸發一次刷新。
刷新的主要步驟如下:
- 在文件緩存系統中創建一個新的段,并把內存中的數據寫入,使其可被搜索。
- 清空內存,準備接收新的數據。
- 將文件系統緩存中的數據通過 Fsync 函數刷新到硬盤中。
- 生成提交點。
- 刪除舊的日志,創建一個空的日志。
此時的狀態如圖 9 所示:
圖 9:Elasticsearch 寫數據的過程
由上面索引創建的過程可知,內存里面的數據并沒有直接被刷新(Flush)到硬盤中,而是被刷新(Refresh)到了文件緩存系統中,這主要是因為持久化數據十分耗費資源,頻繁地調用會使寫入的性能急劇下降。
所以 Elasticsearch,為了提高寫入的效率,利用了文件緩存系統和內存來加速寫入時的性能,并使用日志來防止數據的丟失。
在需要重啟時,Elasticsearch 不僅要根據提交點去加載已經持久化過的段,還需要根據 Translog 里的記錄,把未持久化的數據重新持久化到磁盤上。
根據上面對 Elasticsearch,寫操作流程的介紹,我們可以整理出一個索引數據所要經歷的幾個階段,以及每個階段的數據的存儲方式和作用,如圖 10 所示:
圖 10:Elasticsearch 寫操作流程
在集群中寫索引
假設我們有如圖 11 所示(圖片來自官網)的一個集群,該集群由三個節點組成(Node 1、Node 2 和 Node 3),包含一個由兩個主分片和每個主分片由兩個副本分片組成的索引。
圖 11:寫索引
其中,標星號的 Node 1 是 Master 節點,負責管理整個集群的狀態;p1 和 p2 是主分片;r0 和 r1 是副本分片。為了達到高可用,Master 節點避免將主分片和副本放在同一個節點。
將數據分片是為了提高可處理數據的容量和易于進行水平擴展,為分片做副本是為了提高集群的穩定性和提高并發量。
在主分片掛掉后,會從副本分片中選舉出一個升級為主分片,當副本升級為主分片后,由于少了一個副本分片,所以集群狀態會從 Green 改變為 Yellow,但是此時集群仍然可用。
在一個集群中有一個分片的主分片和副本分片都掛掉后,集群狀態會由 Yellow 改變為 Red,集群狀態為 Red 時集群不可正常使用。
由上面的步驟可知,副本分片越多,集群的可用性就越高,但是由于每個分片都相當于一個 Lucene 的索引文件,會占用一定的文件句柄、內存及 CPU,并且分片間的數據同步也會占用一定的網絡帶寬,所以,索引的分片數和副本數并不是越多越好。
寫索引時只能寫在主分片上,然后同步到副本上,那么,一個數據應該被寫在哪個分片上呢?
如圖 10 所示,如何知道一個數據應該被寫在 p0 還是 p1 上呢答案就是路由(routing),路由公式如下:
- shard = hash(routing)%number_of_primary_shards
其中,Routing 是一個可選擇的值,默認是文檔的 _id(文檔的主鍵,文檔在創建時,如果文檔的 _id 已經存在,則進行更新,如果不存在則創建)。
后面會介紹如何通過自定義 Routing 參數使查詢落在一個分片中,而不用查詢所有的分片,從而提升查詢的性能。
Routing 通過 Hash 函數生成一個數字,將這個數字除以 number_of_primary_shards(分片的數量)后得到余數。
這個分布在 0 到 number_of_primary_shards - 1 之間的余數,就是我們所尋求的文檔所在分片的位置。
這也就說明了一旦分片數定下來就不能再改變的原因,因為分片數改變之后,所有之前的路由值都會變得無效,前期創建的文檔也就找不到了。
由于在 Elasticsearch 集群中每個節點都知道集群中的文檔的存放位置(通過路由公式定位),所以每個節點都有處理讀寫請求的能力。
在一個寫請求被發送到集群中的一個節點后,此時,該節點被稱為協調點(Coordinating Node),協調點會根據路由公式計算出需要寫到哪個分片上,再將請求轉發到該分片的主分片節點上。
圖 12:寫索引
寫操作的流程如下(參考圖 11,圖片來自官網):
- 客戶端向 Node 1(協調節點)發送寫請求。
- Node 1 通過文檔的 _id(默認是 _id,但不表示一定是 _id)確定文檔屬于哪個分片(在本例中是編號為 0 的分片)。請求會被轉發到主分片所在的節點 Node 3 上。
- Node 3 在主分片上執行請求,如果成功,則將請求并行轉發到 Node 1 和 Node 2 的副本分片上。
一旦所有的副本分片都報告成功(默認),則 Node 3 將向協調節點報告成功,協調節點向客戶端報告成功。
集群中的查詢流程
根據 Routing 字段進行的單個文檔的查詢,在 Elasticsearch 集群中可以在主分片或者副本分片上進行。
圖 13
查詢字段剛好是 Routing 的分片字段如“_id”的查詢流程如下(見圖 12,圖片來自官網):
- 客戶端向集群發送查詢請求,集群再隨機選擇一個節點作為協調點(Node 1),負責處理這次查詢。
- Node 1 使用文檔的 routing id 來計算要查詢的文檔在哪個分片上(在本例中落在了 0 分片上)分片 0 的副本分片存在所有的三個節點上。
在這種情況下,協調節點可以把請求轉發到任意節點,本例將請求轉發到 Node 2 上。
- Node 2 執行查找,并將查找結果返回給協調節點 Node 1,Node 1 再將文檔返回給客戶端。
當一個搜索請求被發送到某個節點時,這個節點就變成了協調節點(Node 1)。
協調節點的任務是廣播查詢請求到所有分片(主分片或者副本分片),并將它們的響應結果整合成全局排序后的結果集合。
由上面步驟 3 所示,默認返回給協調節點并不是所有的數據,而是只有文檔的 id 和得分 score,因為我們只返回給用戶 size 條數據,所以這樣做的好處是可以節省很多帶寬,特別是 from 很大時。
協調節點對收集回來的數據進行排序后,找到要返回的 size 條數據的 id,再根據 id 查詢要返回的數據,比如 title、content 等。
圖 14
取回數據等流程如下(見圖 13,圖片來自官網):
- Node 3 進行二次排序來找出要返回的文檔 id,并向相關的分片提交多個獲得文檔詳情的請求。
- 每個分片加載文檔,并將文檔返回給 Node 3。
- 一旦所有的文檔都取回了,Node 3 就返回結果給客戶端。
協調節點收集各個分片查詢出來的數據,再進行二次排序,然后選擇需要被取回的文檔。
例如,如果我們的查詢指定了{"from": 20, "size": 10},那么我們需要在每個分片中查詢出來得分較高的 20+10 條數據,協調節點在收集到 30×n(n 為分片數)條數據后再進行排序。
排序位置在 0-20 的結果會被丟棄,只有從第 21 個開始的 10 個結果需要被取回。這些文檔可能來自多個甚至全部分片。
由上面的搜索策略可以知道,在查詢時深翻(Deep Pagination)并不是一種好方法。
因為深翻時,from 會很大,這時的排序過程可能會變得非常沉重,會占用大量的 CPU、內存和帶寬。因為這個原因,所以強烈建議慎重使用深翻。
分片可以減少每個片上的數據量,加快查詢的速度,但是在查詢時,協調節點要在收集數(from+size)×n 條數據后再做一次全局排序。
若這個數據量很大,則也會占用大量的 CPU、內存、帶寬等,并且分片查詢的速度取決于最慢的分片查詢的速度,所以分片數并不是越多越好。
作者:錢丁君
簡介:就職于永輝云創,擔任基礎架構開發,有多年基礎架構經驗,主要從事電商新零售、互聯網金融行業。技術發燒友,涉獵廣泛。熟悉 Java 微服務架構搭建、推進、衍化;多種中間件搭建、封裝和優化;自動化測試開發、代碼規約插件開發、代碼規范推進;容器化技術 Docker、容器化編排技術 Kubernetes,有較為豐富的運維經驗。
【51CTO原創稿件,合作站點轉載請注明原文作者和出處為51CTO.com】