.NET Core接入ElasticSearch 7.5

寫在前面

最近一段時間,團隊在升級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

ElasticSearch基礎知識

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去中心化,這是相對於外部而言的,從邏輯上說,與任何一個節點的的通訊和與集羣通訊是沒有區別的。以下圖所示。瀏覽器

cluster

索引

索引保存相關數據的地方,是指向一個或者多個物理分片的邏輯命名空間 。另外,每一個Index的名字必須是小寫。負載均衡

image

文檔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值可以確保全部相關文檔 - 好比屬於相同用戶的全部文檔 - 都會被存儲在相同的分片上。

寫操做原理圖:

write1

寫入的請求流程如圖所示(此圖源自《Elasticsearch權威指南》):

timg

寫入到磁盤流程以下圖所示:

write

因而可知ES的實時並不是是徹底的實時,而是一種準實時(Near-Real-Time)。

讀操做探討

讀分爲兩個階段,查詢階段(Query Phrase)以及聚合提取階段(Fetch Phrase)

查詢階段

協調節點接受到讀請求並將請求分配到相應的分片上(有多是主分片或是副本分片這個機制後續會說起)默認狀況下每一個分片建立10個結果(僅包含 document_id 和 Scores)的優先級隊列並以相關性排序返回給協調節點。

查詢階段若是不特殊指定落入的分片有多是 primary 也有多是 replicas這個根據協調節點的負載均衡算法來肯定。

聚合提取階段

假設查詢落入的分片數爲 N那麼聚合階段就是對 N*10 個結果集進行排序而後再經過已經拿到的 document_id 查到對應的 document 並組裝到隊列裏組裝完畢後將有序的數據返回給客戶端。

read1

  • 客戶端發送請求到任意一個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上搜到源碼,方便個性化開發。

寫入數據:

image

返回的數據中,能夠看到Id是一段字符串,這是由於在寫入的過程當中並無指定,因此會由ES默認生成。固然能夠指定:

image

更新數據:
image

_version值會隨着操做次數,逐漸迭代。

刪除數據:

image

查詢操做:

image

項目升級過程當中遇到的問題

分頁查詢過慢:

初次的查詢使用了深度分頁(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));

參考連接:

https://www.dazhuanlan.com/2020/02/13/5e44f118b75cb/

https://www.toutiao.com/i6824365055832752653

相關文章
相關標籤/搜索