寫在前面
最近一段時間,團隊在升級ElasticSearch(以下簡稱ES),從ES 2.2升級到ES 7.5。也是這段時間,我從零開始,逐步的瞭解了ES,中間也踩了不少坑,所以特地梳理和總結一下相關的技術點。
ES小趣聞:
多年前,一個叫做Shay Banon的剛結婚不久的開發者,由於妻子要去倫敦學習廚師,他便跟著也去了。在他找工作的過程中,為了給妻子構建一個食譜的搜尋引擎,他開始使用Lucene進行嘗試。
直接基於Lucene工作會比較困難,所以Shay開始抽象Lucene程式碼以便可以在應用中新增搜尋功能。他釋出了他的第一個開源專案,叫做“Compass”。
後來Shay找到一份工作,這份工作處在高效能和記憶體資料網格的分散式環境中,因此高效能的、實時的、分散式的搜尋引擎也是理所當然需要的。
然後他決定重寫Compass庫使其成為一個獨立的服務叫做Elasticsearch。
Shay的妻子依舊等待著她的食譜搜尋……
由此看見,一個成功的男人背後總是站著一個女人,所以程式設計師們要早點找到物件,可程式設計師找到女朋友又談何容易,程式猿註定悲傷-_-||。
ElasticSearch基礎知識
EElasticsearch是一個開源的分散式、RESTful 風格的搜尋和資料分析引擎,ES底層基於開源庫Apache Lucene,不過Lucene使用門檻太高,ES隱藏了Lucene使用時的複雜性,使得分散式實時文件搜尋、實時分析引擎、高擴充套件性變得更加容易。
安裝
安裝ES,首先要配置Java SDK,然後配置一下環境變數即可。然後再從官網下載ES安裝包,可以選用預設配置,點選下一步—>安裝。
在瀏覽器上輸入http://localhost:9200/,顯示如下文字,就意味著安裝成功了。
{ "name" : "XXXXXXXXXX", "cluster_name" : "elasticsearch", "cluster_uuid" : "mB04ov3OTvSz7OSe0GtZ_A", "version" : { "number" : "7.5.2", "build_flavor" : "unknown", "build_type" : "unknown", "build_hash" : "8bec50e1e0ad29dad5653712cf3bb580cd1afcdf", "build_date" : "2020-01-15T12:11:52.313576Z", "build_snapshot" : false, "lucene_version" : "8.3.0", "minimum_wire_compatibility_version" : "6.8.0", "minimum_index_compatibility_version" : "6.0.0-beta1" }, "tagline" : "You Know, for Search" }
部分基本概念
節點 & 叢集
叢集由多個節點組成,其中一個節點為主節點,主節點由內部選舉演算法選舉產生。當然主節點是相對的,是相對於內部而言的。ES去中心化,這是相對於外部而言的,從邏輯上說,與任何一個節點的的通訊和與叢集通訊是沒有區別的。如下圖所示。
索引
索引儲存相關資料的地方,是指向一個或者多個物理分片的邏輯名稱空間 。另外,每個Index的名字必須是小寫。
文件
Document的核心後設資料有三個:_index、_type(7.X已經弱化了,8.0開始就會移除)、_id。Document 使用 JSON 格式表示。
分片
一個分片是一個底層的工作單元,它僅儲存了全部資料中的一部分。我們的文件被儲存和索引到分片內,但是應用程式是直接與索引而不是與分片進行互動。
Elasticsearch 是利用分片將資料分發到叢集內各處的。分片是資料的容器,文件儲存在分片內,分片又被分配到叢集內的各個節點裡。 當你的叢集規模擴大或者縮小時, Elasticsearch 會自動的在各節點中遷移分片,使得資料仍然均勻分佈在叢集裡。
一個分片可以是主分片或者副本分片。索引內任意一個文件都歸屬於一個主分片,所以主分片的數目決定著索引能夠儲存的最大資料量。
一個副本分片只是一個主分片的拷貝。副本分片作為硬體故障時保護資料不丟失的冗餘備份,併為搜尋和返回文件等讀操作提供服務。
在索引建立的時候就已經確定了主分片數,但是副本分片數可以隨時修改。
理論上一個主分片最大能夠儲存Integer.MAX_VALUE^128 個文件。
寫操作探討
文件會被儲存到主分片,那麼在多個分片的情況下是如何寫入和精確搜尋的。實際上這是通過以下公式確定的:
shard = hash(routing) % number_of_primary_shards
以上的routing的值是一個任意的字串,它預設被設定成文件的_id欄位,但是也可以被設定成其他指定的值。這個routing字串會被傳入到一個雜湊函式(Hash Function)來得到一個數字,然後該數字會和索引中的主要分片數進行模運算來得到餘數。這個餘數的範圍應該總是在0和number_of_primary_shards - 1之間,它就是一份文件被儲存到的分片的號碼。
這就解釋了為什麼索引中的主要分片數量只能在索引建立時被指定,並且將來都不能在被更改:如果主要分片數量在索引建立後改變了,那麼之前的所有路由結果都會變的不正確,從而導致文件不能被正確地獲取。那麼如何水平擴充套件呢,可以移步Designing for scale。
所有的文件API(get, index, delete, bulk, update)都接受一個routing引數,它用來定製從文件到分片的對映。一個特定的routing值能夠確保所有相關文件 - 比如屬於相同使用者的所有文件 - 都會被儲存在相同的分片上。
寫操作原理圖:
寫入的請求流程如圖所示(此圖源自《Elasticsearch權威指南》):
寫入到磁碟流程如下圖所示:
由此可見ES的實時並非是完全的實時,而是一種準實時(Near-Real-Time)。
讀操作探討
讀分為兩個階段,查詢階段(Query Phrase)以及聚合提取階段(Fetch Phrase)
查詢階段
協調節點接受到讀請求,並將請求分配到相應的分片上(有可能是主分片或是副本分片,這個機制後續會提及),預設情況下,每個分片建立10個結果(僅包含 document_id 和 Scores)的優先順序佇列,並以相關性排序,返回給協調節點。
查詢階段如果不特殊指定,落入的分片有可能是 primary 也有可能是 replicas,這個根據協調節點的負載均衡演算法來確定。
聚合提取階段
假設查詢落入的分片數為 N,那麼聚合階段就是對 N*10 個結果集進行排序,然後再通過已經拿到的 document_id 查到對應的 document 並組裝到佇列裡,組裝完畢後將有序的資料返回給客戶端。
- 客戶端傳送請求到任意一個Node,成為Coordinating node
- Coordinating node對Document進行路由,將請求轉發到對應的Node上,此時會使用Round-Robin隨機輪詢演算法,在Primary Shard以及其所有Replica中隨機選擇一個,讓讀請求負載均衡
- 接收請求的node返回Document給Coordinating node
- Coordinating node返回Document給客戶端
ElasticSearch實戰
ES在.NET平臺上的官方客戶端是NEST,以下操作都是基於該package的。
常用操作
以下操作均基於ES-Head,該工具是一個Chrome外掛,非常簡單實用,而且可以在GitHub上搜到原始碼,方便個性化開發。
寫入資料:
返回的資料中,可以看到Id是一段字串,這是因為在寫入的過程中並沒有指定,所以會由ES預設生成。當然可以指定:
_version值會隨著操作次數,逐漸迭代。
刪除資料:
查詢操作:
專案升級過程中遇到的問題
分頁查詢過慢:
初次的查詢使用了深度分頁(from-size)查詢,當資料達到百萬千萬級別時,已經慢的讓人忍無可忍。所謂深度查詢就是涉及到大量 shard 的查詢時,直接跳頁到幾千甚至上萬頁的資料,協調節點就有當機的風險,畢竟協調節點需要將大量資料彙總起來進行排序,耗費大量的記憶體和 CPU 資源。所以慎用!儘可能用 Scroll API ,即只允許拿到下一頁的資訊,不允許跳頁的情況出現,會避免這種情況的發生。
後來改用了快照分頁(scroll),整個查詢過程非常穩定,方差幾乎可以忽略。該查詢會自動返回一個_scroll_id,通過這個id(經過base64編碼)可以繼續查詢。查詢語句如下:http://localhost:9200/_search/scroll?scroll=1m&scroll_id=c2MkjsjskMkkssllasKKKOzM0NDg1ODpksksks5566HHsaskLLLqi692215。這個語句雖然很快,但是無法做到跳頁查詢,只能一頁一頁的查詢。
快照分頁參考程式碼如下:
1: var searchResponse = client.Search<ElasticsearchTransaction>(p =>
2: p.Query(t =>
3: t.Bool(l => l.Filter(f => f.DateRange(m => m.GreaterThanOrEquals(startTime).Field(d => d.PostDate)))))
4: .From(0)
5: .Size(Configurations.SyncSize)
6: .Index("archive")
7: .Sort(s => s.Ascending(a => a.PostDate)).Scroll("60s"));
8:
9:
10: while(某條件)
11: {
12: searchResponse = client.Scroll<ElasticsearchTransaction>("60s", searchResponse.ScrollId);
13:
14: //跳出迴圈的條件
15: }
模糊查詢:
該場景涉及到多個欄位的模糊查詢,當然,這種查詢是十分消耗效率的,使用的時候要慎重,同時還要控制模糊關鍵字的數量,以儘可能在滿足業務的情況下,提升查詢效率,參考程式碼如下:
1: public static List<IHit<TModel>> GetDataByFuzzy(ElasticClient client9200)
2: {
3: string[] fieldList =
4: {
5: "filed1",
6: "filed2",
7: "filed3",
8: "filed4",
9: "filed5",
10: "filed6",
11: "filed7",
12: "filed8",
13: "filed9"
14: };
15:
16:
17: string term = string.Concat("*", string.Join("* *", "i u a n".Split(new[] { ' ' }, StringSplitOptions.RemoveEmptyEntries)), "*");
18:
19: var result = client9200.Search<TModel>(p => p.Query(q => q.Bool(b=>b.Must(t=>t.QueryString(c => c
20: .Fields(fieldList)
21: .Query(term)
22: .Boost(1.1)
23: .Fuzziness(Fuzziness.Auto)
24: .MinimumShouldMatch(2)
25: .FuzzyRewrite(MultiTermQueryRewrite.ConstantScoreBoolean)
26: .TieBreaker(1)
27: .Lenient()
28: )).Filter(f=>
29: f.Term(t=>
30: t.Field(d=>d.AccountKey).Value("123456789")))))
31: .ScriptFields(sf => sf.ScriptField("datetime1",
32: sc => sc.Source("doc['datetime1'].value == null?doc['datetime2'].value: doc['datetime1'].value")))
33: .Source(true)
34: .Index("archive")
35: .From(0)
36: .Size(10000)
37: .Sort(s => s.Descending(a => a.CreateDate)));
38:
39:
40: return result.Hits.Select(p=>p.Source).ToList();
41: }
關於排序
在本次的ES優化升級過程中,關於排序的操作可以說是很糾結的。按照業務要求,要根據兩個時間型別的欄位進行排序,如果某個為空,就按照不為空的排序,使得其排序結果達到穿插的效果,而不是像SQL語句那樣order by field1, field2的排序結果那樣。
找出解決方案的過程很痛苦,因為官方的demo無法執行,這時間型別的操作是個巨坑,經過多方嘗試,終於在檢視ElasticSearch原始碼的情況下,找到了解決方案。
Github地址:https://github.com/elastic/elasticsearch/blob/master/server/src/main/java/org/elasticsearch/script/JodaCompatibleZonedDateTime.java,第411行
查詢語句如下:
1: {
2: "from": 0,
3: "query": {
4: "bool": {
5: "filter": [
6: {
7: "term": {
8: "UserId": {
9: "value": "123456789"
10: }
11: }
12: }
13: ]
14: }
15: },
16: "size": 10,
17: "sort": [
18: {
19: "_script": {
20: "script": {
21: "source": "doc.DateTime1.empty?doc.DateTime2.value.toInstant().toEpochMilli():doc.DateTime1.value.toInstant().toEpochMilli()"
22: },
23: "type": "number",
24: "order": "desc"
25: }
26: }
27: ]
28: }
C#參考程式碼如下:
1: var searchResponse = _elasticsearchClient.Search<T>(s => s
2: .Query(q => q.Bool(b => b
3: .Filter(m => m.Term(t => t.Field(f => f.UserId).Value(userId)),m => m.QueryString(qs => qs.Fields(fieldList).Query(term.PreProcessQueryString())))))
4: .Index(indexName)
5: .ScriptFields(sf => sf
6: .Source(true)
7: .Sort(s=>s.Script(sr=>sr.Script(doc => doc.Source("doc.DateTime1.empty ? doc.DateTime2.value.toInstant().toEpochMilli() : doc.DateTime1.value.toInstant().toEpochMilli()"))))
8: .From(startIndex)
9: .Size(pageSize));
參考連結: