Elasticsearch系列---聚合查詢原理

清茶豆奶發表於2020-04-17

概要

本篇主要介紹聚合查詢的內部原理,正排索引是如何建立的和優化的,fielddata的使用,最後簡單介紹了聚合分析時如何選用深度優先和廣度優先。

正排索引

聚合查詢的內部原理是什麼,Elastichsearch是用什麼樣的資料結構去執行聚合的?用倒排索引嗎?

工作原理

我們瞭解到倒排索引對搜尋是非常高效的,但是在排序或聚合操作方面,倒排索引就顯得力不從心,例如我們舉個實際案例,假設我們有兩個文件:

  1. I have a friend who loves smile
  2. love me, I love you

為了建立倒排索引,我們先按最簡單的用空格把每個單詞分開,可以得到如下結果:
*表示該列文件中有這個詞條,為空表示沒有該詞條

Term doc1 doc2
I * *
have *
a *
friend *
who *
loves *
smile *
love *
me *
you *

如果我們要搜尋love you,我們只需要查詢包含每個詞條的文件:

Term doc1 doc2
love *
you *

搜尋是非常高效的,倒排索引根據詞條來排序,我們首先在詞條列表中打到love,然後掃描所有的列,可以快速看到doc2包含這個關鍵詞。

但聚合操作呢?我們需要找到doc2裡所有唯一的詞條,用倒排索引來完成,代價就非常高了,需要迭代索引的每個詞條,看一下有沒有doc2,有就把這個詞條收錄起來,沒有就檢查下一個詞條,直到整個倒排索引全部搜尋完成。很慢而且難以擴充套件,並且 會隨著資料量的增加而增加。

聚合查詢肯定不能用倒排索引了,那就用正排索引,建立的資料結構將變成這樣:

Doc terms
doc1 I, have, a, friend, who, loves, smile
doc2 love, me, I, you

這樣的資料結構,我們要搜尋doc2包含多少個詞條就非常容易了。

倒排索引+正排索引結合的優勢

如果聚合查詢裡有帶過濾條件或檢索條件,先由倒排索引完成搜尋,確定文件範圍,再由正排索引提取field,最後做聚合計算。

這樣才是最高效的

幫助理解兩個索引結構

倒排索引,類似JAVA中Map的k-v結構,k是分詞後的關鍵詞,v是doc文件編號,檢索關鍵字特別容易,但要找到aggs的value值,必須全部搜尋v才能得到,效能比較低。

正排索引,也類似JAVA中Map的k-v結構,k是doc文件編號,v是doc文件內容,只要有doc編號作引數,提取相應的v即可,搜尋範圍小得多,效能比較高。

底層原理

基本原理
  1. 正排索引也是索引時生成(index-time),倒排索引也是index-time。
  2. 核心寫入原理與倒排索引類似,同樣基於不變原理設計,也寫os cache,磁碟等,os cache要存放所有的doc value,存不下時放磁碟。
  3. 效能問題,jvm記憶體少用點,os cache搞大一些,如64G記憶體的機器,jvm設定為16G,os cache記憶體給個32G左右,os cache夠大才能提升正排索引的快取和查詢效率。
column壓縮

正排索引本質上是一個序列化的連結串列,裡面的資料型別都是一致的(不一致說明索引建立不規範),壓縮時可以大大減少磁碟空間、提高訪問速度,如以下幾種壓縮技巧:

  1. 如果所有的數值各不相同(或缺失),設定一個標記並記錄這些值
  2. 如果這些值小於 256,將使用一個簡單的編碼表
  3. 如果這些值大於 256,檢測是否存在一個最大公約數
  4. 如果沒有存在最大公約數,從最小的數值開始,統一計算偏移量進行編碼

例如:
doc1: 550
doc2: 600
doc3: 500

最大公約數50,壓縮後的結果可能是這樣:
doc1: 11
doc2: 12
doc3: 10

同時最大公約數50也會儲存起來。

禁用正排索引

正排索引預設對所有欄位啟用,除了analyzed text。也就是說所有的數字、地理座標、日期和不分析(not_analyzed)字元型別都會預設開啟。針對某些欄位,可以不存正排索引,減少磁碟空間佔用(生產不建議使用,畢竟無法預知需求的變化),示例如下:

# 對欄位sessionId取消正排索引
PUT music
{
  "mappings": {
    "_doc": {
      "properties": {
        "sessionId": {
          "type":   "keyword",
          "doc_values": false
        }
      }
    }
  }
}

同樣的,我們對倒排索引也可以取消,讓一個欄位可以被聚合,但是不能被正常檢索,示例如下:

PUT music
{
  "mappings": {
    "_doc": {
      "properties": {
        "sessionId": {
          "type":   "keyword",
          "doc_values": true,
          "index": false
        }
      }
    }
  }
}

fielddata原理

上一小節我們提到,正排索引對分詞的欄位是不啟用的,如果我們嘗試對一個分詞的欄位進行聚合操作,如music索引的author欄位,將得到如下提示:

Fielddata is disabled on text fields by default. Set fielddata=true on [author] in order to load fielddata in memory by uninverting the inverted index. Note that this can however use significant memory. Alternatively use a keyword field instead.

這段提示告訴我們,如果分詞的欄位要支援聚合查詢,必須設定fielddata=true,然後把正排索引的資料載入到記憶體中,這會消耗大量的記憶體。

解決辦法:

  1. 設定fielddata=true
  2. 使用author.keyword欄位,建立mapping時有內建欄位的設定。

內部原理

analyzed字串的欄位,欄位分詞後佔用空間很大,正排索引不能很有效的表示多值字串,所以正排索引不支援此類欄位。

fielddata結構與正排索引類似,是另外一份資料,構建和管理100%在記憶體中,並常駐於JVM記憶體堆,極易引起OOM問題。

載入過程

fielddata載入到記憶體的過程是lazy載入的,對一個analzyed field執行聚合時,才會載入,而且是針對該索引下所有的文件進行field-level載入的,而不是匹配查詢條件的文件,這對JVM是極大的考驗。

fielddata是query-time建立,動態填充資料,而不是不是index-time建立,

記憶體限制

indices.fielddata.cache.size 控制為fielddata分配的堆空間大小。 當你發起一個查詢,分析字串的聚合將會被載入到fielddata,如果這些字串之前沒有被載入過。如果結果中fielddata大小超過了指定大小,其他的值將會被回收從而獲得空間(使用LRU演算法執行回收)。

預設無限制,限制記憶體使用,但是會導致頻繁evict和reload,大量IO效能損耗,以及記憶體碎片和gc,這個引數是一個安全衛士,必須要設定:

indices.fielddata.cache.size: 20%

監控fielddata記憶體使用

Elasticsearch提供了監控監控fielddata記憶體使用的命令,我們在上面可以看到記憶體使用和替換的次數,過高的evictions值(回收替換次數)預示著記憶體不夠用的問題和效能不佳的原因:

# 按索引使用 indices-stats API
GET /_stats/fielddata?fields=*

# 按節點使用 nodes-stats API
GET /_nodes/stats/indices/fielddata?fields=*

# 按索引節點
GET /_nodes/stats/indices/fielddata?level=indices&fields=*

fields=*表示所有的欄位,也可以指定具體的欄位名稱。

熔斷器

indices.fielddata.cache.size的作用範圍是當前查詢完成後,發現記憶體不夠用了才執行回收過程,如果當前查詢的資料比記憶體設定的fielddata 的總量還大,如果沒有做控制,可能就直接OOM了。

熔斷器的功能就是阻止OOM的現象發生,在執行查詢時,會預算記憶體要求,如果超過限制,直接掐斷請求,返回查詢失敗,這樣保護Elasticsearch不出現OOM錯誤。

常用的配置如下:

  • indices.breaker.fielddata.limit:fielddata的記憶體限制,預設60%
  • indices.breaker.request.limit:執行聚合的記憶體限制,預設40%
  • indices.breaker.total.limit:綜合上面兩個,限制在70%以內

最好為熔斷器設定一個相對保守點的值。fielddata需要與request斷路器共享堆記憶體、索引緩衝記憶體和過濾器快取,並且熔斷器是根據總堆記憶體大小估算查詢大小的,而不是實際堆記憶體的使用情況,如果堆內有太多等待回收的fielddata,也有可能會導致OOM發生。

ngram對fielddata的影響

字首搜尋一章節我們介紹了ngram,ngram會生成大量的詞條,如果這個欄位同時設定fielddata=true的話,那麼會消耗大量的記憶體,這裡一定要謹慎。

