Elasticsearch 基礎入門詳文

作者:lynneyli,騰訊 IEG 運營開發工程師

Elasticsearch(簡稱:ES)功能強大,其背後有很多默認值,或者默認操作。這些操作優劣並存,優勢在於我們可以迅速上手使用 ES,劣勢在於,其實這些默認值的背後涉及到很多底層原理,怎麼做更合適,只有數據使用者知道。用 ES 的話來說,你比 ES 更懂你的數據,但一些配置信息、限制信息,還是需要在瞭解了 ES 的功能之後進行人工限制。

你是否遇到:在使用了一段時間 ES 之後,期望使用 ES 的其他功能,例如聚合、排序,但因爲字段類型受限,無奈只能進行 reindex 等一系列問題?

題主在遇到一些問題後,發現用 ES 很簡單,但是會用 ES 很難。這讓我下定決心一定好好了解 ES,也就出現了本文。

前言

ES(全稱 Elastic Search)是一款開源、近實時、高性能的分佈式搜索引擎。在近 3 年的熱門搜索引擎類數據統計中,ES 都霸居榜首(數據來源:DBRaking),可見的其深受大家的喜愛。

隨着 ES 的功能越來越強大,其和數據庫的邊界也越來越小,除了進行全文檢索,ES 也支持聚合 / 排序。ES 底層基於 Lucene 開發,針對 Lucene 的侷限性,ES 提供了 RESTful API 風格的接口、支持分佈式、可水平擴展,同時它可以被多種編程語言調用。

ES 很多基礎概念以及底層實現其本質是 Lucene 的概念。

ps:本文所有的 dsl 查詢、結果展示均基於 ES v7.7

歷史背景

Lucene 的歷史背景

下圖這個人叫 Doug Cutting,他是 Hadoop 語言和 Lucene 工具包的創始人。Doug Cutting 畢業於斯坦福大學,在 Xerox 積累了一定的工作經驗後,從 1997 年開始,利用業餘時間開發出了 Lucene。Lucene 面世於 1999 年,並於 2005 年成爲 Apache 頂級開源項目。

Lucene 的特點:

Lucene 的侷限性:

ES 的歷史背景

ElasticSearch 創始人 - Shay Banon

ES 多個版本可能出現破壞性變更,例如,在 6.x,ES 不允許一個 Index 中出現多個 Type。在 ES 的官網,每個版本都對應着一個使用文檔。

在使用 ES 之前,最好先了解 ES 的版本歷史。下面列出一些比較重大的更新版本,可以在瞭解了基本概念之後再看。

基礎概念介紹

下圖簡單概述了 index、type、document 之間的關係,type 在新版本中廢棄,所以畫圖時特殊標識了一下。

index

Index 翻譯過來是索引的意思。在 ES 裏,索引有兩個含義:

type

在 6.x 之前, index 可以被理解爲關係型數據庫中的【數據庫】,而 type 則可以被認爲是【數據庫中的表】。使用 type 允許我們在一個 index 裏存儲多種類型的數據,數據篩選時可以指定 typetype 的存在從某種程度上可以減少 index 的數量,但是 type 存在以下限制:

以上限制要求我們,只有同一個 index 的中的 type 都有類似的映射 (mapping) 時,才勉強適用 type 。否則,使用多個 type 可能比使用多個 index 消耗的資源更多。

這大概也是爲什麼 ES 決定廢棄 type 這個概念,個人感覺 type 的存在,就像是一個語法糖,但是並未帶來太大的收益,反而增加了複雜度。

document

index 中的單條記錄稱爲 document (文檔),可以理解爲表中的一行數據。多條 document 組成了一個 index

