ElasticSearch系列 - ElasticSearch讀寫原理分析

1. 寫流程

Elasticsearch 是當前主流的搜索引擎,其具有擴展性好,查詢速度快,查詢結果近實時等優點,本文將對Elasticsearch的寫操作進行分析。

1.1. lucene的寫操作及其問題

Elasticsearch底層使用Lucene來實現doc的讀寫操作,Lucene通過

public long addDocument(...);
public long deleteDocuments(...);
public long updateDocument(...);

 

三個方法來實現文檔的寫入,更新和刪除操作。但是存在如下問題

  1. 沒有併發設計
    lucene只是一個搜索引擎庫,並沒有涉及到分佈式相關的設計,因此要想使用Lucene來處理海量數據,並利用分佈式的能力,就必須在其之上進行分佈式的相關設計。
  2. 非實時
    將文件寫入lucence後並不能立即被檢索,需要等待lucene生成一個完整的segment才能被檢索
  3. 數據存儲不可靠
    寫入lucene的數據不會立即被持久化到磁盤,如果服務器宕機,那存儲在內存中的數據將會丟失
  4. 不支持部分更新
    lucene中提供的updateDocuments僅支持對文檔的全量更新,對部分更新不支持

1.2. Elasticsearch的寫入方案

針對Lucene的問題,ES做了如下設計

1.2.1. 分佈式設計:

爲了支持對海量數據的存儲和查詢,Elasticsearch引入分片的概念,一個索引被分成多個分片,每個分片可以有一個主分片和多個副本分片,每個分片副本都是一個具有完整功能的lucene實例。分片可以分配在不同的服務器上,同一個分片的不同副本不能分配在相同的服務器上。
在進行寫操作時,ES會根據傳入的_routing參數(或mapping中設置的_routing, 如果參數和設置中都沒有則默認使用_id), 按照公式shard_num = hash(\routing) % num_primary_shards,計算出文檔要分配到的

分片,在從集羣元數據中找出對應主分片的位置,將請求路由到該分片進行文檔寫操作。 

1.2.2. 近實時性-refresh操作

當一個文檔寫入Lucene後是不能被立即查詢到的,Elasticsearch提供了一個refresh操作,會定時地調用lucene的reopen(新版本爲openIfChanged)爲內存中新寫入的數據生成一個新的segment,此時被處理的文檔均可以被檢索到。refresh操作的時間間隔由refresh_interval參數控制,默認爲1s, 當然還可以在寫入請求中帶上refresh表示寫入後立即refresh,另外還可以調用refresh API顯式refresh。

1.2.3.  數據存儲可靠性

  1. 引入translog
    當一個文檔寫入Lucence後是存儲在內存中的,即使執行了refresh操作仍然是在文件系統緩存中,如果此時服務器宕機,那麼這部分數據將會丟失。爲此ES增加了translog, 當進行文檔寫操作時會先將文檔寫入Lucene,然後寫入一份到translog,寫入translog是落盤的(如果對可靠性要求不是很高,也可以設置異步落盤,可以提高性能,由配置index.translog.durabilityindex.translog.sync_interval控制),這樣就可以防止服務器宕機後數據的丟失。由於translog是追加寫入,因此性能比較好。與傳統的分佈式系統不同,這裏是先寫入Lucene再寫入translog,原因是寫入Lucene可能會失敗,爲了減少寫入失敗回滾的複雜度,因此先寫入Lucene.
  2. flush操作
    另外每30分鐘或當translog達到一定大小(由index.translog.flush_threshold_size控制,默認512mb), ES會觸發一次flush操作,此時ES會先執行refresh操作將buffer中的數據生成segment,然後調用lucene的commit方法將所有內存中的segment fsync到磁盤。此時lucene中的數據就完成了持久化,會清空translog中的數據(6.x版本爲了實現sequenceIDs,不刪除translog) 
  3. merge操作
    由於refresh默認間隔爲1s中,因此會產生大量的小segment,爲此ES會運行一個任務檢測當前磁盤中的segment,對符合條件的segment進行合併操作,減少lucene中的segment個數,提高查詢速度,降低負載。不僅如此,merge過程也是文檔刪除和更新操作後,舊的doc真正被刪除的時候。用戶還可以手動調用_forcemerge API來主動觸發merge,以減少集羣的segment個數和清理已刪除或更新的文檔。
  4. 多副本機制
    另外ES有多副本機制,一個分片的主副分片不能分片在同一個節點上,進一步保證數據的可靠性。

1.2.4. 部分更新

lucene支持對文檔的整體更新,ES爲了支持局部更新,在Lucene的Store索引中存儲了一個_source字段,該字段的key值是文檔ID, 內容是文檔的原文。當進行更新操作時先從_source中獲取原文,與更新部分合並後,再調用lucene API進行全量更新, 對於寫入了ES但是還沒有refresh的文檔,可以從translog中獲取。另外爲了防止讀取文檔過程後執行更新前有其他線程修改了文檔,ES增加了版本機制,當執行更新操作時發現當前文檔的版本與預期不符,則會重新獲取文檔再更新。

1.3.  ES的寫入流程

