在 Cloudflare,我們是開源在線分析處理 (OLAP) 資料庫 ClickHouse 的重度使用者。每天,我們會呼叫 ClickHouse 數百萬次,來計算使用者應為其使用的 Cloudflare 產品支付多少費用。如果這些作業未能及時完成,後續的發票對帳就會變得非常困難。
這個管道支撐著數億美元的用量收入、防詐騙系統等業務,因此一旦延遲,下游影響會非常嚴重。
正因如此,當 ClickHouse 的每日彙總工作(負責確保 Cloudflare 帳單正常發出)在一次遷移後明顯變慢時,就成了一個大問題。所有常見的可疑因素看起來都正常:I/O、記憶體、掃描的列數、讀取的資料分區。我們通常在 ClickHouse 查詢變慢時會檢查的一切,似乎都沒有問題。
本文將為您講述我們如何發現深藏在 ClickHouse 內部的一個隱藏瓶頸,以及我們為解決該問題而編寫的三個修補程式。
我們在數十個叢集中使用 ClickHouse 儲存超過一百 PB 的資料。為了簡化我們眾多內部團隊的上線流程,我們在 2022 年初建立了一個名為「Ready-Analytics」的系統。
其概念很簡單:團隊不需要設計新的資料表,而是可以將資料串流到一個單一的大型資料表中。不同的資料集透過 namespace 來區分,每一筆記錄都使用標準的架構(例如:20 個浮點數欄位、20 個字串欄位、一個時間戳記以及一個 indexID)。
在 ClickHouse 中,資料的排序方式對查詢效能至關重要。這就是 indexID 發揮作用的地方。它是一個字串欄位,構成了主鍵的一部分,這意味著針對每個獨立的命名空間,其內部資料的排序方式都可以根據該命名空間擁有者預期的查詢模式進行最佳化。總而言之,我們最終得到一個如下所示的主鍵:(namespace, indexID, timestamp)。
這個系統很受歡迎,有數百個應用程式在使用。到 2024 年 12 月為止,它已經成長到超過 2PiB 的資料,且每秒有數百萬列的資料寫入。但它有一個關鍵的缺點:它的資料保留政策。
Cloudflare 使用 ClickHouse 已經很多年,早在它內建存留時間 (TTL) 功能之前就開始了。因此,我們基於分割區建立了一套自己的資料保留系統。Ready‑Analytics 資料表是以 day 為單位進行分割,而我們的資料保留工作就是直接刪除超過 31 天的分區。
這種「一刀切」的 31 天保留政策是一個重大限制。有些團隊因法律或合約要求需要儲存資料多年,而其他團隊可能只需要幾天。這種限制意味著這些使用情境無法採用 Ready-Analytics,必須選擇傳統的設定方式,而後者的上線流程要複雜得多。
我們需要一個允許每個命名空間自訂保留政策的新系統。
我們考慮了兩種主要方法:
每個命名空間一個表格:這自然能解決保留問題,但需要大量新的自動化機制來管理數千個按需建立的表格。
新的分區鍵: 我們可以將分區鍵從單純的 (day) 變更為 (namespace, day)。
我們選擇了第二個方案。這將使我們現有的保留系統能夠繼續管理分區,但現在可以精細到每個命名空間的粒度。
我們知道這會增加資料表中資料分區的總數量,但我們做了一個關鍵假設:由於每個查詢都會用特定的命名空間來篩選,因此任何單一查詢所讀取的分區數量應該不會改變。我們認為這代表效能不會受到影響。
圖示說明了我們如何變更分區方式,讓我們能以低成本刪除單一命名空間的資料
這個新系統也讓我們能夠建立一個精密的儲存管理層。使用「最大‑最小公平演算法」(max‑min fairness algorithm),我們可以設定一個目標磁碟使用率(例如 90%),並自動「共用」可用的空間。使用量低於其公平份額的命名空間,會將未使用的容量讓給更需要空間的命名空間。這讓我們能夠有信心地將叢集使用率維持在 90%。
我們在 2025 年 1 月開始進行遷移。利用 ClickHouse 的 Merge 資料表功能,我們將舊資料表與新資料表結合,所有新資料都寫入新的分區資料表,而舊資料則會隨著時間逐漸淘汰。
兩個月後,也就是 2025 年 3 月底,我們的帳單團隊回報他們每日的彙整作業變慢了。這些作業對時間非常敏感;如果它們無法完成,帳單就無法寄出。這些作業變得越來越慢,而我們正逼近最後期限。
我們進行了調查,但常見的嫌疑項目都不是問題所在。I/O 正常。記憶體正常。個別查詢的指標顯示,它們讀取的資料量或資料分區數量並沒有比之前多。我們最初的假設看似正確,但系統卻逐漸停滯。
我們花了幾天的時間才想出一個理論。最後,我們繪製了一張查詢執行時間與叢集中資料分區總數的關係圖。其關聯性是不容否認的。
Ready Analytics ClickHouse 叢集上的平均 SELECT 查詢執行時間,顯示效能逐漸衰退。
每個資料表副本的資料分區總數線性成長,這是採用新的 (namespace, day) 分區策略後的結果。
但為什麼會這樣?如果我們沒有讀取更多的分區,為什麼它們的存在就會拖慢我們的速度?
我們轉向使用 ClickHouse 內建的 trace_log 來產生火焰圖。這是一個內建的資料表,會記錄執行中 ClickHouse 伺服器的追蹤資訊。它不僅記錄了正在執行的程式碼軌跡,還會將這些軌跡與特定的使用者、查詢 ID 和其他中繼資料關聯起來,這表示必要時可以篩選出相當精確的事件集合。在我們的案例中,我們特別想查看葉節點 SELECT 查詢。由於這個資料表中提供了豐富的中繼資料,這很容易做到。
第一張基於 CPU 的火焰圖很快就證實了我們的懷疑:大量的時間花費在查詢規劃階段。這是在執行之前,ClickHouse 決定要讀取哪些分區的階段。
火焰圖顯示,葉節點查詢的 CPU 時間中有 45% 用於根據分區 ID 篩選分區向量
火焰圖清楚顯示:45% 的採樣 CPU 時間花在一個名為 filterPartsByPartition 的函數上。
我們第一次嘗試修復是針對這個程式碼路徑做了一個小修補。規劃器會評估啟發式規則來修剪分區,而我們認為這些規則沒有針對我們的資料表以最佳順序進行評估。我們的修補程式改變了順序,帶來了 5% 的小幅改善。我們走在正確的路上,但錯過了真正的問題。
我們之前產生的是「CPU」追蹤,它只會對使用中的執行緒進行取樣。我們切換到「Real」追蹤,它會對所有執行緒進行取樣,包括那些停用或正在等待的執行緒。新的火焰圖讓我們恍然大悟。
火焰圖顯示,葉節點查詢超過一半的執行時間都花費在等待一個保護活躍分區清單的互斥鎖 (mutex) 上
問題不在於受 CPU 限制的工作;而在於嚴重的鎖競爭 (lock contention)。我們查詢超過一半的時間都用來等待取得一個保護資料表分區清單的單一互斥鎖 (MergeTreeData)。為了規劃一個查詢,每一個執行緒都必須:
取得這個互斥鎖的獨佔鎖。
複製一份資料表中所有分區的完整清單。
釋放鎖。
將該清單篩減至相關的分區。
當擁有數萬個分區和數百個並行查詢時,它們全都只能乖乖地排成單一佇列依序等待。
這項發現幫助我們規劃了一系列的最佳化措施來緩解這些熱點。與我們對 ClickHouse 所做的所有修補一樣,我們嘗試讓它們具有通用性,並最終將它們貢獻到上游的程式碼庫中。這讓我們更容易維護我們的分支,也意味著社群也能從我們的修改中受益!
查詢規劃器並不會修改分區清單;它只是讀取而已。它根本沒有理由使用獨佔鎖。
修複方法:我們修改程式碼,改用共用鎖 (std::shared_lock)。這讓所有查詢規劃器能夠同時進入臨界區段。
結果:查詢執行時間立即大幅下降。鎖競爭消失了。
共用鎖最佳化(最佳化 1)對平均 SELECT 查詢時間的即時影響,顯示鎖競爭已解決。
效能雖然顯著改善,但仍未回到基準線。我們再次回到追蹤記錄,製作了另一張「Real」火焰圖。
火焰圖顯示,我們將四分之一的葉節點查詢時間用於複製所有分區的向量,另外四分之一時間用於篩選它(再次複製)。
新的火焰圖顯示瓶頸只是轉移了。現在,即使有了共用鎖,大部分時間仍然用來複製巨大的分區向量。直覺上,複製一個向量聽起來成本不高,但當它包含數萬個元素,並且每秒進行數百次時,累積起來就很可觀了。
修複方法:我們徹底延後了複製的動作。我們建立了一個分區清單「共用副本」。唯讀操作(例如查詢規劃)直接從這個副本讀取。任何會修改分區集合的操作(例如新的資料插入)則會重新產生快取。規劃器現在只複製它們實際需要的、已經篩選過的分區清單。
結果:又一次顯著的效能提升。
推出向量複製最佳化(最佳化 2)後的進一步效能提升。
在內部看到這些巨大的節省之後,我們決定將這些變更帶給社群。在與 ClickHouse Inc. 的維護者進行了一些小的設計迭代後,我們將這些變更合併到 PR #85535 中。自 ClickHouse 版本 25.11 起,這些變更就已可用。
我們仍然沒有停下來。隨著分區數量增加,效能仍然會下降,只是速度慢得多。與分區數量的關聯性仍然存在。幾個月後再次回到這個問題,新的火焰圖(看起來與圖 3 相同)顯示時間花費在篩選程式碼路徑上(我們第一次嘗試修復的那個)。這個程式碼會對所有分區進行線性掃描,逐一評估每個分區的述詞。幾個月後,我們又回到了最佳化之前的查詢執行時間。
但是我們知道這個分區清單是依照分區鍵排序的。請記住,分區鍵的第一個欄位是 namespace,絕大多數的查詢都會用它來篩選,因為它用來識別「租用戶」。我們要如何利用這一點?
修複方法:我們實作了一個基於分區 ID 中 namespace 部分的二元搜尋。這麼做之所以可行,是因為向量是已排序的,所以可以在不實際查看項目的情況下濾除很多項目。由於 namespace 是該排序鍵的第一部分,這種方法特別有效。在經過第一輪的二元搜尋之後,我們需要檢查的分區範圍大幅縮小,而對於這些分區,我們仍然會逐個檢查,套用與之前相同的邏輯,根據其他條件來排除分區。
結果:在 2026 年 3 月部署這個修補程式之後,查詢執行時間下降了 50%(見圖 8)。更重要的是,這終於打破了查詢執行時間與分區數量之間的關聯性。不幸的是,這個解決方案對於任意的查詢條件(例如 namespace in (5,10) 這樣的條件)並沒有那麼通用。我們正在研究更通用的方法,例如擴展查詢條件快取以涵蓋分區篩選。
實施二元搜尋進行分區修剪(最佳化 3)後,延遲持續降低。
這些最佳化解決了帳單系統眼前的危機。但這段歷程揭示了我們分區選擇所帶來的深層且不顯著的成本。
其他問題依然存在。在這篇部落格文章中,我們只描述了增加分區數量對 SELECT 執行時間造成的問題,但它也對 ZooKeeper 造成了困擾,因為 ZooKeeper 負責追蹤 ClickHouse 中所有分區的中繼資料。也許有一天我們會聊聊那個 100 GB ZooKeeper 叢集的故事。
我們為自己爭取到了寶貴的喘息空間,但根本問題依然存在:這種分區策略是長期的正確選擇嗎?或者我們最終仍需面對現實,轉向不同的架構?目前,我們的修補程式還在支撐著,但這次經歷清楚地說明了,即使是規劃周全的變更,也可能因為錯誤的假設而遭遇挫折。
當帳單團隊第一次回報這個問題時,我們每個副本有 3 萬個分區。分區數量從未停止成長,一年後我們每個副本達到了 16 萬個分區,但由於我們在這裡所做的最佳化,查詢執行時間一直保持穩定。
在 Cloudflare,我們在大規模的環境中解決複雜的工程問題。如果您覺得我們在這裡描述的偵錯與最佳化過程,聽起來像是您正在尋找的那種挑戰,歡迎看看我們正在招募的一些職缺。