1. 程式人生 > >聚合查詢越來越慢?——詳解Elasticsearch的Global Ordinals與High Cardinality

聚合查詢越來越慢?——詳解Elasticsearch的Global Ordinals與High Cardinality

  Elasticsearch中的概念很多,本文將從筆者在實踐過程中遇到的問題出發,逐步詳細介紹 Global OrdinalsHigh Cardinality ,這也是筆者的認知過程。文中的Elasticsearch 版本為5.5。

背景

  故事是這樣的,因為業務需要,我們在專案中設計了一種針對Elasticsearch資料的非同步去重方法(注:關於Elasticsearch資料去重,筆者會在另一篇博文中更加詳細介紹),基本思路是:

  • 為每一條資料計算hash值,作為document的一個欄位(keyword型別)插入到Elasticsearch中,資料格式簡化如下:
{
    "timestamp": 1540099182,
    "msgType": 1210,
    ......
    "hash": "31a2c683dccb83ef8b8d1ee43290df62"
}
  • 每隔一段時間,執行一次檢測指令碼,檢查Elasticsearch中的資料是否有重複,相關查詢語句如下(這裡,terms聚合用於發現給定時間範圍內是否有超過2條hash值一樣的資料,top_hits聚合用於找出重複資料組中的具體資料資訊,然後刪除掉重複的資料即可):
{
    "size": 0,
    "query": {
        "bool": {
            "filter": [
                {  
                   "range":{  
                      "timestamp":{  
                         "gte": 1540087200,
                         "lt": 1540087500
                      }
                   }
                }
            ]
        }
    },
    "aggs": {
        "duplications": {
            "terms": {
                "field": "hash",
                "min_doc_count": 2,
                "size": 500
            },
            "aggs": {
                "top_duplications": {
                    "top_hits": {
                        "size": 3
                    }
                }
            }
        }
    }
}

  這樣一個方案,因為只是在資料集中增加了一個hash欄位,並且去重是非同步的,不會影響到原有的設計,所以在通過相關的功能性測試後就上線了。然而,執行一段時間後,出現了嚴重問題:

  • 隨著新資料的寫入,上述的查詢語句變得越來越慢,從秒級逐步變成要20多秒,並且在資料量超過10億條後,每次查詢都會使記憶體超過80%;
  • index的儲存空間比原先增加了近一倍

  對於類似上述的查詢語句,Elasticsearch會先根據Filter條件找出匹配的document,然後再進行聚合運算。在我們的業務中,每次查詢2小時內的資料,並且資料的寫入是勻速的,這意味著每次匹配出來的document個數基本是固定的,那麼為何會出現這個查詢越來越慢的問題?而且,我們發現,即使Filter匹配的document個數為0,也同樣需要很久才能返回結果

  另一方面,經過對比驗證,可以確定是新增加的hash欄位導致了資料儲存空間比原先增加了近一倍。
  帶著這些問題,筆者進行了詳細的調研,最終鎖定Global OrdinalsHigh Cardinality兩個核心概念。其中,github上面的一個issue Terms aggregation speed is proportional to field cardinality 給了很大的啟發。

Global Ordinals

什麼是Ordinals?

  假設有10億條資料,每條資料有一個欄位status(keyword型別),其值有三種可能性:status_pending、status_published、status_deleted,那麼每條資料至少需要14-16 Bytes,也就是說需要將近15GB記憶體才能裝下所有資料。

Doc     |   Term
-------------------------------
0       |   status_pending
1       |   status_deleted
2       |   status_published
3       |   status_pending

  為了減少記憶體使用,考慮將字串排序後進行編號,形成一張對映表,然後在每條資料中使用相應字串的序號來表示。通過這樣的設計,可以將所需記憶體從15 GB減少為1 GB左右。
  這裡的對映表,或者說對映表中的序號,就是Ordinals。

Ordinal |   Term
-------------------------------
0       |   status_deleted
1       |   status_pending
2       |   status_published

