最近一段時間,團隊在升級ElasticSearch(如下簡稱ES),從ES 2.2升級到ES 7.5。也是這段時間,我從零開始,逐步的瞭解了ES,中間也踩了很多坑,因此特意梳理和總結一下相關的技術點。html
ES小趣聞:java
多年前,一個叫作Shay Banon的剛結婚不久的開發者,因爲妻子要去倫敦學習廚師,他便跟着也去了。在他找工做的過程當中,爲了給妻子構建一個食譜的搜索引擎,他開始使用Lucene進行嘗試。
直接基於Lucene工做會比較困難,因此Shay開始抽象Lucene代碼以即可以在應用中添加搜索功能。他發佈了他的第一個開源項目,叫作「Compass」。
後來Shay找到一份工做,這份工做處在高性能和內存數據網格的分佈式環境中,所以高性能的、實時的、分佈式的搜索引擎也是理所固然須要的。
而後他決定重寫Compass庫使其成爲一個獨立的服務叫作Elasticsearch。
Shay的妻子依舊等待着她的食譜搜索……node
由此看見,一個成功的男人背後老是站着一個女人,因此程序員們要早點找到對象,可程序員找到女友又談何容易,程序猿註定悲傷-_-||。git
EElasticsearch是一個開源的分佈式、RESTful 風格的搜索和數據分析引擎,ES底層基於開源庫Apache Lucene,不過Lucene使用門檻過高,ES隱藏了Lucene使用時的複雜性,使得分佈式實時文檔搜索、實時分析引擎、高擴展性變得更加容易。程序員
安裝ES,首先要配置Java SDK,而後配置一下環境變量便可。而後再從官網下載ES安裝包,能夠選用默認配置,點擊下一步—>安裝。github
在瀏覽器上輸入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的名字必須是小寫。負載均衡
文檔elasticsearch
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 並組裝到隊列裏,組裝完畢後將有序的數據返回給客戶端。
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));
參考連接: