High cardinality下對持續寫入的Elasticsearch索引進行聚合查詢的性能優化
背景
最近使用騰訊云Elasticsearch Service的用戶提出,對線上的ES集群進行查詢,響應越來越慢,希望能幫忙優化一下。
查詢越來越慢的語句如下:
{
"_source": false,
"size": 0,
"aggs": {
"traceId": {
"aggs": {
"timestamp_millis": {
"min": {
"field": "timestamp_millis"
}
}
},
"terms": {
"field": "traceId",
"order": {
"timestamp_millis": "desc"
},
"size": 10
}
}
},
"query": {
"bool": {
"filter": {
"bool": {
"must": [
{
"range": {
"timestamp_millis": {
"from": 1556431798000,
"include_lower": true,
"include_upper": true,
"to": 1556435398000
}
}
},
{
"term": {
"user": "1275813850"
}
}
]
}
}
}
}
}
從查詢語句上看到用戶使用了聚合查詢(aggregation query), 第一反應就是聚合查詢影響了查詢速度。但是又發現,用戶的索引是按天創建的,查詢昨天的數據量較大的索引(300GB)響應并不慢,可以達到ms級別,但是查詢當天的正在寫入數據的索引就很慢,并且響應時間隨著寫入數據的增加而增加。
原因分析
初步分析查詢性能瓶頸就在于聚合查詢,但是又不清楚為什么查詢舊的索引會比較快,而查詢正在寫入的索引會越來越慢。所以趁機找了些資料了解了下聚合查詢的實現,最終了解到:
- 聚合查詢會對要進行聚合的字段構建Global Cardinals, 字段的唯一值越多(high cardinality),構建Global Cardinals構建的越慢,參考文章: https://blog.csdn.net/zwgdft/article/details/83215977
- 聚合查詢時構建好的Global Cardinals是存放在內存中的,如果索引不再發生變化(沒有新數據寫入而產生新的segment或者segment merge時), Global Cardinals就不需要重新構建,第一次進行聚合查詢時會構建好Global Cardinals,后續的查詢就會使用在內存中已經緩存好的Global Cardinals了
- 嘗試在查詢時增加execute_hit:map參數,結果無效,原因是用戶使用的6.4.3版本的集群該功能存在bug,雖然通過該參數execute_hit指定了不創建Global Cardinals,但是實際上還是創建了,后續版本已經修復了這個問題, 參考https://github.com/elastic/elasticsearch/issues/37705
優化方案
經過最終討論,決定從業務角度對查詢性能進行優化,既然對持續寫入的索引構建Global Cardinals會越來越慢,那就降低索引的粒度,使得持續寫入的索引數據量降低,同時增加了能夠使用Global Cardinals緩存的索引數據量。
詳細的優化方案如下:
- 降低索引的粒度,按小時創建索引
- 寫入時只寫入當前小時的索引,查詢時根據時間范圍查詢對應的索引
- 為了防止索引數量和分片數量膨脹,可以把舊的按小時創建的索引定期reindex到一個以當天日期為后綴的索引中,reindex完成之后再刪除按小時創建的索引。
實戰過程
根據優化方案,需要實現的內容包括:
- 按小時創建索引,寫入數據
- 每小時執行一次reindex, 把按小時建的索引reindex到按天建的索引中
- 定期刪除按小時建的索引
其中,第一步需要在client端進行,寫入數據時根據當前時間指定索引名稱,如當前時間是
"2019-05-07 03:50:06", 則寫入的索引名稱為2019-05-07-03;第二步和第三步都是定時任務,實戰時嘗試使用SCF(騰訊云Serverless云函數)進行簡單的配置即可。
1.創建SCF云函數
在騰訊云SCF控制臺中,選擇"新建",進入云函數創建頁面:
配置函數名稱,選擇名為"ES寫入函數"的模板,該模板自帶elasticsearch模塊,可以使用es的api操作集群。
創建完成后,需要在"函數配置"TAB頁對函數的網絡進行配置,選擇和Elasticsearch集群同vpc下的網絡:
接下來,就可以配置函數代碼和觸發方式,并進行測試。
1. 定期reindex
定期reindex的函數代碼如下:
# -*- coding: utf8 -*-
from datetime import datetime
from elasticsearch import Elasticsearch
import random
import time
# ES集群地址
ESServer = Elasticsearch("10.0.128.35:9200")
def reindex_hourly_2_daily():
# 索引前綴,到月份
index_prefix = "test-index-"+time.strftime( "%Y-%m" ,time.localtime(time.time())) +"-"
# 當前天
current_day = time.localtime(time.time()).tm_mday
# 當前小時,因為SCF是UTC時間,所以加8個小時,如果不在SCF里運行,則不用加8個小時,也不用進行時區轉換
current_hour = time.localtime(time.time()).tm_hour + 8
# 時區轉換
if current_hour >=24:
current_hour= current_hour-24
current_day = current_day +1
# 前一個小時
last_hour = current_hour -1
# 前一天
last_day = current_day-1
# 前一個小時的索引
last_hour_index=''
# 按天建的索引
daily_index=''
# 如果是0點,則把前一天23點的索引遷移到前一天按天建的索引
if current_hour ==0:
last_hour=23
last_day = current_day-1
# 構造出如2019-05-05格式的索引,日期中的天數小于10則補0
if last_day<10:
daily_index = index_prefix + "0"+ str(last_day)
else:
daily_index = index_prefix + str(last_day)
# 構造出如2019-05-05-01格式的索引,日期中的小時數小于10則補0
if last_hour<10:
last_hour_index = daily_index+ "-0"+ str(last_hour)
else:
last_hour_index = daily_index+ "-"+str(last_hour)
else:
# 構造出如2019-05-05格式的索引
if current_day<10:
daily_index = index_prefix + "0"+ str(current_day)
else:
daily_index = index_prefix + str(current_day)
if last_hour<10:
last_hour_index = daily_index+ "-0"+ str(last_hour)
else:
last_hour_index = daily_index+ "-"+ str(last_hour)
# 自動創建按天建的索引
ESServer.indices.create(daily_index, ignore=400)
body= {}
source ={
'index':last_hour_index
}
dest = {
'index':daily_index
}
body={
'source':source,
'dest':dest
}
# 執行reindex,source和index相同的情況下,重復執行多次也不會造成數據重復
rsp = ESServer.reindex(body=body,wait_for_completion=False)
# 執行reindex返回taskId, 可以通過輪詢taskId判斷操作是否完成
print rsp
def main_handler(event,context):
reindex_hourly_2_daily()
函數代碼說明:
- 使用該函數時需要把ES集群地址修改為自己的集群地址
- SCF執行時使用的時間是UTC時間而不是東八區,所以在編寫函數代碼的時候需要注意進行時區轉換
- 調用reindex api時指定wait_for_completion為false, 讓reindex操作異步執行,同時返回一個taskId, 后續可以通過task api輪詢該task查看任務是否完成;可以選擇在reindex完成后刪除按小時建的索引, 也可以選擇延遲刪除,后續定期清理掉按小時建的索引
- 無需擔心函數重復執行造成數據重復的情況,reindex執行的是一個upsert操作, 如果source index中的docId在dest index中不存在,則插入該doc,否則更新該doc
配置定期reindex函數的觸發方式為每小時的第1分鐘執行:
2. 定期刪除按小時建的索引
根據需要,可以選擇在每天凌晨0點到5點這個時間段,業務請求量不大時,刪除前一天按小時建的索引,避免過多的重復數據,以及避免分片數量膨脹。
函數代碼如下:
# -*- coding: utf8 -*-
from datetime import datetime
from elasticsearch import Elasticsearch
import random
import time
ESServer = Elasticsearch("10.0.128.35:9200")
def delete_old_index():
# 索引前綴,到月份
index_prefix = "test-index-"+time.strftime( "%Y-%m" ,time.localtime(time.time())) +"-"
# 當前天
current_day = time.localtime(time.time()).tm_mday
# 當前小時,因為SCF是UTC時間,所以加8個小時,如果不在SCF里運行,則不用加8個小時,也不用進行時區轉換
current_hour = time.localtime(time.time()).tm_hour + 8
# 前一天
last_day = current_day-1
if current_hour >=24:
last_day = current_day
# 需要刪除的索引,以通配符表示,如2019-05-05-*,表示刪除前一天所有的按小時建的索引
will_delete_index_prefix=''
if last_day<10:
will_delete_index_prefix = index_prefix + "0"+ str(last_day) +"-"
else:
will_delete_index_prefix = index_prefix + str(last_day)+"-"
for i in range(24):
hour = ""
if i<10:
hour = "0"+str(i)
else:
hour = str(i)
ESServer.indices.delete(will_delete_index_prefix+hour, ignore=[400, 404])
def main_handler(event,context):
delete_old_index()
函數說明:
- 該函數用于刪除前一天的按小時建的索引,如當前天是2019-06-07, 則函數執行時會刪除
2019-06-06-00到2019-06-06-23全部24個索引
配置定期刪除索引函數的觸發方式為每天的2點執行(SCF使用的是UTC時間,所以cron表達式中需要加8個小時):
總結
- 經過以上分析與實戰,我們最終降低了High cardinality下對持續寫入的Elasticsearch索引進行聚合查詢的時延,在利用緩存的情況下,聚合查詢響應在ms級
- 相比按天建索引,采用按小時建索引的優化方案,增加了部分冗余的數據,分片的數量也有增加;因為每小時的數據量相比每天要小的多,所以按小時建的索引分片數量可以設置的低一些,防止出現分片數量過多而大量占用內存的情況
- 如果數據量比較大,reindex會比較慢,可以通過snapshot api把按小時建的索引數據導入到按天建的索引中,數據導入的速度會比較快,可以參考文檔
https://cloud.tencent.com/document/product/845/19549