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

概要

本篇主要介紹聚合查詢的內部原理,正排索引是如何建立的和優化的,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架構社區

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章