"hits" : {
    "total" : {
      "value" : 3563,
      "relation" : "eq"
    },
    "max_score" : 1.0,
    "hits" : [
      {
        "_index" : "test",
        "_type" : "_doc",
        "_id" : "3073",
        "_score" : 1.0,
        "_source" : {
   ...
   }
  }
]

上圖爲 ES 一條文檔數據,其中:

field

一個 document 會由一個或多個 field 組成,field 是 ES 中數據索引的最小定義單位,下面僅列舉部分常用的類型。

⚠️ 在 ES 中,沒有數組類型,任何字段都可以變成數組。

string
text
PUT my_index
{
  "mappings"{
    "properties"{
      "city"{
        "type""text",
        "fields"{
          "raw"{
            "type":  "keyword"
          }
        }
      }
    }
  }
}
keyword
numeric

long, integer, short, byte, double, float, half_float, scaled_float...

"price"{
        "type""scaled_float",
        "scaling_factor"100
      }

mapping

mapping 是一個定義 document 結構的過程, mapping 中定義了一個文檔所包含的所有 field 信息。

定義字段索引過多會導致爆炸的映射, 這可能會導致內存不足錯誤和難以恢復的情況, mapping 提供了一些配置對 field 進行限制,下面列舉幾個可能會比較常見的:

dynamic mapping

在索引 document 時,ES 的動態 mapping 會將新增內容中不存在的字段,自動的加入到映射關係中。ES 會自動檢測新增字段的邏輯,並賦予其默認值。

截取了部分 ES 官方文檔中的話術,ES 認爲一些自動化的操作會讓新手上手更容易。但是同時,又提出,你肯定比 ES 更瞭解你的數據,可能剛開始使用起來覺得比較方便,但是最好還是自己明確定義映射關係。

(🙄️ 個人認爲,這些自動操作是在用戶對 ES 沒有太多瞭解的情況下進行的,如果剛開始依賴了這些默認的操作,例如:新增字段使用了 ES 賦予的默認值,如果後續有分析、排序、聚合等操作可能會有一定限制)。

⚠️ 在 ES 中,刪除 / 變更 field 定義,需要進行 reindex ,所以在構建 mapping 結構時記得評估好字段的用途,以使用最合適的字段類型。

部分查詢關鍵字介紹

match&match_phrase
GET /_analyze
{
  "text"["這是測試"],
  "analyzer""ik_smart"
}
//Result
{
  "tokens" : [
    {
      "token" : "這是",
      "start_offset" : 0,
      "end_offset" : 2,
      "type" : "CN_WORD",
      "position" : 0
    },
    {
      "token" : "測試",
      "start_offset" : 2,
      "end_offset" : 4,
      "type" : "CN_WORD",
      "position" : 1
    }
  ]
}


//match+analyzer:ik_smart
//可以查詢到所有describe中包含【這是測試】、【這是】、【測試】的doc
GET /doraon_recommend_tab_test/_search
{
  "query"{
    "match"{
      "describe":{
      "query""這是測試",
      "analyzer""ik_smart"
      }
    }
  }
}

//match_phrase + analyzer:ik_smart + slop=0(默認)
//可以查詢到所有describe中包含【這是】+【測試】token間隔爲0的doc(說人話就是:模糊匹配【這是測試】)
GET /doraon_recommend_tab_test/_search
{
  "_source""describe",
  "query"{
    "match_phrase"{
      "describe""這是測試"
    }
  }
}

//match_phrase + analyzer:ik_smart + slop=1
//可以查詢到所有describe中包含【這是】+【測試】token間隔爲1的doc
//例如某個doc中describe爲【這是一個測試】,【這是一個測試】分詞後的token分別爲【這是】【一個】【測試】
//【這是】和【測試】之間間隔了1個token【一個】,所以可以被查詢到;同理【這是一個我的測試】查詢不到
GET /test/_search
{
  "query"{
    "match_phrase"{
      "describe":{
        "query""這是測試",
        "analyzer""ik_smart",
        "slop"1
      }
    }
  }
}
term

term 是進行精確查找的關鍵;在 Lucene 中,term 是中索引和搜索的最小單位。一個 field 會由一個或多個 term 組成, term 是由 field 經過 Analyzer(分詞)產生。Term Dictionaryterm 詞典,是根據條件查找 term 的基本索引。

以上兩點均是因爲 text 字段存儲的是分詞結果,如果字段值爲空,分詞結果將不會存儲 term 信息, keyword 字段存儲的是原始內容。

GET /test/_termvectors/123?fields=content
{
  "_index" : "[your index]",
  "_type" : "_doc",
  "_id" : "123",
  "_version" : 2,
  "found" : true,
  "took" : 0,
  "term_vectors" : { }
}

GET /test/_termvectors/234?fields=card_pic
{
  "_index" : "[your index]",
  "_type" : "_doc",
  "_id" : "234",
  "_version" : 1,
  "found" : true,
  "took" : 0,
  "term_vectors" : {
    "card_pic" : {
      "field_statistics" : {
        "sum_doc_freq" : 183252,
        "doc_count" : 183252,
        "sum_ttf" : 183252
      },
      "terms" : {
        "" : {
          "term_freq" : 1,
          "tokens" : [
            {
              "position" : 0,
              "start_offset" : 0,
              "end_offset" : 0
            }
          ]
        }
      }
    }
  }
}

分析器 Analyzer

在上一篇文章中提到了,針對全文索引類型,一定要選擇合適的分析器,現在我們就來了解一下分析器~

Analyzer 主要是對輸入的文本類內容進行分析(通常是分詞),將分析結果以 term 的形式進行存儲。

Analyzer 由三個部分組成:Character FiltersTokenizerToken Filters

ES 內置的分析器有 Standard AnalyzerSimple AnalyzerWhitespace AnalyzerStop AnalyzerKeyword AnalyzerPattern AnalyzerLanguage AnalyzersFingerprint Analyzer,並且支持定製化

這裏的內置分詞器看起來都比較簡單,這裏簡單介紹一下 Standard Analyzer、Keyword Analyzer,其他的分詞器大家感興趣可以自行查閱。

text 類型默認 analyzer:Standard Analyzer

Standard Analyzer 的組成部分:

如果 text 類型沒有指定 Analyzer,Standard Analyzer,前面我們已經瞭解了 ES 分析器的結構,理解它的分析器應該不在話下。Unicode 文本分割算法依據的標準,給出了文本中詞組、單詞、句子的默認分割邊界。該附件在 notes 中提到,像類似中文這種複雜的語言,並沒有明確的分割邊界,簡而言之就是說,中文並不適用於這個標準

通常我們的全文檢索使用場景都是針對中文的,所以我們在創建我們的映射關係時,一定要指定合適的分析器。

keyword 類型默認 analyzer:Keyword Analyzer

Keyword Analyzer 本質上就是一個 "noop" Analyzer,直接將輸入的內容作爲一整個 token。

第三方中文分詞器 ik

github 地址:https://github.com/medcl/elasticsearch-analysis-ik

IK Analyzer 是一個開源的,基於 java 語言開發的輕量級的中文分詞工具包。從 2006 年 12 月推出 1.0 版開始, IKAnalyzer 已經推出了 4 個大版本。最初,它是以開源項目 Luence 爲應用主體的,結合詞典分詞和文法分析算法的中文分詞組件。從 3.0 版本開始,IK 發展爲面向 Java 的公用分詞組件,獨立於 Lucene 項目,同時提供了對 Lucene 的默認優化實現。在 2012 版本中,IK 實現了簡單的分詞歧義排除算法,標誌着 IK 分詞器從單純的詞典分詞向模擬語義分詞衍化。

使用方式:

// mapping創建
PUT /[your index]
{
  "mappings"{
    "properties"{
      "text_test":{
        "type""text",
        "analyzer""ik_smart"
      }
    }
  }
}

// 新建document
POST /[your index]/_doc
{
  "text_test":"我愛中國"
}

//查看term vector
GET /[your index]/_termvectors/ste3HYABZRKvoZUCe2oH?fields=text_test
//結果包含了 “我”“愛”“中國”

相似性得分 similarity

classic:基於 TF/IDF 實現,V7 已禁止使用,V8 徹底廢除(僅供瞭解)

TF/IDF 介紹文章:https://zhuanlan.zhihu.com/p/31197209

TF/IDF 使用逆文檔頻率作爲權重,降低常見詞彙帶來的相似性得分。從公式中可以看出,這個相似性算法僅與文檔詞頻相關,覆蓋不夠全面。例如:缺少文檔長度帶來的權重,當其他條件相同,“王者榮耀” 這個查詢關鍵字同時出現在短篇文檔和長篇文檔中時,短篇文檔的相似性其實更高。

在 ESV5 之前,ES 使用的是 Lucene 基於 TF/IDF 自實現的一套相關性得分算法,如下所示:

score(q,d)  =
            queryNorm(q)
          · coord(q,d)
          · ∑ (
                tf(t in d)
              · idf(t)²
              · t.getBoost()
              · norm(t,d)
            ) (t in q)

Lucene 已經針對 TF/IDF 做了儘可能的優化,但是有一個問題仍然無法避免:

另一條曲線是 BM25 算法相似性得分隨詞頻的關係,它的結果隨詞頻上升而趨於一個穩定值。

BM25:默認

BM25 介紹文章:https://en.wikipedia.org/wiki/Okapi_BM25 ,對 BM25 的實現細節我們在這裏不做過多闡述,主要了解一下 BM25 算法相較於之前的算法有哪些優點:

我們在查詢過程可以通過設置 "explain":true 查看相似性得分的具體情況

GET /[your index]/_search
{
  "explain": true,
  "query"{
    "match"{
      "describe""測試"
    }
  }
}
//簡化版查詢結果
{
    "_explanation"{
        "value": 0.21110919,
        "description""weight(describe:測試 in 1) [PerFieldSimilarity], result of:",
        "details"[
            {
                "value": 0.21110919,
                "description""score(freq=1.0), computed as boost * idf * tf from:",
                "details"[
                    {
                        "value": 2.2,
                        "description""boost",
                        "details"[]
                    },
                    {
                        "value": 0.18232156,
                        "description""idf, computed as log(1 + (N - n + 0.5) / (n + 0.5)) from:",
                        "details"[...]
                    },
                    {
                        "value": 0.5263158,
                        "description""tf, computed as freq / (freq + k1 * (1 - b + b * dl / avgdl)) from:",
                        "details"[...]
                    }
                ]
            }
        ]
    }
}
boolean

boolean 相似性非常好理解,只能根據查詢條件是否匹配,其最終值其實就是 query boost 值。

query and filter context

他們本質上的區別是是否參與相關性得分。在查詢過程中,官方建議可以根據實際使用情況配合使用 filterquery 。但是如果你的查詢並不關心相關性得分,僅關心查詢到的結果,其實兩者差別不大

題主本來以爲使用 filter 可以節省計算相似性得分的耗時,但是使用 filter 同樣會進行相似性得分,只是通過特殊的方式將其 value 置爲了 0。

//only query
GET /[your index]/_search
{
  "explain": true,
  "query"{
    "bool"{
      "must"[
        {"match"{"describe""測試"}},
        {"term"{"tab_id": 5}}
      ]
    }
  }
}
//簡化_explanation結果
{
    "_explanation"{
        "value": 1.2111092,
        "description""sum of:",
        "details"[
            {
                "value": 0.21110919,
                "description""weight(describe:測試 in 1) [PerFieldSimilarity], result of:"
            },
            {
                "value": 1,
                "description""tab_id:[5 TO 5]",
                "details"[]
            }
        ]
    }
}

//query+filter
GET /[your index]/_search
{
  "explain": true,
  "query"{
    "bool"{
      "filter"[
        {"term"{"tab_id""5"}}
      ],
      "must"[
        {"match"{"describe""測試"}}
      ]
    }
  }
}
//簡化_explanation結果
{
    "_explanation"{
        "value": 0.21110919,
        "description""sum of:",
        "details"[
            {
                "value": 0.21110919,
                "description""weight(describe:測試 in 1) [PerFieldSimilarity], result of:"
            },
            {
                "value": 0,
                "description""match on required clause, product of:",
                "details"[
                    {
                        "value": 0,
                        "description""# clause",
                        "details"[]
                    },
                    {
                        "value": 1,
                        "description""tab_id:[5 TO 5]",
                        "details"[]
                    }
                ]
            }
        ]
    }
}

排序 sort

在執行 ES 查詢時,默認的排序規則是根據相關性得分倒排,針對非全文索引字段,可以指定排序方式,使用也非常簡單。

//查詢時先根據tab_id降序排列,若tab_id相同,則根究status升序排列
GET /[your index]/_search
{
  "sort"[
    {"tab_id"{"order""desc"}},
    {"status"{"order""asc"}}
  ]
}
好坑啊:缺失數值類字段的默認值並不是 0

事情的背景

題主使用的編程語言是 golang,通常使用 pb 定義結構體,生成對應的 go 代碼,默認情況下,結構體字段的 json tag 都會包含 omitempty 屬性,也就是忽略空值,如果數字類型的 value 爲 0,進行 json marshall 時,不會生成對應字段。

事情的經過

剛好題主通過以上方式進行文檔變更,所以實際上如果某個數值字段爲 0,它並沒有被存儲。

在題主的功能邏輯裏,剛好需要對某個數值字段做升序排列,驚奇地發現我認爲的字段值爲 0 的文檔,出現在了列表最末。

事情的調查結果

針對缺失數值類字段的默認值並不是 0,ES 默認會保證排序字段沒有 value 的文檔被放在最後,默認情況下:

好消息是,ES 爲我們提供了 missing 參數,我們可以指定缺失值填充,但是它太隱蔽了 😭,其默認值爲 _last

GET /[your index]/_search
{
  "sort"[
    {"num"{"order""asc"}}
  ]
}
//簡化結果
{
    "hits"[
      {"sort"[1]},
        {"sort"[9223372036854775807]},
        {"sort"[9223372036854775807]}
    ]
}

GET /your_index/_search
{
  "sort"[
    {"num"{"order""desc"}}
  ]
}

//簡化結果
{
    "hits"[
        {"sort"[1]},
        {"sort"[-9223372036854775808]},
        {"sort"[-9223372036854775808]}
    ]
}

// with missing
GET /[your index]/_search
{
  "sort"[
    {
      "num"{
        "order""asc",
        "missing""0"
      }
    }
  ]
}

//簡化結果
{
    "hits"[
        {"sort"[0]},
        {"sort"[0]},
        {"sort"[1]}
    ]
}
使用技巧:用 function score 實現自定義排序

不知道大家是否遇到過類似的場景:期望查詢結果按照某個類型進行排序,或者查詢結果順序由多個字段的權重組合決定。

具體解決方案需要根據業務具體情況而定,這裏給出一種基於 ES 查詢的解決方案。ES 爲我們提供了 function score ,支持自定義相關性得分 score 的生成方式,部分參數介紹:

舉點實際的栗子,假設咱們有一個存放水果的 Index:

GET /fruit_test/_search
{
  "explain": true,
  "query"{
    "function_score"{
      "functions"[
        {
          "filter"{"term"{"type""pear"}},
          "weight"1
        },
        {
          "filter"{"term"{"type""apple"}},
          "weight"2
        }
      ],
      "boost": 1,
      "score_mode""sum"
    }
  }
}
GET /fruit_test/_search
{
  "query"{
    "function_score"{
      "query"{"range"{"stock"{"gt": 0}}
      },
      "functions"[
        {
          "filter"{"term"{"color""green"}},
          "weight"1
        },
        {
          "filter"{"term"{"color""red"}},
          "weight"2
        },
        {
          "filter"{"term"{"type""pear"}},
          "weight"3
        },
        {
          "filter"{"term"{"type""apple"}},
          "weight"4
        },
        {
          "filter"{"term"{"pre_sale": false}},
          "weight"7
        }
      ],
      "boost": 1,
      "boost_mode""sum",
      "score_mode""sum"
    }
  },
  "sort"[
    {"_score"{"order""desc"}},
    {"price_per_kg"{"order""asc"}
    }
  ]
}

聚合 aggs

聚合操作可以幫助我們將查詢數據按照指定的方式進行歸類。常見的聚合方式,諸如:max、min、avg、range、根據 term 聚合等等,這些都比較好理解,功能使用上也沒有太多疑惑,下面主要介紹題主在使用過程中遇到的坑點以及指標聚合嵌套查詢。

ES 還支持 pipline aggs,主要針對的對象不是文檔集,而是其他聚合的結果,感興趣的同學可以自行了解。

好坑啊:ES 默認的時間格式爲毫秒級時間

如果你有訴求,需要針對秒級時間戳進行時間聚合,例如:某銷售場景下,我們期望按小時 / 天 / 月 / 進行銷售單數統計。

那麼有以下兩種常見錯誤使用方式需要規避:

正確的做法:創建 mapping,明確指定時間的格式爲秒級時間戳。

PUT /date_test/_mapping
{
  "properties":{
    "create_time":{
      "type":"date",
      "format" : "epoch_second"
    }
  }
}

//以年爲時間間隔 進行統計
GET /date_test/_search
{
  "size": 0,
  "aggs"{
    "test"{
      "date_histogram"{
        "field""create_time",
        "format""yyyy",
        "interval""year"
      }
    }
  }
}
//從查詢結果可以看出來,實際計算時ES會幫我們把秒級時間戳轉成毫秒級時間戳
{
"aggregations" : {
    "test" : {
      "buckets" : [
        {
          "key_as_string" : "2018",
          "key" : 1514764800000,
          "doc_count" : 2
        },
        {
          "key_as_string" : "2019",
          "key" : 1546300800000,
          "doc_count" : 0
        },
        {
          "key_as_string" : "2020",
          "key" : 1577836800000,
          "doc_count" : 3
        }
      ]
    }
  }
}
聚合嵌套查詢

上面介紹了根據時間聚合,還是以剛剛的例子來說,某銷售場景下,我們期望在根據時間統計銷售單數的同時,統計出時間區間內的銷售總金額。

GET /date_test/_search
{
  "size": 0,
  "aggs"{
    "test"{
      "date_histogram"{
        "field""create_time",
        "format""yyyy",
        "interval""year"
      },
      "aggs"{
        "sum_profit"{
          "sum"{
            "field""profit"
          }
        }
      }
    }
  }
}

{
"aggregations" : {
    "test" : {
      "buckets" : [
        {
          "key_as_string" : "2018",
          "key" : 1514764800000,
          "doc_count" : 2,
          "sum_profit" : {
            "value" : 200.0
          }
        },
        {
          "key_as_string" : "2019",
          "key" : 1546300800000,
          "doc_count" : 0,
          "sum_profit" : {
            "value" : 0.0
          }
        },
        {
          "key_as_string" : "2020",
          "key" : 1577836800000,
          "doc_count" : 3,
          "sum_profit" : {
            "value" : 3000.0
          }
        }
      ]
    }
  }
}
使用技巧:自實現 distinct

ES 默認並不支持 distinct,可以嘗試使用 terms 聚合,解析結果中的 key

{
"aggregations" : {
    "test" : {
      "doc_count_error_upper_bound" : 0,
      "sum_other_doc_count" : 0,
      "buckets" : [
        {"key" : "1","doc_count" : 2},
        {"key" : "10","doc_count" : 2},
        {"key" : "16","doc_count" : 2}
      ]
    }
  }
}

索引別名、索引生命週期策略、索引模版

ES 的一個比較常見的應用場景是存儲日誌流,自實現一套這樣的系統就可以結合上述 3 個功能。

參考

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/GG_zrQlaiP2nfPOxzx_j9w