ElasticSearch 2 (37) - 信息聚合系列之內存與延時
摘要
控制內存使用與延時
版本
elasticsearch版本: elasticsearch-2.x
內容
Fielddata
聚合使用一個叫 fielddata 的數據結構(在 Fielddata 里簡單介紹)。Fielddata 通常是 Elasticsearch 集群中內存消耗最大的一部分,所以理解它的工作方式十分重要。
小貼士
Fielddata 可以隨意被加載到內存中,或是索引是創建並存在磁盤上的。稍后我們會在 Doc Values 中討論磁盤上的 fielddata。現在我們只關注內存中的 fielddata,因為它目前是 Elasticsearch 默認的操作模式。在將來版本會對此做調整。
Fielddata 的存在是因為倒排索引只對某些操作是高效的。倒排索引的優勢在於查找包含某個項的文檔,而對於從另外一個方向的相反操作並不高效,即:確定只出現在單個文檔里的所有項。聚合需要這種次級的輔助訪問模式。
對於以下倒排索引:
Term Doc_1 Doc_2 Doc_3
------------------------------------
brown | X | X |
dog | X | | X
dogs | | X | X
fox | X | | X
foxes | | X |
in | | X |
jumped | X | | X
lazy | X | X |
leap | | X |
over | X | X | X
quick | X | X | X
summer | | X |
the | X | | X
------------------------------------
如果我們想要獲得任何包含 brown
這個詞的完整列表,我們會創建如下查詢:
GET /my_index/_search
{
"query" : {
"match" : {
"body" : "brown"
}
},
"aggs" : {
"popular_terms": {
"terms" : {
"field" : "body"
}
}
}
}
查詢部分簡單又高效。倒排索引是根據項來排序的,所以我們首先在詞項列表中找到 brown
,然后掃描所有列,找到包含 brown
的文檔,我們可以快速看到 Doc_1
和 Doc_2
包含 brown
這個 token。
然后,對於聚合部分,我們需要找到 Doc_1
和 Doc_2
里所有唯一的詞項,用倒排所以做這件事情代價很高:我們會迭代索引里的每個詞項並收集 Doc_1
和 Doc_2
列里面 token。這很慢而且難以擴展:隨着詞項和文檔的數量增加,執行時間也會增加。
Fielddata 通過轉置兩者間的關系來解決這個問題。倒排索引將詞項映射到包含它們的文檔,而 fielddata 將文檔映射到它們包含的詞項:
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_1
和 Doc_2
的唯一 token 會非常容易。獲得每個文檔行,獲取所有的詞項,然后求兩個集合的並集。
小貼士
fielddata 緩存是按照段(segment)分割的。換句話說,當一個新的 segment 可供搜索時,舊segment 里面緩存的 fielddata 仍然有效。只有那些新 segment 里的數據才需要被加載到內存中。
因此,搜索和聚合是相互緊密纏繞的。搜索使用倒排索引查找文檔,聚合收集和聚合 fielddata 里的數據,而它本身也是通過倒排索引生成的。
本章剩下的部分會涵蓋關於減少 fielddata 內存占用或提升執行效率的各種功能。
小貼士
Fielddata 不僅可以用於聚合。任何需要查找某個文檔包含的值的操作都必須使用它。除了聚合,還包括排序,訪問字段值的腳本,父子關系處理(參見 父子關系(Parent-Child Relationship))以及某些類型的查詢和過濾,比如 地理位置距離過濾器
geo_distance
filter。
聚合與分析(Aggregations and Analysis)
有些聚合,比如 terms
桶,操作字符串字段。字符串字段可能是 analyzed
或 not_analyzed
,那么問題來了,分析是怎么影響聚合的呢?
答案是影響“很多”,但可以通過一個示例來更好說明這點。首先索引一些代表美國各個州的文檔:
POST /agg_analysis/data/_bulk
{ "index": {}}
{ "state" : "New York" }
{ "index": {}}
{ "state" : "New Jersey" }
{ "index": {}}
{ "state" : "New Mexico" }
{ "index": {}}
{ "state" : "New York" }
{ "index": {}}
{ "state" : "New York" }
我們希望創建一個數據集里各個州的唯一列表,並且計數。簡單,讓我們使用 terms
桶:
GET /agg_analysis/data/_search
{
"size" : 0,
"aggs" : {
"states" : {
"terms" : {
"field" : "state"
}
}
}
}
得到結果:
{
...
"aggregations": {
"states": {
"buckets": [
{
"key": "new",
"doc_count": 5
},
{
"key": "york",
"doc_count": 3
},
{
"key": "jersey",
"doc_count": 1
},
{
"key": "mexico",
"doc_count": 1
}
]
}
}
}
寶貝兒,這完全不是我們想要的!沒有對州名計數,聚合計算了每個詞的數目。背后的原因很簡單:聚合是基於倒排索引創建的,倒排索引是 后置分析(post-analysis) 的。
當我們把這些文檔加入到 Elasticsearch 中時,字符串 “New York
” 被分析/分詞成 ["new", "york"]
。每個 token 都用來提取 fielddata 里的內容,所以我們最終看到 new
的數量而不是 New York
。
這顯然不是我們想要的行為,但幸運的是很容易修正它。
我們需要為 state
定義 multifield 並且設置成 not_analyzed
。這樣可以防止 New York
被分析,也意味着在聚合過程中它會以單個 token 的形式存在。讓我們嘗試完整的過程,但這次指定一個 raw multifield:
DELETE /agg_analysis/
PUT /agg_analysis
{
"mappings": {
"data": {
"properties": {
"state" : {
"type": "string",
"fields": {
"raw" : {
"type": "string",
"index": "not_analyzed" #1
}
}
}
}
}
}
}
POST /agg_analysis/data/_bulk
{ "index": {}}
{ "state" : "New York" }
{ "index": {}}
{ "state" : "New Jersey" }
{ "index": {}}
{ "state" : "New Mexico" }
{ "index": {}}
{ "state" : "New York" }
{ "index": {}}
{ "state" : "New York" }
GET /agg_analysis/data/_search
{
"size" : 0,
"aggs" : {
"states" : {
"terms" : {
"field" : "state.raw" #2
}
}
}
}
#1 這次我們顯式映射 state
字段並包括一個 not_analyzed
輔字段。
#2 聚合針對 state.raw
字段而不是 state
。
現在運行聚合,我們得到了合理的結果:
{
...
"aggregations": {
"states": {
"buckets": [
{
"key": "New York",
"doc_count": 3
},
{
"key": "New Jersey",
"doc_count": 1
},
{
"key": "New Mexico",
"doc_count": 1
}
]
}
}
}
在實際中,這樣的問題很容易被察覺,我們的聚合會返回一些奇怪的桶,我們會記住分析的問題。總之,很少有在聚合中使用分析字段的實例。當我們疑惑時,只要增加一個 multifield 就能有兩種選擇。
高基數內存的影響(High-Cardinality Memory Implications)
避免分析字段的另外一個原因就是:高基數字段在加載到 fielddata 時會消耗大量內存,分析的過程會經常(盡管不總是這樣)生成大量的 token,這些 token 大多都是唯一的。這會增加字段的整體基數並且帶來更大的內存壓力。
有些類型的分析對於內存來說 極度 不友好,想想 n-gram 的分析過程,New York
會被 n-gram 成以下 token:
ne
ew
w
y
yo
or
rk
可以想象 n-gram 的過程是如何生成大量唯一 token 的,特別是在分析成段文本的時候。當這些數據加載到內存中,會輕而易舉的將我們堆空間消耗殆盡。
所以,在進行跨字段聚合之前,花點時間驗證一下字段是 not_analyzed
,如果我們想聚合 analyzed
的字段,確保分析過程不會生成任何不必要的 token。
小貼士
最后,一個字段是
analyzed
還是not_analyzed
並不重要,字段里的唯一值越多,字段基數越高,就需要更多的內存。字符串字段特別是這樣,因為每個唯一的字符串都必須保存在內存中,字符串越長,需要的內存越多。
限制內存使用(Limiting Memory Usage)
為了讓聚合(或任何需要訪問字段值的操作)更快,訪問 fielddata 必須快速,這就是為什么將它載入內存的原因。但加載過多的數據到內存會導致垃圾回收變慢,因為 JVM 會嘗試在堆中找到額外的空間,或甚至有可能導致 OutOfMemory 異常。
Elasticsearch 不僅僅將與查詢匹配的文檔載入到 fielddata 中,這可能會令我們感到吃驚,它還會將 索引內所有文檔 的值加載,甚至是那些不同類型 _type
的文檔!
邏輯是這樣:如果查詢會訪問文檔 X、Y 和 Z,那很有可能會在下一個查詢中訪問其他文檔。將所有的信息一次加載,再將其維持在內存中的方式要比每次請求都掃描倒排索引的代價要低。
JVM 堆是有限資源,應該被合理利用。限制 fielddata 對堆使用的影響有多套機制,這些限制方式非常重要,因為堆棧的亂用會導致節點不穩定(感謝緩慢的垃圾回收機制),甚至導致節點宕機(通常伴隨 OutOfMemory 異常)。
選擇堆大小(Choosing a Heap Size)
在設置 Elasticsearch 堆大小時需要通過
$ES_HEAP_SIZE
環境變量應用兩個規則:
不要超過可用 RAM 的 50%
Lucene 能很好利用文件系統的緩存,它是通過系統內核管理的。如果沒有足夠的文件系統緩存空間,性能會收到影響。
不要超過 32GB:
如果堆大小小於 32 GB,JVM 可以利用指針壓縮,這可以大大降低內存的使用:每個指針 4 字節而不是 8 字節。
+ 將堆大小從 32 GB 增加到 34 GB 意味着更少的可用內存,因為所有的指針都會占用雙倍的空間。而且如果堆越大,垃圾回收的代價會更高,同時也會導致節點不穩定。
這個限制直接影響了 fielddata 可以使用內存的量。
Fielddata的大小(Fielddata Size)
indices.fielddata.cache.size
控制為 fielddata 分配的堆空間大小。當查詢需要訪問新字段值時,它會先將值加載到內存中,然后嘗試把它們加入到 fielddata 。如果結果中 fielddata 大小超過了指定大小,其他的值會被剔除從而獲得空間。
默認情況下,設置都是 unbounded ,Elasticsearch 永遠都不會從 fielddata 中剔除數據。
這個默認設置是刻意選擇的:fielddata 不是臨時緩存。它是駐留內存里的數據結構,必須可以快速執行訪問,而且構建它的代價十分高昂。如果每個請求都重載數據,性能會十分糟糕。
一個有界的大小會強制數據結構剔除數據。我們會看合適應該設置這個值,但請首先閱讀以下警告:
警告
這個設置是一個安全衛士,而非內存不足的解決方案。
如果沒有足夠空間可以將 fielddata 保留在內存中,Elasticsearch 就會時刻從磁盤重載數據,並剔除其他數據以獲得更多空間。內存的剔除機制會導致重度磁盤I/O,並且在內存中生成很多垃圾,這些垃圾必須在晚些時候被回收掉。
設想我們正在對日志進行索引,每天使用一個新的索引。通常我們只對過去一兩天的數據感興趣,盡管我們會保留老的索引,但我們很少需要查詢它們。不過如果采用默認設置,舊索引的 fielddata 永遠不會從緩存中剔除!fieldata 會保持增長直到 fielddata 發生斷熔(參見 斷路器(Circuit Breaker)),這樣我們就無法載入更多的 fielddata。
這個時候,我們被困在了死胡同。但我們仍然可以訪問舊索引中的 fielddata,也無法加載任何新的值。相反,我們應該剔除舊的數據,並為新值獲得更多空間。
為了防止發生這樣的事情,可以通過在 config/elasticsearch.yml
文件中增加配置為 fielddata 設置一個上限:
indices.fielddata.cache.size: 40%
可以設置堆大小的百分比,也可以是某個值 5gb。
有了這個設置,最久未使用(LRU)的 fielddata 會被剔除為新數據騰出空間。
警告
可能發現在線文檔有另外一個設置:
indices.fielddata.cache.expire
。這個設置永遠都不會被使用!它很有可能在不久的將來被棄用。
這個設置要求 Elasticsearch 剔除那些過期的 fielddata,不管這些值有沒有被用到。
這對性能是件很糟糕的事情。剔除會有消耗性能,它刻意的安排剔除方式,而沒能獲得任何回報。
沒有理由使用這個設置:我們不能從理論上假設一個有用的情形。目前,它的存在只是為了向前兼容。我們只在很有以前提到過這個設置,但不幸的是網上各種文章都將其作為一種性能調優的小竅門來推薦。
它不是。永遠不要使用!
監控 fielddata(Monitoring fielddata)
無論是仔細監控 fielddata 的內存使用情況,還是看有無數據被剔除都十分重要。高的剔除數可以預示嚴重的資源問題以及性能不佳的原因。
Fielddata 的使用可以被監控:
-
按索引使用
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=*
,可以將內存使用分配到每個字段。
斷路器(Circuit Breaker)
機敏的讀者可能已經發現 fielddata 大小設置的一個問題。fielddata 大小是在數據加載之后檢查的。如果一個查詢試圖加載比可用內存更多的信息到 fielddata 中會發生什么?答案很丑陋:我們會碰到 OutOfMemoryException 。
Elasticsearch 包括一個 fielddata 斷熔器,這個設計就是為了處理上述情況。斷熔器通過內部檢查(字段的類型、基數、大小等等)來估算一個查詢需要的內存。它然后檢查要求加載的 fielddata 是否會導致 fielddata 的總量超過堆的配置比例。
如果估算查詢的大小超出限制,就會觸發斷路器,查詢會被中止並返回異常。這都發生在數據加載之前,也就意味着不會引起 OutOfMemoryException 。
可用的斷路器(Available Circuit Breakers)
Elasticsearch 有一系列的斷路器,它們都能保證內存不會超出限制:
indices.breaker.fielddata.limit
fielddata
斷路器默認設置堆的 60% 作為 fielddata 大小的上限。
indices.breaker.request.limit
request
斷路器估算需要完成其他請求部分的結構大小,例如創建一個聚合桶,默認限制是堆內存的 40%。
indices.breaker.total.limit
total
揉合request
和fielddata
斷路器保證兩者組合起來不會使用超過堆內存的 70%。
斷路器的限制可以在文件 config/elasticsearch.yml
中指定,可以動態更新一個正在運行的集群:
PUT /_cluster/settings
{
"persistent" : {
"indices.breaker.fielddata.limit" : "40%" #1
}
}
#1 這個限制是按對內存的百分比設置的。
最好為斷路器設置一個相對保守點的值。記住 fielddata 需要與 request
斷路器共享堆內存、索引緩沖內存和過濾器緩存。Lucene 的數據被用來構造索引,以及各種其他臨時的數據結構。正因如此,它默認值非常保守,只有 60% 。過於樂觀的設置可能會引起潛在的堆棧溢出(OOM)異常,這會使整個節點宕掉。
另一方面,過度保守的值只會返回查詢異常,應用程序可以對異常做相應處理。異常比服務器崩潰要好。這些異常應該也能促進我們對查詢進行重新評估:為什么單個查詢需要超過堆內存的 60% 之多?
小貼士
在 Fielddata Size 中,我們提過關於給 fielddata 的大小加一個限制,從而確保舊的無用 fielddata 被剔除的方法。The relationship between
indices.fielddata.cache.size
和indices.breaker.fielddata.limit
之間的關系非常重要。如果斷路器的限制低於緩存大小,沒有數據會被剔除。為了能正常工作,斷路器的限制要比緩存大小要高。
值得注意的是:斷路器是根據總堆內存大小估算查詢大小的,而非根據實際堆內存的使用情況。這是由於各種技術原因造成的(例如,堆可能看上去是滿的但實際上可能只是在等待垃圾回收,這使我們難以進行合理的估算)。但作為終端用戶,這意味着設置需要保守,因為它是根據總堆內存必要的,而不是可用堆內存。
Fielddata 的過濾(Fielddata Filtering)
設想我們正在運行一個網站運行用戶收聽他們喜歡的歌曲。為了讓他們可以更容易的管理自己的音樂庫,用戶可以為歌曲設置任何他們喜歡的標簽,這樣我們就會有很多歌曲被附上 rock(搖滾)
、hiphop(嘻哈)
和 electronica(電音)
這樣的標簽,但也會有些歌曲被附上 my_16th_birthday_favorite_anthem
這樣的標簽。
現在設想我們想要為用戶展示每首歌曲最受歡迎的三個標簽,很有可能 rock
這樣的標簽會排在三個中的最前面,而 my_16th_birthday_favorite_anthem
則不太可能得到評級。盡管如此,為了計算最受歡迎的標簽,我們必須強制將這些一次性使用的項加載到內存中。
感謝 fielddata 過濾,我們可以控制這種狀況。我們知道自己只對最流行的項感興趣,所以我們可以簡單地避免加載那些不太有意思的長尾項:
PUT /music/_mapping/song
{
"properties": {
"tag": {
"type": "string",
"fielddata": { #1
"filter": {
"frequency": { #2
"min": 0.01, #3
"min_segment_size": 500 #4
}
}
}
}
}
}
#1 fielddata
關鍵字允許我們配置 fielddata 處理該字段的方式。
#2 frequency
過濾器允許我們基於項頻率過濾加載 fielddata。
#3 只加載那些至少在本段文檔中出現 1% 的項。
#4 忽略任何少於 500 文檔的段。
有了這個映射,只有那些至少在本段文檔中出現超過 1% 的項才會被加載到內存中。我們也可以指定一個最大詞頻,它可以被用來排除常用項,比如停用詞。
這種情況下,詞頻是按照段來計算的。這是實現的一個限制:fielddata 是按段來加載的,所以可見的詞頻只是該段內的頻率。但是,這個限制也有些有趣的特性:它可以讓受歡迎的新項迅速提升到頂部。
比如一個新風格的歌曲在一夜之間受大眾歡迎,我們可能想要將這種新風格的歌曲標簽包括在最受歡迎列表中,但如果我們倚賴對索引做完整的計算獲取詞頻,我們就必須等到新標簽變得像 rock(搖滾)
和 electronica(電音)
一樣流行。由於頻度過濾的實現方式,新加的標簽會很快作為高頻標簽出現在新段內,也當然會迅速上升到頂部。
min_segment_size
參數要求 Elasticsearch 忽略某個大小以下的段。如果一個段內只有少量文檔,它的詞頻會非常粗略沒有任何意義。小的分段會很快被合並到更大的分段中,也會大到足以考慮其中。
小貼士
通過頻次來過濾項並不是唯一的選擇,我們也可以使用正則式來決定只加載那些匹配的項。例如,我們可以用
regex
過濾器處理 twitte 上的消息只將以#
號開始的標簽加載到內存中。這假設我們使用的分析器會保留標點符號,像whitespace
分析器。
Fielddata 過濾對內存使用有巨大的影響,權衡也是顯而易見的:我們實際上是在忽略數據。但對於很多應用,這種權衡是合理的,因為這些數據根本就沒有被使用到。內存的節省通常要比包括一個大量而無用的長尾項更為重要。
Doc Values
內存中的 fieldadata 受堆內存大小的限制。當然這個問題可以通過橫向擴展來解決(我們總可以增加新的節點),我會發現重度使用聚合和排序會引起堆空間耗盡而並沒有良好利用節點里的其他資源。
fielddata 默認會不加控制的加載內容到內存中,但這並不是唯一的選擇。它也可以在索引時被寫入磁盤,並且能夠提供與內存 fielddata 所有的功能,只不過沒有使用到堆內存。這種替代格式被稱為 doc values。
Doc values 是在 Elasticsearch 1.0.0 的版本中加入的,但直到最近,它們要比內存 fielddata 慢很多。在對性能進行測評分析后,發現它有很多瓶頸,不論是在 Elasticsearch 中還是在 Lucene 中都是如此,所以被移除了。從版本 2.0 開始,doc values 成為幾乎所有字段類型的默認格式,但對 analyzed
的字符串字段會有明顯異常發生。
Doc values 目前只比內存 fielddata 慢 10–25% ,並且它有兩個主要的優勢:
- 它在磁盤中而非堆內存中。這讓我們可以處理那些通常難以放入內存里的大量 fielddata。事實上,我們的堆空間(
$ES_HEAP_SIZE
)現在可以設置一個較小的值,這樣可以提高垃圾回收的速度,最終節點也會比較穩定。 - Doc values 是在索引時而不是在搜索時構建,但內存 fielddata 必須在搜索時構建並將倒排索引反向。因為 doc values 是預先構建的,所以可以更快的對它進行初始化。
我們用更大的索引空間換來了較慢的 fielddata 訪問。Doc values 非常高效,所以對很多查詢來講,我們甚至注意不到它的速度稍稍變慢了。再加上更快的垃圾回收效率以及提高的初始化時間,顯然總得來說為我們帶來了好處。
需要提供的文件緩存空間越多,doc values 的性能會越好。如果保持 doc values 的文件處於文件系統的緩存中,那么從性能商講,訪問這些文件就幾乎與直接讀取內存是等價的。文件系統的緩存是由系統內核控制的,而非 JVM。
啟用 Doc Values(Enabling Doc Values)
Doc values 默認對 numeric(數值)、日期(date)、布爾值(Boolean)、二進制值(binary)和 地理經緯度(geo-point)字段以及 not_analyzed
的字符串字段開啟的。目前它們還無法使用在 analyzed
字符串字段上。如果我們確信不需要對字段進行排序或聚合操作,或者不需要用腳本訪問字段,那么我們可以禁用 doc values 從而節省磁盤空間:
PUT /music/_mapping/song
{
"properties" : {
"tag": {
"type": "string",
"index" : "not_analyzed",
"doc_values": false
}
}
}
提前加載 fielddata(Preloading Fielddata)
Elasticsearch 加載內存 fielddata 的默認行為是 延遲加載 。當 Elasticsearch 接收一個需要使用某個特定字段 fielddata 的查詢時,它會將索引中每個分段的完整字段內容加載到內存中。
對於小分段來說,這個過程的需要的時間可以忽略。但如果我們有些 5 GB 的分段,並希望加載 10 GB 的 fielddata 到內存中,這個過程可能會要數十秒。已經習慣亞秒響應的用戶會突然遇到一個打擊,網站明顯無法響應。
有三種方式可以解決這個延時高峰:
- 預加載 fielddata
- 預加載全局序號
- 預熱內存
所有的變化都基於同一概念:預加載 fielddata 這樣在用戶進行搜索時就不會碰到延遲高峰。
預加載 fielddata(Eagerly Loading Fielddata)
第一個工具稱為預加載(與默認的延遲加載相對)。隨着新分段的創建(通過刷新、寫入或合並等方式),啟動字段預加載可以使那些對搜索不可見的分段里的 fielddata 提前加載。
這就意味着首次命中分段的查詢不需要促發 fielddata 的加載,因為緩存內容已經被載入到內存。這也能避免用戶碰到冷緩存加載時的延時高峰。
預加載是按字段啟用的,所以我們可以控制具體哪個字段可以預先加載:
PUT /music/_mapping/_song
{
"price_usd": {
"type": "integer",
"fielddata": {
"loading" : "eager" #1
}
}
}
#1 設置 fielddata.loading: eager
可以告訴 Elasticsearch 預先將此字段的內容載入內存中。
Fielddata 的載入可以使用 update-mapping
API 對已有字段設置 lazy
或 eager
兩種模式。
警告
預加載只是簡單的將載入 fielddata 的代價轉移到索引刷新的時候,而不是查詢時。
內容多的分段會比內容少的分段需要更長的刷新時間。通常,內容多的分段是由那些已經對查詢可見的小分段合並而成的,所以較慢的刷新時間也不是很重要。
全局序號(Global Ordinals)
有種可以用來降低字符串 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" #1
}
}
}
#1 設置 eager_global_ordinals
也暗示着 fielddata 是預加載的。
正如 fielddata 的預加載一樣,預構建全局序號發生在新分段對於搜索可見之前。
注意
序號的構建只被應用於字符串。數值信息(integers(整數)、geopoints(地理經緯度)、dates(日期)等等)不需要使用序號映射,因為這些值自己本質上就是序號映射。
因此,我們只能為字符串字段預構建其全局序號。
也可以對 Doc values 進行全局序號預構建:
PUT /music/_mapping/_song
{
"song_title": {
"type": "string",
"doc_values": true,
"fielddata": {
"loading" : "eager_global_ordinals" #1
}
}
}
#1 這種情況下,fielddata 沒有載入到內存中,而是 doc values 被載入到文件系統緩存中。
與 fielddata 預加載不一樣,預建全局序號會對數據的實時性產生影響,構建全局序號會使一個刷新延時幾秒。選擇在於是在每次刷新時付出代價,還是在刷新后的第一次查詢時。如果經常索引而查詢較少,那么可能在查詢時付出代價要比每次刷新時要好。
小貼士
讓全局序號自己付出代價。如果我們有高基數字段需要花數秒鍾重建,增加
refresh_interval
可以使全局序號保留更長的有效期。這也能降低 CPU 的使用,因為重建全局序號不需要那么頻繁。
索引預熱器(Index Warmers)
最后我們談談 索引預熱器。預熱器早於 fielddata 預加載和全局序號預加載之前出現,它們仍然尤其存在的理由。一個索引預熱器允許我們指定一個查詢和聚合須要在新分片對於搜索可見之前執行。這個想法是通過預先填充或預熱緩存讓用戶永遠無法遇到延遲的波峰。
原來,預熱器最重要的用法是確保 fielddata 被預先加載,因為這通常是最耗時的一步。現在可以通過前面討論的那些技術來更好的控制它,但是預熱器還是可以用來預建過濾器緩存,當然我們也還是能選擇用它來預加載 fielddata。
讓我們注冊一個預熱器然后解釋發生了什么:
PUT /music/_warmer/warmer_1 #1
{
"query" : {
"filtered" : {
"filter" : {
"bool": {
"should": [ #2
{ "term": { "tag": "rock" }},
{ "term": { "tag": "hiphop" }},
{ "term": { "tag": "electronics" }}
]
}
}
}
},
"aggs" : {
"price" : {
"histogram" : {
"field" : "price", #3
"interval" : 10
}
}
}
}
#1 預熱器被關聯到索引(music
)上,使用接入口 _warmer
以及 ID (warmer_1
)。
#2 為三種最受歡迎的曲風預建過濾器緩存。
#3 字段 price
的 fielddata 和全局序號會被預加載。
預熱器是根據具體索引注冊的,每個預熱器都有唯一的 ID ,因為每個索引可能有多個預熱器。
然后我們可以指定查詢,任何查詢。它可以包括查詢、過濾器、聚合、排序值、腳本,任何有效的查詢表達式都毫不誇張。這里的目的是想注冊那些可以代表用戶產生流量壓力的查詢,從而將合適的內容載入緩存。
當新建一個分段時,Elasticsearch 理論上會執行注冊在預熱器中的查詢。執行這些查詢會強制加載緩存,只有在所有預熱器執行完,這個分段才會對搜索可見。
警告
與預加載類似,預熱器只是將冷緩存的代價轉移到刷新的時候。當注冊預熱器時,做出明智的決定十分重要。為了確保每個緩存都被讀入,我們可以加入上千的預熱器,但這也會使新分段對於搜索可見的時間急劇上升。
實際中,我們會選擇那些大多數用戶的查詢,然后注冊它們。
有些管理的細節(比如獲得已有預熱器和刪除預熱器)沒有在本小節提到,剩下的詳細內容可以參考 預熱器文檔(warmers documentation)。
避免組合爆炸(Preventing Combinatorial Explosions)
terms
桶基於我們的數據動態構建桶;它並不知道到底生成了多少桶。盡管這對單個聚合還行,但考慮當一個聚合包含另外一個聚合,這樣一層又一層的時候會發生什么。合並每個聚合的唯一值會導致它隨着生成桶的數量而發生爆炸。
設想我們有一個表示影片大小適度的數據集合。每個文檔都列出了影片的演員:
{
"actors" : [
"Fred Jones",
"Mary Jane",
"Elizabeth Worthing"
]
}
如果我們想要確定出演影片最多的是個演員以及與他們合作最多的演員,使用聚合並不算什么:
{
"aggs" : {
"actors" : {
"terms" : {
"field" : "actors",
"size" : 10
},
"aggs" : {
"costars" : {
"terms" : {
"field" : "actors",
"size" : 5
}
}
}
}
}
}
這會返回前十位出演最多的演員,以及與他們合作最多的五位演員。這似乎是個不大的聚合,只返回 50 個值!
但是,這個看上去無傷大雅的查詢可以輕而易舉地消耗大量內存,我們可以通過在內存中構建一個樹來查看這個 terms
聚合。actors
聚合會構建樹的第一層,每個演員都有一個桶。然后,內套在第一層的每個節點之下,costar
聚合會構建第二層,每個聯合出演一個桶,如圖 Figure 42, “Build full depth tree” 所示,這意味着每部影片會生成 n*n 個桶!
Figure 42. Build full depth tree
用真實點的數字,設想平均每部影片有 10 名演員,每部影片就會生成 10 * 10 == 100 個桶。如果總共有 20,000 部影片,粗率計算就會生成 2,000,000 個桶。
現在,記住,聚合只是簡單的希望得到前十位演員和與他們聯合出演者,總共 50 個值。為了得到最終的結果,我們創建了一個有 2,000,000 桶的樹,然后對其排序,最后將結果減少到前 10 位演員。圖 Figure 43, “Sort tree” 和圖 Figure 44, “Prune tree” 對這個過程進行了闡述。
Figure 43. Sort tree
Figure 44. Prune tree
這時我們一定非常抓狂,2 萬文檔雖然微不足道,但是聚合也不輕松。如果我們有 2 億文檔,想要得到前 100 位演員以及與他們合作最多的 20 位演員,以及合作者的合作者會怎樣?
可以判斷組合擴大快速增長會使這種策略難以維持。世界上並不存在足夠的內存來支持這種非受控狀態下的組合爆炸。
Depth-First Versus Breadth-First
Elasticsearch 允許我們改變聚合的集合模式,就是為了應對這種狀況。我們之前展示的策略叫做 深度優先 ,它是默認設置,先構建完整的樹,然后修剪無用節點。深度優先的方式對於大多數聚合都能正常工作,但對於如我們演員和聯合演員這樣例子的情形就不太適用。
為了應對這些特殊的應用場景,我們應該使用另一種集合策略叫做 廣度優先 。這種策略的工作方式有些不同,它先執行第一層聚合,在繼續下一層聚合之前會先做修剪。圖 Figure 45, “Build first level” 到 Figure 47, “Prune first level” 對這個過程進行了闡述。
在我們的示例中,actors
聚合會首先執行,在這個時候,我們的樹只有一層,但我們已經知道了前 10 位的演員!這就沒有必要保留其他的演員信息,因為它們無論如何都不會出現在前十位中。
Figure 45. Build first level
Figure 46. Sort first level
Figure 47. Prune first level
因為我們已經知道了前十名演員,我們可以安全的修剪其他節點。修剪后,下一層是基於它的執行模式讀入的,重復執行這個過程直到聚合完成,如圖 Figure 48, “Populate full depth for remaining nodes” 所示。這就可以避免那種適於使用廣度優先策略的查詢,因為組合而導致桶的爆炸增長和內存急劇降低的問題。
Figure 48. Populate full depth for remaining nodes
要使用廣度優先,只需簡單的通過參數 collect
開啟:
{
"aggs" : {
"actors" : {
"terms" : {
"field" : "actors",
"size" : 10,
"collect_mode" : "breadth_first" #1
},
"aggs" : {
"costars" : {
"terms" : {
"field" : "actors",
"size" : 5
}
}
}
}
}
}
#1 按聚合來開啟 breadth_first
。
廣度優先只有在當桶內的文檔比可能生成的桶多時才應該被用到。深度搜索在桶層對文檔數據緩存,然后在修剪階段后的子聚合過程中再次使用這些文檔緩存。
在修剪之前,廣度優先聚合對於內存的需求與每個桶內的文檔數量成線性關系。對於很多聚合來說,每個桶內的文檔數量是相當大的。想象一個以月為間隔的直方圖:每個桶內可能有數以億計的文檔。這使廣度優先不是一個好的選擇,這也是為什么深度優先作為默認策略的原因。
但對於演員的示例,默認聚合生成大量的桶,但每個桶內的文檔相對較少,而廣度優先的內存效率更高。如果不是這樣,我們構建的聚合要不然就會失敗。