Elastic Search 分布式工作原理
前言
Elastic Search 是分布式的,但是對於我們開發者來說並未過多的參與其中,我們只需啟動對應數量的節點,並給它們分配相同的 cluster.name,讓它們歸屬於同一個集群,創建索引的時候只需指定索引主分片數和副分片數即可,其他的都交給了 ES 內部自己去實現。
這和數據庫的分布式和同源的 solr 實現分布式都是有區別的,數據庫要做集群分布式,比如分庫分表需要我們指定路由規則和數據同步策略等,包括讀寫分離,主從同步等,solr 的分布式也需依賴 zookeeper,但是 Elastic Search 完全屏蔽了這些。
雖然 Elastic Search 天生就是分布式的,並且在設計時屏蔽了分布式的復雜性,但是我們還得知道它內部的原理。
節點交互原理
- ES 和其他中間件一樣,比如 mysql,redis 有 master-slave 模式。ES 集群也會選舉一個節點做為 master 節點。
- master 節點它的職責是維護全局集群狀態,在節點加入或離開集群的時候重新分配分片。
- 所有文檔級別的寫操作不會與 master 節點通信,master 節點並不需要涉及到文檔級別的變更和搜索等操作,ES 分布式不太像 mysql 的 master-slave 模式,mysql 是寫在主庫,然后再同步數據到從庫。而 ES 文檔寫操作是分片上而不是節點上,先寫在主分片,主分片再同步給副分片,因為主分片可以分布在不同的節點上,所以當集群只有一個 master 節點的情況下,即使流量的增加它也不會成為瓶頸,就算它掛了,任何節點都有機會成為主節點。
- 讀寫可以請求任意節點,節點再通過轉發請求到目的節點,比如一個文檔的新增,文檔通過路由算法分配到某個主分片,然后找到對應的節點,將數據寫入到主分片上,然后再同步到副分片上。
寫入文檔
- 客戶端向 node-1 發送新增文檔請求。
- 節點通過文檔的路由算法確定該文檔屬於主分片-P0。因為主分片-P0在 node-3,所以請求會轉發到 node-3。
- 文檔在 node-3 的主分片-P0上新增,新增成功后,將請求轉發到 node-1 和 node-2 對應的副分片-R0上。一旦所有的副分片都報告成功,node-3 向 node-1 報告成功,node-1 向客戶端報告成功。
讀取文檔
- 客戶端向 node-1 發送讀取文檔請求。
- 在處理讀取請求時,node-1 在每次請求的時候都會通過輪詢所有的副本分片來達到負載均衡。
Elastic Search文檔的路由原理
前言
當新增一個文檔的時候,文檔會被存儲到一個主分片中。 Elastic Search 如何知道一個文檔應該存放到哪個分片中呢?當我們創建文檔時,它如何決定這個文檔應當被存儲在分片 1 還是分片 2 中呢?
路由算法
首先這肯定不會是隨機的,否則將來要獲取文檔的時候我們就不知道從何處尋找了。實際上,這個過程是根據下面這個公式決定的:
shard = hash(routing) % number_of_primary_shards
routing 是一個可變值,默認是文檔的 _id,也可以設置成一個自定義的值。routing通過 hash 函數生成一個數字,然后這個數字再除以 number_of_primary_shards (主分片的數量)后得到余數。這個分布在 0 到 number_of_primary_shards-1 之間的余數,就是我們所尋求的文檔所在分片的位置。
這就解釋了為什么我們要在創建索引的時候就確定好主分片的數量並且永遠不會改變這個數量:因為如果數量變化了,那么所有之前路由的值都會無效,文檔也再也找不到了。
新增一個文檔(指定id)
PUT /nba/_doc/1 { "name": "哈登", "team_name": "⽕火箭", "position": "得分后衛", "play_year": "10", "jerse_no": "13" }
查看文檔在哪個分片上
GET /nba/_search_shards?routing=1 返回值如下: { "nodes": { "V1JO7QXLSX-yeVI82WkgtA": { "name": "node-1", "ephemeral_id": "_d96PgOSTnKo6nrJVqIYpw", "transport_address": "192.168.1.101:9300", "attributes": { "ml.machine_memory": "8589934592", "xpack.installed": "true", "ml.max_open_jobs": "20" } }, "z65Hwe_RR_efA4yj3n8sHQ": { "name": "node-3", "ephemeral_id": "MOE_Ne7ZRyaKRHFSWJZWpA", "transport_address": "192.168.1.101:9500", "attributes": { "ml.machine_memory": "8589934592", "ml.max_open_jobs": "20", "xpack.installed": "true" } } }, "indices": { "nba": {} }, "shards": [ [ { "state": "STARTED", "primary": true, "node": "V1JO7QXLSX-yeVI82WkgtA", "relocating_node": null, "shard": 2, "index": "nba", "allocation_id": { "id": "leX_k6McShyMoM1eNQJXOA" } }, { "state": "STARTED", "primary": false, "node": "z65Hwe_RR_efA4yj3n8sHQ", "relocating_node": null, "shard": 2, "index": "nba", "allocation_id": { "id": "6sUSANMuSGKLgcIpBa4yYg" } } ] ] }
Elastic Search 的樂觀鎖
鎖的簡單分類
- 悲觀鎖
- 顧名思義,就是很悲觀,每次去拿數據的時候都認為別人會修改,所以每次在拿數據的時候都會上鎖,這樣別人想拿這個數據就會阻塞,直到它拿到鎖。傳統的關系型數據庫里邊就用到了很多這種鎖機制,比如行鎖,表鎖等,讀鎖,寫鎖等,都是在做操作之前先上鎖。
- 樂觀鎖
- 顧名思義,就是很樂觀,每次去拿數據的時候都認為別人不會修改,所以不會上鎖,但是在更新的時候會判斷一下在此期間別人有沒有去更新這個數據,比如可以使用版本號等機制。樂觀鎖適用於多讀的應用類型,這樣可以提高吞吐量,因為我們 Elastic Search 一般業務場景都是寫少讀多,所以通過樂觀鎖可以在控制並發的情況下又能有效的提高系統吞吐量。
版本號樂觀鎖
Elastic Search 中對文檔的 index, GET 和 delete 請求時,都會返回一個 _version,當文檔被修改時版本號遞增。
所有文檔的更新或刪除 API,都可以接受 version 參數,這允許你在代碼中使用樂觀的並發控制,這里要注意的是版本號要大於舊的版本號,並且加上 version_type=external。
獲取文檔
GET /nba/_doc/1 返回值如下: { "_index": "nba", "_type": "_doc", "_id": "1", "_version": 1, "_seq_no": 4, "_primary_term": 7, "found": true, "_source": { "name": "哈登", "team_name": "⽕火箭", "position": "得分后衛", "play_year": "10", "jerse_no": "13" } }
通過版本號新增文檔(version 要大於舊的 version)
POST /nba/_doc/1?version=2&version_type=external 參數: { "name": "哈登", "team_name": "⽕火箭", "position": "得分后衛", "play_year": "10", "jerse_no": "13" }
倒排索引的原理
我們打開 NBA 中國官網,搜索 james 得到以下結果
假設文檔集合如下圖所示
- 我們是怎么通過 james 查找到名字帶有 james 的球員呢?
- 如果按照這個圖,我們是不是得把這5個文檔遍歷一遍,把文檔帶有 james 的球員查找出來?
- 如果按照這種順序掃描,那每次輸入不同的關鍵字,豈不是要從頭到尾遍歷一遍?
假設文檔集合如下圖所示
- 我們把這個5個球員的名字進行分詞,每個分詞轉成小寫字母,並且以每個分詞分組,統計它所在文檔的位置。
- 當有關鍵字請求過來的時候,將關鍵字轉成小寫,查找出關鍵字匹配到的文檔位置,然后全部返回。
完善倒排索引
參數解釋
- DocId:單詞出現的文檔id
- TF:單詞在某個文檔中出現的次數
- POS:單詞在文檔中出現的位置
Elastic Search 的分詞原理
前言一
我們創建一個文檔
PUT test/_doc/1 參數:
{ "msg": "喬丹是籃球之神" }
我們通過'喬丹'這個關鍵詞來搜索這個文檔
POST /test/_search 參數: { "query": { "match": { "msg": "喬丹" } } }
我們發現能匹配文檔出來,那整一個過程的原理是怎樣的呢?
前言二
我們來試下使用中文分詞器
PUT test/_mapping 參數: { "properties": { "msg_chinese": { "type": "text", "analyzer": "ik_max_word" } } } POST test/_doc/1 參數: { "msg": "喬丹是籃球之神", "msg_chinese": "喬丹是籃球之神" } POST /test/_search 參數: { "query": { "match": { "msg_chinese": "喬" } } } POST /test/_search 參數: { "query": { "match": { "msg": "喬" } } }
為什么同樣是輸入'喬',為什么 msg 能匹配出文檔,而 msg_chinese 不能呢?
寫時分詞
我們來分析 msg 這個字段是怎樣分詞的
POST test/_analyze 參數: { "field": "msg", "text": "喬丹是籃球之神" } 返回值: 喬,丹,是,籃,球,之,神
再來分析 msg_chinese 這個字段是怎樣分詞的
POST test/_analyze
參數: { "field": "msg_chinese", "text": "喬丹是籃球之神" } 返回值: 喬丹, 是, 籃球, 之神
文檔寫入的時候會根據字段設置的分詞器類型進行分詞,如果不指定就是默認的 standard 分詞器。
寫時分詞器需要在 mapping 中指定,而且一旦指定就不能再修改,若要修改必須重建索引。
讀時分詞
由於讀時分詞器默認與寫時分詞器默認保持一致,拿上面的例子,你搜索 msg 字段,那么讀時分詞器為 standard ,搜索 msg_chinese 時分詞器則為 ik_max_word。這種默認設定也是非常容易理解的,讀寫采用一致的分詞器,才能盡最大可能保證分詞的結果是可以匹配的。
允許讀時分詞器單獨設置
POST test/_search 參數: { "query": { "match": { "msg_chinese": { "query": "喬丹", "analyzer": "standard" } } } }
一般來講不需要特別指定讀時分詞器,如果讀的時候不單獨設置分詞器,那么讀時分詞器的驗證方法與寫時一致。
深入分析
分析器(analyzer)有三部分組成
- char filter:字符過濾器
- tokenizer:分詞器
- token filter:token 過濾器
char filter(字符過濾器)
字符過濾器以字符流的形式接收原始文本,並可以通過添加、刪除或更改字符來轉換該流。一個分析器可能有0個或多個字符過濾器。
tokenizer (分詞器)
一個分詞器接收一個字符流,並將其拆分成單個 token (通常是單個單詞),並輸出一個 token 流。比如使用 whitespace 分詞器當遇到空格的時候會將文本拆分成 token。
"eating an apple" >> [eating, an, apple]。一個分析器必須只能有一個分詞器
POST _analyze { "text": "eating an apple", "analyzer": "whitespace" }
token filter (token過濾器)
token 過濾器接收 token 流,並且可能會添加、刪除或更改 tokens。比如一個 lowercase token filter 可以將所有的 token 轉成小寫。一個分析器可能有0個或多個 token 過濾器,它們按順序應用。
standard分析器
- tokenizer
- Stanard tokenizer
- token filters
- Standard Token Filter
- Lower Case Token Filter