ES的任意節點都可以作爲協調節點(coordinating node)接受請求,當協調節點接受到請求後進行一系列處理,然後通過_routing字段找到對應的primary shard,並將請求轉發給primary shard, primary shard完成寫入後,將寫入併發發送給各replica, raplica執行寫入操作後返回給primary shard, primary shard再將請求返回給協調節點。大致流程如下圖: 

寫流程具體操作流程圖

1.3.1. coordinating節點

ES中接收並轉發請求的節點稱爲coordinating節點,ES中所有節點都可以接受並轉發請求。當一個節點接受到寫請求或更新請求後,會執行如下操作:

  1. ingest pipeline
    查看該請求是否符合某個ingest pipeline的pattern, 如果符合則執行pipeline中的邏輯,一般是對文檔進行各種預處理,如格式調整,增加字段等。如果當前節點沒有ingest角色,則需要將請求轉發給有ingest角色的節點執行。
  2. 自動創建索引
    判斷索引是否存在,如果開啓了自動創建則自動創建,否則報錯
  3. 設置routing
    獲取請求URL或mapping中的_routing,如果沒有則使用_id, 如果沒有指定_id則ES會自動生成一個全局唯一ID。該_routing字段用於決定文檔分配在索引的哪個shard上。
  4. 構建BulkShardRequest
    由於Bulk Request中包含多種(Index/Update/Delete)請求,這些請求分別需要到不同的shard上執行,因此協調節點,會將請求按照shard分開,同一個shard上的請求聚合到一起,構建BulkShardRequest
  5. 將請求發送給primary shard
    因爲當前執行的是寫操作,因此只能在primary上完成,所以需要把請求路由到primary shard所在節點
  6. 等待primary shard返回

1.3.2.  primary shard

Primary請求的入口是PrimaryOperationTransportHandler的MessageReceived, 當接收到請求時,執行的邏輯如下

  1. 判斷操作類型
    遍歷bulk請求中的各子請求,根據不同的操作類型跳轉到不同的處理邏輯
  2. 將update操作轉換爲Index和Delete操作
    獲取文檔的當前內容,與update內容合併生成新文檔,然後將update請求轉換成index請求,此處文檔設置一個version v1
  3. Parse Doc
    解析文檔的各字段,並添加如_uid等ES相關的一些系統字段
  4. 更新mapping
    對於新增字段會根據dynamic mapping或dynamic template生成對應的mapping,如果mapping中有dynamic mapping相關設置則按設置處理,如忽略或拋出異常
  5. 獲取sequence Id和Version
    從SequcenceNumberService獲取一個sequenceID和Version。SequcenID用於初始化LocalCheckPoint, verion是根據當前Versoin+1用於防止併發寫導致數據不一致。
  6. 寫入lucene
    這一步開始會對文檔uid加鎖,然後判斷uid對應的version v2和之前update轉換時的versoin v1是否一致,不一致則返回第二步重新執行。 如果version一致,如果同id的doc已經存在,則調用lucene的updateDocument接口,如果是新文檔則調用lucene的addDoucument. 這裏有個問題,如何保證Delete-Then-Add的原子性,ES是通過在Delete之前會加上已refresh鎖,禁止被refresh,只有等待Add完成後釋放了Refresh Lock, 這樣就保證了這個操作的原子性。
  7. 寫入translog
    寫入Lucene的Segment後,會以key value的形式寫Translog, Key是Id, Value是Doc的內容。當查詢的時候,如果請求的是GetDocById則可以直接根據_id從translog中獲取。滿足nosql場景的實時性。
  8. 重構bulk request
    因爲primary shard已經將update操作轉換爲index操作或delete操作,因此要對之前的bulkrequest進行調整,只包含index或delete操作,不需要再進行update的處理操作。
  9. flush translog
    默認情況下,translog要在此處落盤完成,如果對可靠性要求不高,可以設置translog異步,那麼translog的fsync將會異步執行,但是落盤前的數據有丟失風險。
  10. 發送請求給replicas
    將構造好的bulkrequest併發發送給各replicas,等待replica返回,這裏需要等待所有的replicas返回,響應請求給協調節點。如果某個shard執行失敗,則primary會給master發請求remove該shard。這裏會同時把sequenceID, primaryTerm, GlobalCheckPoint等傳遞給replica。
  11. 等待replica響應
    當所有的replica返回請求時,更細primary shard的LocalCheckPoint。

1.3.3.  replica shard

Replica 請求的入口是在ReplicaOperationTransportHandler的messageReceived,當replica shard接收到請求時執行如下流程:

  1. 判斷操作類型
    replica收到的寫如請求只會有add和delete,因update在primary shard上已經轉換爲add或delete了。根據不同的操作類型執行對應的操作
  2. Parse Doc
  3. 更新mapping
  4. 獲取sequenceId和Version 直接使用primary shard發送過來的請求中的內容即可
  5. 寫如lucene
  6. write Translog
  7. Flush translog

1.4. 異常處理

  1. 如果請求在協調節點的路由階段失敗,則會等待集羣狀態更新,拿到更新後,進行重試,如果再次失敗, 則仍舊等集羣狀態更新,直到超時 1 分鐘爲止。超時後仍失敗則進行整體 請求失敗處理
  2. 在主分片寫入過程中,寫入是阻塞的。只有寫入成功,纔會發起寫副本請求。如果主 shard 寫失敗,則整個請求被認爲處理失敗 。 如果有部分副本寫失敗,則整個請求被認爲處理成功。
  3. 無論主分片還是副分片,當寫一個 doc 失敗時,集羣不會重試,而是關閉本地 shard, 然後向 Master彙報,刪除是以 shard爲單位的。刪除後會新建一個分片,然後進行數據同步

1.5.  總結與分析

Elasticsearch建立在Lucene基礎之上,底層採用lucene來實現文件的讀寫操作,實現了文檔的存儲和高效查詢。然後lucene作爲一個搜索庫在應對海量數據的存儲上仍有一些不足之處。
Elasticsearch通過引入分片概念,成功地將lucene部署到分佈式系統中,增強了系統的可靠性和擴展性。
Elasticsearch通過定期refresh lucene in-momory-buffer中的數據,使得ES具有了近實時的寫入和查詢能力。
Elasticsearch通過引入translog,多副本,以及定期執行flush,merge等操作保證了數據可靠性和較高的存儲性能。
Elasticsearch通過存儲_source字段結合verison字段實現了文檔的局部更新,使得ES的使用方式更加靈活多樣。
Elasticsearch基於lucene,又不簡單地只是lucene,它完美地將lucene與分佈式系統結合,既利用了lucene的檢索能力,又具有了分佈式系統的衆多優點。

 

2. 讀流程

  1. 客戶端發送請求到任意一個node,成爲coordinate node
  2. coordinate node對document進行路由,將請求轉發到對應的node,此時會使用round-robin隨機輪詢算法,在primary shard以及其所有replica中隨機選擇一個,讓讀請求負載均衡
  3. 接收請求的node返回document給coordinate node
  4. coordinate node返回document給客戶端

在讀取時,文檔可能己經存在於主分片上,但還沒有複製到副分片。在這種情況下,讀請

求命中副分片時可能會報告文檔不存在,但是命中主分片可能成功返回文擋。 es可以提高分片優先級進行查詢(將該分片放在前列)

2.1. GET

GET讀操作時,具體流程圖

  1. sendRequest是異步
  2. 從es5.x開始,讀取只從lucene中讀取,不從translog讀取,每次讀取會判斷是否需要刷盤
  3. 當一個請求返回onFailure後,取下一個分片地址

2.2. MGET

MGET讀操作時,具體流程圖

  1. 遍歷請求,計算出每個 doc 的路由信息,得到由 shardid爲 key組成的 request map
  2. 循環處理組織好的每個 shard 級請求,調用處理 GET 請求時使用 TransportSingle­ ShardAction#AsyncSingleAction 處理單個 doc 的流程。
  3. 收集 Response,全部 Response 返回後執行 finishHim(),給客戶端返回結果。 回覆的消息中文檔順序與請求的順序一致。如果部分文檔讀取失敗,則不影響其他結果,檢索失敗的 doc 會在回覆信息中標出

 

3. Search流程

search流程分爲兩個步驟  query+fetch

因爲不知道文檔id,需要根據傳入的搜索關鍵詞,查詢出符合條件的文檔id(query階段),然後根據得到的文檔id,查詢出文檔信息(fetch階段)

fetch可以轉換爲get/mget一樣

search 有兩種模式:精確/全文

精確:只返回有/無

全文:返回評分最高的(不保證是你想要的那個),所以需要調整評分

檢索流程圖

3.1. Query流程

Search有兩種類型

DFS_QUERY_THEN_FETCH

QUERY_THEN_FETCH(默認)

DFS會全局評分,算分會更加準確,因爲QUERY_THEN_FETCH模式下的算法是每個分片進行算分,然後彙總取topN,可能存在誤差

QUERY_THEN_FETCH搜索在Query階段的步驟

  1. 請求發到協調節點
  2. 協調節點轉發查詢請求到索引的每個分片上(可能是主,也可能是副本,負載均衡)
  3. 每個分片在本地執行查詢,並且使用本地的Term/Document Frequency信息進行評分,添加結果到大小from+size的本地優先隊列中
  4. 每個分片返回各自的優先隊列中所有的文檔id和排序值給協調節點,協調節點再合併這些值放入自己的優先隊列中,產生一個全局排序後的列表

如果是DSF,就不會在分片本地進行算分,而是信息集中起來到進行算分

 

3.2. Fetch流程

fetch目的就是根據文檔id得到完整的文檔內容

fetch操作流程

  1. 協調節點向相關的node發生GET請求
  2. 分片所在節點查詢出內容後返回數據
  3. 協調節點等待所有文檔都返回了,返回返回給客戶端

假設這裏查詢from=10000,size=10的數據

這裏其實會創建number_of_shards * (from+ size)優先認列,每個分片會創建from+ size大小的優先隊列。爲了避免一些問題,應儘量控制分頁深度

 

 

如果您覺得這篇文章對您有用,歡迎各位關注我的公衆號【Java程序喵】

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章