Doc     |   Ordinal
-------------------------------
0       |   0   # deleted
1       |   2   # published
2       |   1   # pending
3       |   0   # deleted
什麼是Global Ordinals?

  當我們對status欄位做Terms聚合查詢時,請求會透過Coordinate Node分散到Shard所在的Node中執行,而針對每個Shard的查詢又會分散到多個Segment中去執行。
  上述的Ordinals是per-segment ordinals,是針對每個Segment裡面的資料而言,意味著同一個字串在不同的per-segment ordinals中可能對應的序號是不同的。比如,在Segment 1中只有status_deleted(0)和status_published(1)兩個值,而Segment 2中有3個值:status_deleted(0),status_pending(1),status_published(2)。
  這樣就面臨一個抉擇:方案一,在完成per-segment的查詢後,將相應的序號轉換成字串,返回到Shard層面進行合併;方案二,構建一個Shard層面的Global Ordinals,實現與per-segment ordinals的對映,就可以在Shard層面完成聚合後再轉換成字串。
  經過權衡,Elasticsearch(Lucene)選擇了方案二作為預設方法:構建Global Ordinals。

為何會影響聚合查詢?

  構建Global Ordinals的目的是為了減少記憶體使用、加快聚合統計,在大多數情況下其表現出來的效能都非常好。之所以會影響到查詢效能,與其構建時機有關:

  • 由於Global Ordinals是Shard級別的,因此當一個Shard的Segment發生變動時就需要重新構建Global Ordinals,比如有新資料寫入導致產生新的Segment、Segment Merge等情況。當然,如果Segment沒有變動,那麼構建一次後就可以一直利用快取了(適用於歷史資料)。
  • 預設情況下,Global Ordinals是在收到聚合查詢請求並且該查詢會命中相關欄位時構建,而構建動作是在查詢最開始做的,即在Filter之前

  這樣的構建方式,在遇到某個欄位的值種類很多(即下文所述的High Cardinary問題)時會變的非常慢,會嚴重影響聚合查詢速度,即使Filter出來的document很少也需要花費很久,也就是上文筆者遇到的問題,即在High Cardinary情況下,構建Global Ordinals非常慢。因為我們新加的hash欄位對於每條資料都不一樣,所以當寫入越來越多的資料後,聚合查詢越來越慢(大概超過5000W條之後)。

有哪些優化方法?

  雖然在Lucene 7.1中,針對global ordinals的構建有些優化(LUCENE-7905),但是仍然不能避免這樣的問題。目前有這樣幾種優化方法(或者說是緩解之法,目前尚未發現完美的方法):

  • 增加Shard個數。因為Global Ordinals是Shard層面的,增加Shard個數也許可以緩解問題,前提是:第一,要能確定有問題的欄位的值種類可以通過該方式減少在單個Shard中的量;第二,確保Shard的個數增加不會影響到整體的效能。
  • 延長refresh interval,即減少構建Global Ordinals的次數來緩解其影響,前提是要能接受資料的非實時性。
  • 修改execution_hint的值。在Terms聚合中,可以設定執行方式是map還是global_ordinals,前者的意思是直接使用該欄位的字串值來做聚合,即無需構建Global Ordinals。這樣的方式,適用於可以確定匹配文件資料量的場景,並且不會引起記憶體的暴增,比如在筆者的業務場景中,每次只查詢2小時內的資料量。這也是當前我們的優化方法。
GET /_search
{
    "aggs" : {
        "tags" : {
             "terms" : {
                 "field" : "status",
                 "execution_hint": "map" 
             }
         }
    }
}

High Cardinality

  相信看完上文,讀者已經知道什麼是High Cardinality了。所謂High Cardinality,指的是Large Number of Unique Values,即某個欄位的值有很多很多種,比如筆者業務中的那個hash欄位。在Elasticsearch,High Cardinality會帶來各種問題,百害而無一利,所以應該儘量避免,避免不了也要做到心中有數,在出問題時可以及時調整。

  • High Cardinality會導致構建Global Ordinals過程變慢,從而導致聚合查詢變慢、記憶體使用過高。
  • High Cardinality會導致壓縮比率降低,從而導致儲存空間增加,特別是像hash值這樣完全隨機的字串。
  • 對High Cardinality欄位執行Cardinality聚合查詢時,會受到精度控制從而導致結果不精確。

  本文結合筆者在實踐過程中遇到的由High Cardinality引起Global Ordinals構建過慢,從而導致聚合查詢變慢的問題,闡述了Global Ordinals和High Cardinality兩個核心概念,希望對遇到類似問題的人有所幫助。目前,針對我們的業務場景,相關的調整有:第一,使用"execution_hint": "map"來避免構建Global Ordinals;第二,嘗試在資料上傳端增加對壓縮友好的唯一鍵來作為去重物件,比如uuid4;第三,減小index的切割時間,比如從weekly index變成daily index,從而降低index中單個shard的資料量。


Bruce
2018/10/22 下午