Prometheus on CeresDB 演進之路

文|劉家財(花名:塵香 )

螞蟻集團高級開發工程師 專注時序存儲領域

校對|馮家純

本文 7035 字 閱讀 10 分鐘

CeresDB 在早期設計時的目標之一就是對接開源協議,目前系統已經支持 OpenTSDB 與 Prometheus 兩種協議。Prometheus 協議相比 OpenTSDB 來說,非常靈活性,類似於時序領域的 SQL。

隨着內部使用場景的增加,查詢性能、服務穩定性逐漸暴露出一些問題,這篇文章就來回顧一下 CeresDB 在改善 PromQL 查詢引擎方面做的一些工作,希望能起到拋磚引玉的作用,不足之處請指出。

PART. 1 內存控制

對於一個查詢引擎來說,大部分情況下性能的瓶頸在 IO 上。爲了解決 IO 問題,一般會把數據緩存在內存中,對於 CeresDB 來說,主要包括以下幾部分:

  • MTSDB:按數據時間維度緩存數據,相應的也是按時間範圍進行淘汰

  • Column Cache:按時間線維度緩存數據,當內存使用達到指定閾值時,按時間線訪問的 LRU 進行淘汰

  • Index Cache:按照訪問頻率做 LRU 淘汰

img

上面這幾個部分,內存使用相對來說比較固定,影響內存波動最大的是查詢的中間結果。如果控制不好,服務很容易觸發 OOM 。

中間結果的大小可以用兩個維度來衡量:橫向的時間線和縱向的時間線。

img

控制中間結果最簡單的方式是限制這兩個維度的大小,在構建查詢計劃時直接拒絕掉,但會影響用戶體驗。比如在 SLO 場景中,會對指標求月的統計數據,對應的 PromQL 一般類似 sum_over_time(success_reqs[30d]) ,如果不能支持月範圍查詢,就需要業務層去適配。

要解決這個問題需要先了解 CeresDB 中數據組織與查詢方式,對於一條時間線中的數據,按照三十分鐘一個壓縮塊存放。查詢引擎採用了向量化的火山模型,在不同任務間 next 調用時,數據按三十分鐘一個批次進行傳遞。

img

在進行上述的 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])) 的執行示意圖如下:

img

爲了儘可能減少 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 這樣的索引來加速,如下圖所示:

img

對於 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 的一次銷燬、創建,就會產生大量的新時間線,因此有必要對索引進行拆分。常見的思路是按時間來劃分,比如每兩天新生成一份索引,查詢時根據時間範圍,做多份索引的合併。爲了避免因切換索引帶來的寫入/查詢抖動,實現時增加了預寫的邏輯,思路大致如下:

寫入時,索引切換並不是嚴格按照時間窗口,而是提前指定一個預寫點,該預寫點後的索引會進行雙寫,即寫入當前索引與下一個索引中。這樣做的依據是時間局部性,這些時間線很有可能在下一個窗口依然有效,通過提前的預寫,一方面可以預熱下一個索引,另一方面可以減緩查詢擴分片查詢的壓力,因爲下一分片已經包含上一分片自預寫點後的數據,這對於跨過整點的查詢尤爲重要。

img

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 時,都會運行這兩部分測試,通過後才允許合入主幹分支。

img

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 智能化能力。

歡迎投遞諮詢 :[email protected]

「參 考」

· 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/

本週推薦閱讀

如何在生產環境排查 Rust 內存佔用過高問題

新一代日誌型系統在 SOFAJRaft 中的應用

終於!SOFATracer 完成了它的鏈路可視化之旅

螞蟻集團技術風險代碼化平臺實踐(MaaS)

img

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章