概要
本篇主要介紹聚合查詢的內部原理,正排索引是如何建立的和優化的,fielddata的使用,最後簡單介紹了聚合分析時如何選用深度優先和廣度優先。
正排索引
聚合查詢的內部原理是什麼,Elastichsearch是用什麼樣的資料結構去執行聚合的?用倒排索引嗎?
工作原理
我們瞭解到倒排索引對搜尋是非常高效的,但是在排序或聚合操作方面,倒排索引就顯得力不從心,例如我們舉個實際案例,假設我們有兩個文件:
- I have a friend who loves smile
- 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即可,搜尋範圍小得多,效能比較高。
底層原理
基本原理
- 正排索引也是索引時生成(index-time),倒排索引也是index-time。
- 核心寫入原理與倒排索引類似,同樣基於不變原理設計,也寫os cache,磁碟等,os cache要存放所有的doc value,存不下時放磁碟。
- 效能問題,jvm記憶體少用點,os cache搞大一些,如64G記憶體的機器,jvm設定為16G,os cache記憶體給個32G左右,os cache夠大才能提升正排索引的快取和查詢效率。
column壓縮
正排索引本質上是一個序列化的連結串列,裡面的資料型別都是一致的(不一致說明索引建立不規範),壓縮時可以大大減少磁碟空間、提高訪問速度,如以下幾種壓縮技巧:
- 如果所有的數值各不相同(或缺失),設定一個標記並記錄這些值
- 如果這些值小於 256,將使用一個簡單的編碼表
- 如果這些值大於 256,檢測是否存在一個最大公約數
- 如果沒有存在最大公約數,從最小的數值開始,統一計算偏移量進行編碼
例如:
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,然後把正排索引的資料載入到記憶體中,這會消耗大量的記憶體。
解決辦法:
- 設定fielddata=true
- 使用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架構社群微信群共同探討技術