fielddata精細化控制

fielddata過濾

過濾的主要目的是去掉長尾資料,我們可以加一些限制條件,如下請求:

PUT /music/_mapping/children
{
  "properties": {
    "tags": {
      "type": "text",
      "fielddata": true,
      "fielddata_frequency_filter": {
        "min": 0.001,
        "max": 0.1,
        "min_segment_size": 500
      }
    }
  }
}

fielddata_frequency_filter過濾器會基於以下條件進行過濾:

  • 出現頻率介紹0.1%和10%之間
  • 忽略文件個數小於500的段檔案

fidelddata是按段來載入的,所以出現頻率是基於某個段計算得來的,如果一個段內只有少量文件,統計詞頻意義不大,等段合併到大的段當中,超過500個文件這個限制,就會納入計算。

fielddata資料對記憶體的佔用是顯而易見的,對fielddata過濾長尾是一種權衡。

序號標記預載入

假設我們的文件用來標記狀態有幾種字串:

  • SUCCESS
  • FAILED
  • PENDING
  • WAIT_PAY

狀態這類的欄位,系統設計時肯定是可以窮舉的,如果我們儲存到Elasticsearch中也用的是字串型別,需要的儲存空間就會多一些,如果我們換成1,2,3,4這種Byte型別的,就可以節省很多空間。

"序號標記"做的就是這種優化,如果文件特別多(PB級別),那節省的空間就非常可觀,我們可以對這類可以窮舉的欄位設定序號標記,如下請求:

PUT /music/_mapping/children
{
  "properties": {
    "tags": {
      "type": "text",
      "fielddata": true,
      "eager_global_ordinals": true
    }
  }
}

深度優先VS廣度優先

Elasticsearch的聚合查詢時,如果資料量較多且涉及多個條件聚合,會產生大量的bucket,並且需要從這些bucket中挑出符合條件的,那該怎麼對這些bucket進行挑選是一個值得考慮的問題,挑選方式好,事半功倍,效率非常高,挑選方式不好,可能OOM,我們拿深度優先和廣度優先這兩個方式來講解。

我們舉個電影與演員的例子,一部電影由多名演員參與,我們搜尋的需求:出演電影最多的10名演員以及他們合作最多的5名演員。

如果是深度優先,示例圖如下:

這種查詢方式需要構建完整的資料,會消耗大量的記憶體。假設我們每部電影有10位演員(1主9配),有10萬部電影,那麼第一層的資料就有10萬條,第二層為9*10萬=90萬條,共100萬條資料。

我們對這100萬條資料進行排序後,取主角出演次數最多的10個,即10條資料,裁掉99加上與主角合作最多的5名演員,共50條資料。

構建了100萬條資料,最終只取50條,記憶體是不是有點浪費?

如果是廣度優先,示例圖如下:

這種查詢方式先查詢電影主角,取前面10條,第一層就只有10條資料,裁掉其他不要的,然後找出跟主角有關聯的配角人員,與合作最多的5名,共50條資料。

聚合查詢預設是深度優先,設定廣度優先只需要設定collect_mode引數為breadth_first,示例:

GET /music/children/_search
{
  "size": 0,
  "aggs": {
    "lang": {
      "terms": {
        "field": "language",
        "collect_mode" : "breadth_first" 
      },
      "aggs": {
        "length_avg": {
          "avg": {
            "field": "length"
          }
        }
      }
    }
  }
}
注意

使用深度優先還是廣度優先,要考慮實際的情況,廣度優先僅適用於每個組的聚合數量遠遠小於當前總組數的情況,比如上面的例子,我只取10位主角,但每部電影都有一位主角,聚合的10位主角組數遠遠小於總組數,所以是適用的。

另外一組按月統計的柱狀圖資料,總組數固定只有12個月,但每個月下的資料量特別大,廣度優先就不適合了。

所以說,使用哪種方式要看具體的需求。

小結

本篇講解的聚合查詢原理,可以根據實際案例做一些演示,加深一下印象,多閱讀一下官網文件,實際工作中這塊用到的地方還是比較多的,謝謝。

專注Java高併發、分散式架構,更多技術乾貨分享與心得,請關注公眾號:Java架構社群
可以掃左邊二維碼新增好友,邀請你加入Java架構社群微信群共同探討技術
Java架構社群

相關文章