文|劉家財(花名:塵香 )
螞蟻集團高階開發工程師 專注時序儲存領域
校對|馮家純
本文 7035 字 閱讀 10 分鐘
CeresDB 在早期設計時的目標之一就是對接開源協議,目前系統已經支援 OpenTSDB 與 Prometheus 兩種協議。Prometheus 協議相比 OpenTSDB 來說,非常靈活性,類似於時序領域的 SQL。
隨著內部使用場景的增加,查詢效能、服務穩定性逐漸暴露出一些問題,這篇文章就來回顧一下 CeresDB 在改善 PromQL 查詢引擎方面做的一些工作,希望能起到拋磚引玉的作用,不足之處請指出。
PART. 1 記憶體控制
對於一個查詢引擎來說,大部分情況下效能的瓶頸在 IO 上。為了解決 IO 問題,一般會把資料快取在記憶體中,對於 CeresDB 來說,主要包括以下幾部分:
- MTSDB:按資料時間維度快取資料,相應的也是按時間範圍進行淘汰
- Column Cache:按時間線維度快取資料,當記憶體使用達到指定閾值時,按時間線訪問的 LRU 進行淘汰
- Index Cache:按照訪問頻率做 LRU 淘汰
上面這幾個部分,記憶體使用相對來說比較固定,影響記憶體波動最大的是查詢的中間結果。如果控制不好,服務很容易觸發 OOM 。
中間結果的大小可以用兩個維度來衡量:橫向的時間線和縱向的時間線。
控制中間結果最簡單的方式是限制這兩個維度的大小,在構建查詢計劃時直接拒絕掉,但會影響使用者體驗。比如在 SLO 場景中,會對指標求月的統計資料,對應的 PromQL 一般類似 sum_over_time(success_reqs[30d]) ,如果不能支援月範圍查詢,就需要業務層去適配。
要解決這個問題需要先了解 CeresDB 中資料組織與查詢方式,對於一條時間線中的資料,按照三十分鐘一個壓縮塊存放。查詢引擎採用了向量化的火山模型,在不同任務間 next 呼叫時,資料按三十分鐘一個批次進行傳遞。
在進行上述的 sum_over_time 函式執行時,會先把三十天的資料依次查出來,之後進行解壓,再做一個求和操作,這種方式會導致記憶體使用量隨查詢區間線性增長。如果能去掉這個線性關係,那麼查詢數量即使翻倍,記憶體使用也不會受到太大影響。
為了達到這個目的,可以針對具備累加性的函式操作,比如 sum/max/min/count 等函式實現流式計算,即每一個壓縮塊解壓後,立即進行函式求值,中間結果用一個臨時變數儲存起來,在所有資料塊計算完成後返回結果。採用這種方式後,之前 GB 級別的中間結果,最終可能只有幾 KB。
PART. 2 函式下推
不同於單機版本的 Prometheus ,CeresDB 是採用 share-nothing 的分散式架構,叢集中有主要有三個角色:
- datanode:儲存具體 metric 資料,一般會被分配若干分片(sharding),有狀態
- proxy:寫入/查詢路由,無狀態
- meta:儲存分片、租戶等資訊,有狀態。
一個 PromQL 查詢的大概執行流程:
1.proxy 首先把一個 PromQL 查詢語句解析成語法樹,同時根據 meta 中的分片資訊查出涉及到的 datanode
2.通過 RPC 把語法樹中可以下推執行的節點傳送給 datanode
3.proxy 接受所有 datanode 的返回值,執行語法樹中不可下推的計算節點,最終結果返回給客戶端
sum(rate(write_duration_sum[5m])) / sum(rate(write_duration_count[5m])) 的執行示意圖如下:
為了儘可能減少 proxy 與 datanode 之間的 IO 傳輸,CeresDB 會盡量把語法樹中的節點推到 datanode 層中,比如對於查詢 sum(rate(http_requests[3m])) ,理想的效果是把 sum、rate 這兩個函式都推到 datanode 中執行,這樣返回給 proxy 的資料會極大減少,這與傳統關係型資料庫中的“下推選擇”思路是一致的,即減少運算涉及的資料量。
按照 PromQL 中涉及到的分片數,可以將下推優化分為兩類:單分片下推與多分片下推。
### 單分片下推
對於單分片來說,資料存在於一臺機器中,所以只需把 Prometheus 中的函式在 datanode 層實現後,即可進行下推。這裡重點介紹一下 subquery【1】 的下推支援,因為它的下推有別於一般函式,其他不瞭解其用法的讀者可以參考 Subquery Support【2】。
subquery 和 query_range【3】 介面(也稱為區間查詢)類似,主要有 start/end/step 三個引數,表示查詢的區間以及資料的步長。對於 instant 查詢來說,其 time 引數就是 subquery 中的 end ,沒有什麼爭議,但是對於區間查詢來說,它本身也有 start/end/step 這三個引數,怎麼和 subquery 中的引數對應呢?
假設有一個步長為 10s 、查詢區間為 1h 的區間查詢,查詢語句是 avg_over_time((a_gauge == bool 2)[1h:10s]) ,那麼對於每一步,都需要計算 3600/10=360 個資料點,按照一個小時的區間來算,總共會涉及到 360*360=129600 的點,但是由於 subquery 和區間查詢的步長一致,所以有一部分點是可以複用的,真正涉及到的點僅為 720 個,即 2h 對應 subquery 的資料量。
可以看到,對於步長不一致的情況,涉及到的資料會非常大,Prometheus 在 2.3.0 版本後做了個改進,當 subquery 的步長不能整除區間查詢的步長時,忽略區間查詢的步長,直接複用 subquery 的結果。這裡舉例分析一下:
假設區間查詢 start 為 t=100,step 為 3s,subquery 的區間是 20s,步長是 5s,對於區間查詢,正常來說:
1.第一步
需要 t=80, 85, 90, 95, 100 這五個時刻的點
2.第二步
需要 t=83, 88, 83, 98, 103 這五個時刻的點
可以看到每一步都需要錯開的點,但是如果忽略區間查詢的步長,先計算 subquery ,之後再把 subquery 的結果作為 range vector 傳給上一層,區間查詢的每一步看到的點都是 t=80, 85, 90, 95, 100, 105…,這樣就又和步長一致的邏輯相同了。此外,這麼處理後,subquery 和其他的返回 range vector 的函式沒有什麼區別,在下推時,只需要把它封裝為一個 call (即函式)節點來處理,只不過這個 call 節點沒有具體的計算,只是重新根據步長來組織資料而已。
call: avg_over_time step:3
└─ call: subquery step:5
└─ binary: ==
├─ selector: a_gauge
└─ literal: 2
在上線該優化前,帶有 subquery 的查詢無法下推,這樣不僅耗時長,而且還會生產大量中間結果,記憶體波動較大;上線該功能後,不僅有利於記憶體控制,查詢耗時基本也都提高了 2-5 倍。
多分片下推
對於一個分散式系統來說,真正的挑戰在於如何解決涉及多個分片的查詢效能。在 CeresDB 中,基本的分片方式是按照 metric 名稱,對於那些量大的指標,採用 metric + tags 的方式來做路由,其中的 tags 由使用者指定。
因此對於 CeresDB 來說,多分片查詢可以分為兩類情況:
1.涉及一個 metric,但是該 metric 具備多個分片
2.涉及多個 metric,且所屬分片不同
單 metric 多分片
對於單 metric 多分片的查詢,如果查詢的過濾條件中攜帶了分片 tags,那麼自然就可以對應到一個分片上,比如(cluster 為分片 tags):
up{cluster="em14"}
這裡還有一類特殊的情況,即
sum by (cluster) (up)
該查詢中,過濾條件中雖然沒有分片 tags,但是聚合條件的 by 中有。這樣查詢雖然會涉及到多個分片,但是每個分片上的資料沒有交叉計算,所以也是可以下推的。
這裡可以更進一步,對於具備累加性質的聚合運算元,即使過濾條件與 by 語句中都沒有分片 tags 時,也可以通過插入一節點進行下推計算,比如,下面兩個查詢是等價的:
sum (up)
# 等價於
sum ( sum by (cluster) (up) )
內層的 sum 由於包括分片 tags ,所以是可以下推的,而這一步就會極大減少資料量的傳輸,即便外面 sum 不下推問題也不大。通過這種優化方式,之前耗時 22s 的聚合查詢可以降到 2s。
此外,對於一些二元操作符來說,可能只涉及一個 metric ,比如:
time() - kube_pod_created > 600
這裡面的 time() 600 都可以作為常量,和 kube_pod_created 一起下推到 datanode 中去計算。
多 metric 多分片
對於多 metric 的場景,由於資料分佈沒有什麼關聯,所以不用去考慮如何在分片規則上做優化,一種直接的優化方式併發查詢多個 metric,另一方面可以借鑑 SQL rewrite 的思路,根據查詢的結構做適當調整來達到下推效果。比如:
sum (http_errors + grpc_errors)
# 等價於
sum (http_errors) + sum (grpc_errors)
對於一些聚合函式與二元操作符組合的情況,可以通過語法樹重寫來將聚合函式移動到最內層,來達到下推的目的。需要注意的是,並不是所有二元操作符都支援這樣改寫,比如下面的改寫就不是等價的。
sum (http_errors or grpc_errors)
# 不等價
sum (http_errors) or sum (grpc_errors)
此外,公共表示式消除技巧也可以用在這裡,比如 (total-success)/total 中的 total 只需要查詢一次,之後複用這個結果即可。
PART. 3 索引匹配優化
對於時序資料的搜尋來說,主要依賴 tagk->tagv->postings 這樣的索引來加速,如下圖所示:
對於 up{job="app1"} ,可以直接找到對應的 postings (即時間線 ID 列表),但是對於 up{status!="501"} 這樣的否定匹配,就無法直接找到對應的 postings,常規的做法是把所有的兩次遍歷做個並集,包括第一次遍歷找出所有符合條件的 tagv ,以及第二次遍歷找出所有的 postings 。
但這裡可以利用集合的運算性質【4】,把否定的匹配轉為正向的匹配。例如,如果查詢條件是 up{job="app1",status!="501"} ,在做合併時,先查完 job 對應的 postings 後,直接查 status=501 對應的 postings ,然後用 job 對應的 postings 減去 cluster 對應的即可,這樣就不需要再去遍歷 status 的 tagv 了。
# 常規計算方式
{1, 4} ∩ {1, 3} = {1}
# 取反,再相減的方式
{1, 4} - {2, 4} = {1}
與上面的思路類似,對於 up{job=~"app1|app2"} 這樣的正則匹配,可以拆分成兩個 job 的精確匹配,這樣也能省去 tagv 的遍歷。
此外,針對雲原生監控的場景,時間線變更是頻繁發生的事情,pod 的一次銷燬、建立,就會產生大量的新時間線,因此有必要對索引進行拆分。常見的思路是按時間來劃分,比如每兩天新生成一份索引,查詢時根據時間範圍,做多份索引的合併。為了避免因切換索引帶來的寫入/查詢抖動,實現時增加了預寫的邏輯,思路大致如下:
寫入時,索引切換並不是嚴格按照時間視窗,而是提前指定一個預寫點,該預寫點後的索引會進行雙寫,即寫入當前索引與下一個索引中。這樣做的依據是時間區域性性,這些時間線很有可能在下一個視窗依然有效,通過提前的預寫,一方面可以預熱下一個索引,另一方面可以減緩查詢擴分片查詢的壓力,因為下一分片已經包含上一分片自預寫點後的資料,這對於跨過整點的查詢尤為重要。
PART. 4 全鏈路 trace
在實施效能優化的過程中,除了參考一些 metric 資訊,很重要的一點是對整個查詢鏈路做 trace 跟蹤,從 proxy 接受到請求開始,到 proxy 返回結果終止,此外還可以與客戶端傳入的 trace ID 做關聯,用於排查使用者的查詢問題。
說來有趣的是,trace 跟蹤效能提升最高的一次優化是刪掉了一行程式碼。由於原生 Prometheus 可能會對接多個 remote 端,因此會對 remote 端的結果按時間線做一次排序,之後合併時就可以用歸併的思路,以 O(n*m) 的複雜度合併來自 n 個 remote 端的資料(每個 remote 端假設有 m 條時間線)。但對於 CeresDB 來說,只有一個 remote 端,因此這個排序是不需要的,去掉這個排序後,那些不能下推的查詢基本提高了 2-5 倍。
PART. 5 持續整合
儘管基於關係代數和 SQL rewrite rule 等有一套成熟的優化規則,但還是需要用整合測試來保證每次開發迭代的正確性。CeresDB 目前通過 linke 的 ACI 做持續整合,測試用例包括兩部分:
- Prometheus 自身的 PromQL 測試集【5】
- CeresDB 針對上述優化編寫的測試用例
在每次提交 MR 時,都會執行這兩部分測試,通過後才允許合入主幹分支。
PART. 6 PromQL Prettier
在對接 Sigma 雲原生監控的過程中,發現 SRE 會寫一些特別複雜的 PromQL,肉眼比較難分清層次,因此基於開源 PromQL parser 做了一個格式化工具,效果如下:
Original:
topk(5, (sum without(env) (instance_cpu_time_ns{app="lion", proc="web", rev="34d0f99", env="prod", job="cluster-manager"})))
Pretty print:
topk (
5,
sum without (env) (
instance_cpu_time_ns{app="lion", proc="web", rev="34d0f99", env="prod", job="cluster-manager"}
)
)
下載、使用方式見該專案 README【6】。
「總 結」
本文介紹了隨著使用場景的增加 Prometheus on CeresDB 做的一些改進工作,目前 CeresDB 的查詢效能,相比 Thanos + Prometheus 的架構,在大部分場景中有了 2-5 倍提升,對於命中多個優化條件的查詢,可以提升 10+ 倍。CeresDB 已經覆蓋 AntMonitor (螞蟻的內部監控系統)上的大部分監控場景,像 SLO、基礎設施、自定義、Sigma 雲原生等。
本文羅列的優化點說起來不算難,但難在如何把這些細節都做對做好。在具體開發中曾遇到一個比較嚴重的問題,由於執行器在流水線的不同 next 階段返回的時間線可能不一致,加上 Prometheus 特有的回溯邏輯(預設 5 分鐘),導致在一些場景下會丟資料,排查這個問題就花了一週的時間。
記得之前在看 Why ClickHouse Is So Fast?【7】 時,十分贊同裡面的觀點,這裡作為本文的結束語分享給大家:
“ What really makes ClickHouse stand out is attention to low-level details.”
招 聘
我們是螞蟻智慧監控技術中臺的時序儲存團隊,我們正在使用 Rust 構建高效能、低成本並具備實時分析能力的新一代時序資料庫。
螞蟻監控風險智慧團隊持續招聘中,團隊主要負責螞蟻集團技術風險領域的智慧化能力及平臺建設,為技術風險幾大戰場(應急,容量,變更,效能等)的各種智慧化場景提供演算法支援,包含時序資料異常檢測,因果關係推理和根因定位,圖學習和事件關聯分析,日誌分析和挖掘等領域,目標打造世界領先的 AIOps 智慧化能力。
歡迎投遞諮詢 :jiachun.fjc@antgroup.com
「參 考」
· PromQL Subqueries and Alignment
【1】subquery:
https://prometheus.io/docs/prometheus/latest/querying/examples/#subquery
【2】Subquery Support:
https://prometheus.io/blog/2019/01/28/subquery-support/
【3】query_range
https://prometheus.io/docs/prometheus/latest/querying/api/#range-queries
【4】運算性質
https://zh.wikipedia.org/wiki/%E8%A1%A5%E9%9B%86
【5】PromQL 測試集
https://github.com/prometheus/prometheus/tree/main/promql/testdata
【6】README
https://github.com/jiacai2050/promql-prettier
【7】Why ClickHouse Is So Fast?
https://clickhouse.com/docs/en/faq/general/why-clickhouse-is-so-fast/