1.Doc Values
聚合使用一個叫Doc Values的數據結構。Doc Values使聚合更快、更高效且內存友好。
Doc Values的存在是因為倒排索引只對某些操作是高效的。倒排索引的優勢在於查找包含某個項的文檔,而反過來確定哪些項在單個文檔里並不高效。
結構類似如下:
Doc Terms ----------------------------------------------------------------- Doc_1 | brown, dog, fox, jumped, lazy, over, quick, the Doc_2 | brown, dogs, foxes, in, lazy, leap, over, quick, summer Doc_3 | dog, dogs, fox, jumped, over, quick, the
Doc values在索引的時候生成,伴隨倒排索引的創建。像倒排索引一樣基於per-segment,且是不可變,被序列化存儲到磁盤。通過序列化持久化數據結構到磁盤,可以以來操作系統的文件緩存來代替JVM heap。但是當工作空間需要的內存很大時,Doc Values會被置換出內存,這樣會導致訪問速度降低,但是如果放在JVM heap,將直接導致內存溢出錯誤。
Doc Values默認對除了分詞的所有字段起作用。因為分此字段產生太多tokens且Doc Values對其並不是很有效。
由於Doc Values默認開啟,如果你不會執行基於一個確定的子段 聚合、排序或執行腳本(Script ),你可以選擇關閉Doc Values,這可以為你節省磁盤空間,提高索引數據的速度。
PUT my_index { "mappings": { "my_type": { "properties": { "session_id": { "type": "string", "index": "not_analyzed", "doc_values": false } } } } }
設置doc_values: false,這個字段將不再支持據聚合、排序和腳本執行(Script);
同時也可以對倒排索引做類似的配置:
PUT my_index { "mappings": { "my_type": { "properties": { "customer_token": { "type": "string", "index": "not_analyzed", "doc_values": true, "index": "no" } } } } }
這個可以支持聚合,但不支持查詢,因為不會對這個字段生成倒排索引。
2.聚合與分析
分析對聚合有兩方面的影響,
2.1.分析影響聚合中使用的 tokens
例如字符串 "New York" 被分析/分析成 ["new", "york"] 。這些單獨的 tokens ,都被用來填充聚合計數,所以我們最終看到 new 的數量而不是 New York。
可以通過加multifield來修正,如下:聚合時指定為未分詞的raw字段。
PUT /agg_analysis { "mappings": { "data": { "properties": { "state" : { "type": "string", "fields": { "raw" : { "type": "string", "index": "not_analyzed" } } } } } } }
2.1.Doc values 不支持 analyzed
Doc values 不支持 analyzed 字符串字段,因為它們不能很有效的表示多值字符串。 Doc values 最有效的是,當每個文檔都有一個或幾個 tokens 時, 但不是無 數的,分詞字符串(想象一個 PDF ,可能有幾兆字節並有數以千計的獨特 tokens)。
出於這個原因,doc values 不生成分詞的字符串,然而,這些字段仍然可以使用聚合,那怎么可能呢?
答案是一種被稱為 fielddata 的數據結構。與 doc values 不同,
fielddata 構建和管理 100% 在內存中,常駐於 JVM 內存堆。這意味着它本質上是不可擴展的,有很多邊緣情況下要提防。
本章的其余部分是解決在分詞字符串上下文中 fielddata 的挑戰。
從歷史上看,fielddata 是 所有字段的默認設置。但是 Elasticsearch 已遷移到 doc values 以減少 OOM 的幾率。分詞字符串是仍然使用 fielddata 的最后一塊陣地。 最終目標是建立一個序列化的數據結構類似於 doc values ,可以處理高維度的分詞字符串,逐步淘汰 fielddata。
避免分詞字段的另外一個原因就是:高基數字段在加載到 fielddata 時會消耗大量內存。 分詞的過程會經常(盡管不總是這樣)生成大量的 token,這些 token 大多都是唯一的。 這會增加字段的整體基數並且帶來更大的內存壓力。
有些類型的分詞對於內存來說 極度 不友好,想想 n-gram 的分析過程, New York 會被 n-gram 分析成以下 token:
ne、ew、w 、 y、yo、or、rk
可以想象 n-gram 的過程是如何生成大量唯一 token 的,特別是在對成段文本分詞的時候。當這些數據加載到內存中,會輕而易舉的將我們堆空間消耗殆盡。
在聚合字符串字段之前,請評估情況:
a.這是一個 not_analyzed 字段嗎?如果是,可以通過 doc values 節省內存 。
b.否則,這是一個 analyzed 字段,它將使用 fielddata 並加載到內存中。這個字段因為 n-grams 有一個非常大的基數?如果是,這對於內存來說極度不友好。
2.2.text類型默認禁用fielddate,排序、聚合需要手動開啟
POST book1/_mapping/english/?pretty { "english":{ "properties":{ "addr":{ "type":"text", "fielddata":true } } } }
Fielddata可能會消耗大量的堆空間,尤其是在加載高基數text字段時。一旦fielddata已加載到堆中,它將在該段的生命周期內保留。此外,加載fielddata是一個昂貴的過程,可能會導致用戶遇到延遲命中。這就是默認情況下禁用fielddata的原因。
如果您嘗試對text 字段上的腳本進行排序,聚合或訪問,您將看到以下異常:
默認情況下,在文本字段上禁用Fielddata。設置
fielddata=true為[your_field_name]以通過同相反向索引在內存中加載fielddata。請注意,這可能會占用大量內存。
3.Fielddata
一旦分詞字符串被加載到 fielddata ,他們會一直在那里,直到被驅逐(或者節點崩潰)。由於這個原因,留意內存的使用情況,了解它是如何以及何時加載的,怎樣限制對集群的影響是很重要的。
Fielddata 是 延遲 加載。如果你從來沒有聚合一個分析字符串,就不會加載 fielddata 到內存中。此外,fielddata 是基於字段加載的, 這意味着只有很活躍地使用字段才會增加 fielddata 的負擔。
然而,這里有一個令人驚訝的地方。假設你的查詢是高度選擇性和只返回命中的 100 個結果。大多數人認為 fielddata 只加載 100 個文檔。
實際情況是,fielddata 會加載索引中(針對該特定字段的) 所有的文檔,而不管查詢的特異性。邏輯是這樣:如果查詢會訪問文檔 X、Y 和 Z,那很有可能會在下一個查詢中訪問其他文檔。
與 doc values 不同,fielddata 結構不會在索引時創建。相反,它是在查詢運行時,動態填充。這可能是一個比較復雜的操作,可能需要一些時間。 將所有的信息一次加載,再將其維持在內存中的方式要比反復只加載一個 fielddata 的部分代價要低。
JVM 堆 是有限資源的,應該被合理利用。 限制 fielddata 對堆使用的影響有多套機制,這些限制方式非常重要,因為堆棧的亂用會導致節點不穩定(感謝緩慢的垃圾回收機制),甚至導致節點宕機(通常伴隨 OutOfMemory 異常)。
在設置 Elasticsearch 堆大小時需要通過 $ES_HEAP_SIZE 環境變量應用兩個規則:
3.1.不要超過可用 RAM 的 50%
Lucene 能很好利用文件系統的緩存,它是通過系統內核管理的。如果沒有足夠的文件系統緩存空間,性能會收到影響。 此外,專用於堆的內存越多意味着其他所有使用 doc values 的字段內存越少。
3.2.不要超過 32 GB
如果堆大小小於 32 GB,JVM 可以利用指針壓縮,這可以大大降低內存的使用:每個指針 4 字節而不是 8 字節。
4.Fielddata的大小
indices.fielddata.cache.size 控制為 fielddata 分配的堆空間大小。 當你發起一個查詢,分析字符串的聚合將會被加載到 fielddata,如果這些字符串之前沒有被加載過。如果結果中 fielddata 大小超過了指定大小,其他的值將會被回收從而獲得空間。
默認情況下,這個設置是禁用的,Elasticsearch 永遠都不會從 fielddata 中回收數據。
這個默認設置是刻意選擇的:fielddata 不是臨時緩存。它是駐留內存里的數據結構,必須可以快速執行訪問,而且構建它的代價十分高昂。如果每個請求都重載數據,性能會十分糟糕。
一個有界的大小會強制數據結構回收數據。
設想我們正在對日志進行索引,每天使用一個新的索引。通常我們只對過去一兩天的數據感興趣,盡管我們會保留老的索引,但我們很少需要查詢它們。不過如果采用默認設置,舊索引的 fielddata 永遠不會從緩存中回收! fieldata 會保持增長直到 fielddata 發生斷熔,這樣我們就無法載入更多的 fielddata。
這個時候,我們被困在了死胡同。但我們仍然可以訪問舊索引中的 fielddata,也無法加載任何新的值。相反,我們應該回收舊的數據,並為新值獲得更多空間。
為了防止發生這樣的事情,可以通過在 config/elasticsearch.yml 文件中增加配置為 fielddata 設置一個上限:
indices.fielddata.cache.size: 20% : 有了這個設置,最久未使用(LRU)的 fielddata 會被回收為新數據騰出空間。
4.1.監控fileddata
Fielddata 的使用可以被監控:
1).按索引使用 indices-stats API :GET /_stats/fielddata?fields=*
2).按節點使用 nodes-stats API : GET /_nodes/stats/indices/fielddata?fields=*
3).按索引節點:GET /_nodes/stats/indices/fielddata?level=indices&fields=*
4.2.斷路器(Circuit Breakers)
fielddata 大小是在數據加載 之后 檢查的。 如果一個查詢試圖加載比可用內存更多的信息到 fielddata 中會發生什么?答案很丑陋:我們會碰到 OutOfMemoryException 。
Elasticsearch 包括一個 fielddata 斷熔器 ,這個設計就是為了處理上述情況。 斷熔器通過內部檢查(字段的類型、基數、大小等等)來估算一個查詢需要的內存。它然后檢查要求加載的 fielddata 是否會導致 fielddata 的總量超過堆的配置比例。
如果估算查詢的大小超出限制,就會 觸發 斷路器,查詢會被中止並返回異常。這都發生在數據加載 之前 ,也就意味着不會引起 OutOfMemoryException 。
Elasticsearch 有一系列的斷路器,它們都能保證內存不會超出限制:
1).indices.breaker.fielddata.limit
fielddata 斷路器默認設置堆的 60% 作為 fielddata 大小的上限。
2).indices.breaker.request.limit
request 斷路器估算需要完成其他請求部分的結構大小,例如創建一個聚合桶,默認限制是堆內存的 40%。
3).indices.breaker.total.limit
total 揉合 request 和 fielddata 斷路器保證兩者組合起來不會使用超過堆內存的 70%。
斷路器的限制可以在文件 config/elasticsearch.yml 中指定,可以動態更新一個正在運行的集群:
PUT /_cluster/settings { "persistent" : { "indices.breaker.fielddata.limit" : "40%" } }
關於給 fielddata 的大小加一個限制,從而確保舊的無用 fielddata 被回收的方法。 indices.fielddata.cache.size 和 indices.breaker.fielddata.limit 之間的關系非常重要。 如果斷路器的限制低於緩存大小,沒有數據會被回收。為了能正常工作,斷路器的限制 必須 要比緩存大小要高。
4.3.fielddata過濾
PUT /music/_mapping/song { "properties": { "tag": { "type": "string", "fielddata": { "filter": { "frequency": { "min": 0.01, "min_segment_size": 500 } } } } } }
1).只加載那些至少在本段文檔中出現 1% 的項。
2).忽略任何文檔個數小於 500 的段。
有了這個映射,只有那些至少在 本段 文檔中出現超過 1% 的項才會被加載到內存中。我們也可以指定一個 最大 詞頻,它可以被用來排除 常用 項,比如 停用詞 。
這種情況下,詞頻是按照段來計算的。這是實現的一個限制:fielddata 是按段來加載的,所以可見的詞頻只是該段內的頻率。但是,這個限制也有些有趣的特性:它可以讓受歡迎的新項迅速提升到頂部。
min_segment_size 參數要求 Elasticsearch 忽略某個大小以下的段。 如果一個段內只有少量文檔,它的詞頻會非常粗略沒有任何意義。 小的分段會很快被合並到更大的分段中,某一刻超過這個限制,將會被納入計算。
5.預加載fielddata
Elasticsearch 加載內存 fielddata 的默認行為是 延遲 加載 。 當 Elasticsearch 第一次查詢某個字段時,它將會完整加載這個字段所有 Segment 中的倒排索引到內存中,以便於以后的查詢能夠獲取更好的性能。
對於小索引段來說,這個過程的需要的時間可以忽略。但如果我們有一些 5 GB 的索引段,並希望加載 10 GB 的 fielddata 到內存中,這個過程可能會要數十秒。 已經習慣亞秒響應的用戶很難會接受停頓數秒卡着沒反應的網站。
有三種方式可以解決這個延時高峰:
1).預加載 fielddata
2).預加載全局序號
3).緩存預熱
所有的變化都基於同一概念:預加載 fielddata ,這樣在用戶進行搜索時就不會碰到延遲高峰。
5.1.預加載
第一個工具稱為 預加載 (與默認的 延遲加載相對)。隨着新分段的創建(通過刷新、寫入或合並等方式), 啟動字段預加載可以使那些對搜索不可見的分段里的 fielddata 提前 加載。
這就意味着首次命中分段的查詢不需要促發 fielddata 的加載,因為 fielddata 已經被載入到內存。避免了用戶遇到搜索卡頓的情形。
預加載是按字段啟用的,所以我們可以控制具體哪個字段可以預先加載:
PUT /music/_mapping/_song { "tags": { "type": "string", "fielddata": { "loading" : "eager" } } }
Fielddata 的載入可以使用 update-mapping API 對已有字段設置 lazy 或 eager 兩種模式。
5.2全局序號
有種可以用來降低字符串 fielddata 內存使用的技術叫做 序號 。
設想我們有十億文檔,每個文檔都有自己的 status 狀態字段,狀態總共有三種: status_pending 、 status_published 、 status_deleted 。如果我們為每個文檔都保留其狀態的完整字符串形式,那么每個文檔就需要使用 14 到 16 字節,或總共 15 GB。
取而代之的是我們可以指定三個不同的字符串,對其排序、編號:0,1,2。
Ordinal | Term ------------------- 0 | status_deleted 1 | status_pending 2 | status_published 序號字符串在序號列表中只存儲一次,每個文檔只要使用數值編號的序號來替代它原始的值。 Doc | Ordinal ------------------------- 0 | 1 # pending 1 | 1 # pending 2 | 2 # published 3 | 0 # deleted
這樣可以將內存使用從 15 GB 降到 1 GB 以下!
但這里有個問題,記得 fielddata 是按分 段 來緩存的。如果一個分段只包含兩個狀態( status_deleted 和 status_published )。那么結果中的序號(0 和 1)就會與包含所有三個狀態的分段不一樣。
如果我們嘗試對 status 字段運行 terms 聚合,我們需要對實際字符串的值進行聚合,也就是說我們需要識別所有分段中相同的值。一個簡單粗暴的方式就是對每個分段執行聚合操作,返回每個分段的字符串值,再將它們歸納得出完整的結果。 盡管這樣做可行,但會很慢而且大量消耗 CPU。
取而代之的是使用一個被稱為 全局序號 的結構。 全局序號是一個構建在 fielddata 之上的數據結構,它只占用少量內存。唯一值是 跨所有分段 識別的,然后將它們存入一個序號列表中,正如我們描述過的那樣。
現在, terms 聚合可以對全局序號進行聚合操作,將序號轉換成真實字符串值的過程只會在聚合結束時發生一次。這會將聚合(和排序)的性能提高三到四倍。
構建全局序號(Building global ordinals)
當然,天下沒有免費的晚餐。 全局序號分布在索引的所有段中,所以如果新增或刪除一個分段時,需要對全局序號進行重建。 重建需要讀取每個分段的每個唯一項,基數越高(即存在更多的唯一項)這個過程會越長。
全局序號是構建在內存 fielddata 和 doc values 之上的。實際上,它們正是 doc values 性能表現不錯的一個主要原因。
和 fielddata 加載一樣,全局序號默認也是延遲構建的。首個需要訪問索引內 fielddata 的請求會促發全局序號的構建。由於字段的基數不同,這會導致給用戶帶來顯著延遲這一糟糕結果。一旦全局序號發生重建,仍會使用舊的全局序號,直到索引中的分段產生變化:在刷新、寫入或合並之后。
預構建全局序號(Eager global ordinals)
單個字符串字段 可以通過配置預先構建全局序號:
PUT /music/_mapping/_song { "song_title": { "type": "string", "fielddata": { "loading" : "eager_global_ordinals" } } }
正如 fielddata 的預加載一樣,預構建全局序號發生在新分段對於搜索可見之前。
序號的構建只被應用於字符串。數值信息(integers(整數)、geopoints(地理經緯度)、dates(日期)等等)不需要使用序號映射,因為這些值自己本質上就是序號映射。 因此,我們只能為字符串字段預構建其全局序號
也可以對 Doc values 進行全局序號預構建:
PUT /music/_mapping/_song { "song_title": { "type": "string", "doc_values": true, "fielddata": { "loading" : "eager_global_ordinals" } } }
這種情況下,fielddata 沒有載入到內存中,而是 doc values 被載入到文件系統緩存中。
與 fielddata 預加載不一樣,預建全局序號會對數據的 實時性 產生影響,構建一個高基數的全局序號會使一個刷新延時數秒。 選擇在於是每次刷新時付出代價,還是在刷新后的第一次查詢時。如果經常索引而查詢較少,那么在查詢時付出代價要比每次刷新時要好。如果寫大於讀,那么在選擇在查詢時重建全局序號將會是一個更好的選擇。
5.3.索引預熱器(index warmers)
最后我們談談 索引預熱器 。預熱器早於 fielddata 預加載和全局序號預加載之前出現,它們仍然尤其存在的理由。一個索引預熱器允許我們指定一個查詢和聚合須要在新分片對於搜索可見之前執行。 這個想法是通過預先填充或 預熱緩存 讓用戶永遠無法遇到延遲的波峰。
原來,預熱器最重要的用法是確保 fielddata 被預先加載,因為這通常是最耗時的一步。現在可以通過前面討論的那些技術來更好的控制它,但是預熱器還是可以用來預建過濾器緩存,當然我們也還是能選擇用它來預加載 fielddata。
讓我們注冊一個預熱器然后解釋發生了什么:
PUT /music/_warmer/warmer_1 { "query" : { "bool" : { "filter" : { "bool": { "should": [ { "term": { "tag": "rock" }}, { "term": { "tag": "hiphop" }}, { "term": { "tag": "electronics" }} ] } } } }, "aggs" : { "price" : { "histogram" : { "field" : "price", "interval" : 10 } } } }
1).預熱器被關聯到索引( music )上,使用接入口 _warmer 以及 ID ( warmer_1 )。
2).為三種最受歡迎的曲風預建過濾器緩存。
3).字段 price 的 fielddata 和全局序號會被預加載。
預熱器是根據具體索引注冊的, 每個預熱器都有唯一的 ID ,因為每個索引可能有多個預熱器。
然后我們可以指定查詢,任何查詢。它可以包括查詢、過濾器、聚合、排序值、腳本,任何有效的查詢表達式都毫不誇張。 這里的目的是想注冊那些可以代表用戶產生流量壓力的查詢,從而將合適的內容載入緩存。
當新建一個分段時,Elasticsearch 將會執行注冊在預熱器中的查詢。執行這些查詢會強制加載緩存,只有在所有預熱器執行完,這個分段才會對搜索可見。
