分區鍵改了,查詢仍然有精確的 namespace 過濾,理論上應該更快才對。實際上,帳單管線開始在幾分鐘內停滯——I/O 正常,記憶體正常,CPU 正常。問題藏在一個從沒人量測過的 mutex 裡。
ClickHouse query planner 的 mutex 瓶頸與三階段修補
Cloudflare 的 Ready-Analytics ClickHouse 叢集規模超過 100PB,核心表在 2024 年 12 月已累積 2PiB 以上資料、每秒攝取數百萬筆。帳單管線跑在這份資料上——任何聚合事件都直接傳導到客戶可見的計費正確性。
為了支援 per-namespace 保留政策,工程師把分區鍵從 (day) 改為 (namespace, day)。變更看似無害:查詢本來就按 namespace 過濾,新分區鍵讓過濾更精確、保留政策得以個別套用。
但 part 數量從 3 萬攀升至 16 萬。一個深藏在 query planner 裡的 mutex 開始讓數百條平行查詢排隊等候。
具體機制是:query planner 在拿到每條查詢需要的 parts 子集前,必須對整個 parts 向量做一次完整的線性過濾,而這個過濾在獨佔鎖底下進行。鎖持有時間隨 parts 數量線性成長,於是每條並行查詢都被前一條的鎖持有時間阻塞。bottleneck 不在 I/O、不在 CPU、不在 disk,而在一個比 std::mutex 大不了多少的 critical section 裡。
問題在 2025 年 1 月隨分區鍵變更開始醞釀,到 3 月底帳單團隊才回報「事情不太對」——中間兩個月部分查詢已在退化,但 I/O 與 CPU 圖看起來正常,沒有警報觸發。這是「假設與現實開始發散,但量測仍回報健康」的經典樣態。
MergeTreeData mutex 的鎖定語意
ClickHouse 的 MergeTree 引擎把資料儲存在多個 part 中——每個 part 是排序好的、不可變的資料片段,新資料以新 part 落地,背景 merge 任務週期性把小 parts 合併成大 parts。這個 LSM-like 設計是高寫入吞吐量的關鍵;讀者則承擔「跨多個 parts 合併視圖」的成本,parts 數量大時 trade-off 向寫者傾斜。
Query planner 在執行每條查詢時需要拿到當下完整的 parts 清單,再從中過濾出符合 WHERE 條件的目標分區,最後交給 reader threads 平行掃描。planner 是 single thread、sequential bottleneck——在 16 萬 parts 的世界裡變成關鍵限制。
問題在「拿到那份清單」的方式:planner 對 MergeTreeData 結構持有獨佔鎖(std::mutex),把整個 parts 向量複製出來,然後釋放鎖。複製出來的副本交給 filterPartsByPartition 做線性掃描,過濾出目標 namespace 對應的 parts。
在 3 萬 parts 時整個操作是毫秒等級、鎖競爭幾乎不可見;在 16 萬 parts 時,複製本身開始耗時,加上數百條並行查詢同時搶這把鎖,query planning 變成嚴格串行化的瓶頸。鎖競爭是 self-amplifying 的——parts 越多 → 鎖持有越久 → 排隊越長 → 後續延遲越高——形成 feedback loop,閾值前隱性、過閾值後 dominant。30k parts 時沒問題、80k 時開始失控,閾值附近只有 step function。
關鍵細節是:這把鎖保護的是「parts 清單本身」,不是 parts 內容。讀取 planner 只要讀清單、做過濾,並沒有要修改清單;但原始實作用 std::mutex——一把獨佔鎖,不分讀寫。即使所有等候者都只是來讀,仍然必須一個接一個進入 critical section。這是修補的第一個槓桿點。
filterPartsByPartition 對 partition key prefix 做線性比對,整體 O(N);3 萬 parts 時是 CPU 上小尖峰,16 萬時是火焰圖上單一函式佔 45%。但這只是「複製完畢、進入過濾」之後的計算成本——complete picture 還包含等待鎖的時間,而 CPU 火焰圖看不到前者。每個 part metadata entry 數百 bytes,16 萬 × 數百 bytes ≈ 數十 MB 的複製,每條查詢一次。這成本進一步推高 critical section 的時間。
原始實作用獨佔鎖是因為這把鎖同時保護「讀清單」與「修改清單」。在 parts 數量小、平行查詢少的世界裡用獨佔沒什麼成本,於是 default 就是獨佔——這是典型「default 在使用模式變化前合理,變化後變成 anti-pattern」的故事。
annotated 架構:mutex 在哪一層
把 ClickHouse 的 MergeTree 查詢規劃路徑攤平,鎖在哪一段、誰看不見誰,會比抽象描述清楚很多。下面這張圖把整條路徑切成四個責任邊界:
並行查詢執行緒 · 責任
每條進來的 SQL 查詢都需要先做 query planning,才能進入 reader pool 開始實際讀資料。Cloudflare 的叢集上同時有「數百條」這樣的執行緒在跑——帳單聚合是大宗,但其它分析查詢也在搶同一份 parts 清單。
看不見:自己究竟是 plan 慢還是 read 慢——標準的查詢耗時欄位把兩段合在一起報。
MergeTreeData · 責任
這是表格層級的元資料結構,protected by 一個 std::mutex。任何要讀完整 parts 清單的人都得先拿這把鎖、複製整個 vector、再放鎖。在原始實作裡,鎖是獨佔的,所有來讀的執行緒都得排隊。
看不見:誰在排隊、排了多久——這層只回應「現在誰拿著鎖」。
Parts vector · 責任
持有當下所有 active parts 的記憶體向量。每個 part 是一段排序好的不可變資料;新進的資料以新 part 形式落地,背景合併任務週期性地把小 parts 合併成大 parts。在 (namespace, day) 分區鍵下,向量天然按 namespace 排好序——這個 invariant 後來成為第三階段二分搜尋優化的關鍵。
看不見:自己被複製了幾次、副本最後用到幾個 element。
filterPartsByPartition · 責任
對複製出來的 parts 向量副本做線性掃描,挑出符合查詢 WHERE 條件的 parts。在 CPU 火焰圖上這個函式佔了 45%,是第一個明顯的訊號。但這只是「複製完畢、進入過濾」之後的 CPU 成本——並不包含等待鎖的時間。
看不見:自己上游的 critical section 持有時間。一個只看 CPU 火焰圖的工程師會以為這個函式是元兇,而不是症狀。
修補前的查詢規劃路徑:每條查詢都要對 MergeTreeData 結構持有獨佔鎖、複製整個 parts 向量、釋放鎖、…
MergeTreeData 的獨佔鎖讓 160k parts 下的並行查詢全部串行化,瓶頸在 critical section 而非 I/O。
點任一框查看其責任——每個 panel 都標示「看不見」的維度。在大型系統的 root cause analysis 裡,「誰看不見誰」往往比「誰做什麼」更重要:bottleneck 經常藏在 invisibility 的邊界上。並行查詢執行緒只知道自己慢、不知道是 plan 慢還是 read 慢;MergeTreeData 只知道現在誰拿著鎖、不知道誰在排隊;filterPartsByPartition 只知道自己 CPU 上跑了 45%、不知道上游等了多久。每一層的儀表都合理,但組合起來看不到 critical path 的真正樣貌。
把這想成 sequence diagram 會更清楚 bottleneck 的時間維度:N 條並行查詢進來,第 1 條拿鎖、複製向量、釋放鎖、開始過濾——這段時間第 2 到第 N 條全部在 sleep;第 N 條開始拿鎖時,前面 N-1 條的累積鎖持有時間就是它的等候時間。鎖持有時間隨 parts 數線性成長、等候時間隨並行度線性成長,端到端延遲是 O(N × parts)——3 萬 parts 時可容忍,16 萬時超過任何 SLA 預算。
診斷的轉折——CPU mode vs Real mode trace_log
診斷的關鍵突破來自 ClickHouse 內建的 trace_log。預設的「CPU mode」採樣只記錄當下在 CPU 上的 thread——等候 mutex 的 thread 不在 CPU 上跑,完全不會出現在火焰圖。切換到「Real mode」後所有 thread 都被採樣,包括正在睡覺等鎖的,mutex 等待時間才被看見。
CPU 模式火焰圖顯示 filterPartsByPartition 佔 45% CPU 時間——只看這張圖,自然的修補方向是優化過濾函式本身(換演算法、加 SIMD、降低 cache miss)。Real 模式則顯示「超過一半的 leaf 查詢時間花在等待 mutex」。同一個系統、同一段時間、同一個瓶頸,兩種眼鏡指向完全不同的修補方向——一個 compute optimization,一個 concurrency restructuring。
同一段時間、同一個查詢負載,兩種 trace_log 採樣模式給出截然不同的元兇診斷
CPU 模式火焰圖看不見 mutex 等待;Real 模式才揭示 50% 以上延遲在 critical section 排隊。
Cloudflare 工程師發現問題的順序值得記下來:先是 CPU 火焰圖把 filterPartsByPartition 標出來、試了一個改善 part 排序的小修補(拿到 5% 改善),然後才意識到 5% 不對勁——如果這個函式真的是瓶頸,優化它應該拿到更大改善。「改善幅度不符合假設」是切換到 Real mode 採樣的觸發點。
Real mode 採樣在 thread 進入 sleep 時記錄一筆 sample、退出 sleep 時記錄另一筆,兩者時間差就是 wait 時長。這些 sample 連同 stack trace 寫入 system.trace_log,可以用 SQL 聚合再導出成 perf format 給 FlameGraph script 畫圖。在 ClickHouse 21.x 之後就有了,但很多營運團隊從沒切過 Real mode,因為平常 CPU mode 夠用——CPU profiling 非常擅長找「在 CPU 上跑得太久」的程式碼(80% 的性能問題形態),但鎖等待這類「明明耗時但沒消耗 CPU」的瓶頸正好在它的盲區。
診斷流程可以概念化為:先 on-CPU profiler 看是否有顯著熱點;若優化後改善幅度顯著低於熱點佔比,跳到 off-CPU profiler 看等候堆疊。CPU profile 把「等候時間」當作 invisible 處理,而現代生產系統的瓶頸越來越多是 wait-bound 而非 compute-bound——鎖、I/O、network、cross-region replication、GC pause 全在 on-CPU profiler 的盲區。Linux 上有 perf + BCC 的 offcputime;JVM 有 async-profiler 的 wall mode;Go 有 trace 跟 block profile;ClickHouse 有 trace_log Real mode——每個 runtime 都有對應工具,但工具的存在不等於被使用,大多數團隊只在 incident 時想到要切換。
三階段修補的結構
修補不是一次到位,而是三個遞進的優化,每個針對瓶頸的不同層次。
第一階段——shared lock:query planner 讀取 parts 向量時並不會修改它,所以獨佔鎖是過度保守的。改為 std::shared_lock(read lock)後,多條查詢可以同時持有鎖。Cloudflare 的描述是「lock contention vanished」——競爭立即消失,查詢延遲即時下降。安全性的 invariant 是「向量本身不被同時 mutate」——新 part 加入與 merge 完成都走寫鎖,shared / exclusive lock 語意保證這一點。
第二階段——cached vector copy:即使有了 shared lock,每條查詢仍然要把完整 16 萬個 parts 複製出來、再過濾掉絕大部分。修補方向是維護一份 shared cached copy:任何修改 parts 集合的操作 regenerate cache,讀者直接讀 cached copy,planner 只複製過濾後的子集——從複製 16 萬個元素降到複製幾千個。這在 PR #85535 裡和第一階段一起合進 ClickHouse 25.11。風險點是 cache regeneration 必須在 parts mutation 的同一個寫鎖區段內完成,否則讀者可能拿到過期的 cache。
第三階段——namespace 二分搜尋:因為 (namespace, day) 是分區鍵,parts 向量按 namespace lexicographic 排序,過濾 namespace 不需要線性掃描,std::lower_bound 可以直接定位目標 namespace 的區段,把查詢延遲與 part 數量的相關性從 O(N) 壓縮到 O(log N)。第三階段在 2026 年 3 月部署到 Cloudflare 內部叢集,再帶來約 50% 的 query duration 下降。
第三階段沒有合進上游,原因是它依賴「parts 向量按分區鍵 prefix 排序」這個 invariant,對 (namespace, day) 這類 categorical prefix 成立,對更一般的 partition expression 就不一定。要推上游得在 part metadata 裡加「partition prefix 是否單調」的 flag、在 planner 裡按 flag dispatch——這是更大的設計提案。
三個階段展示一個漸進優化的設計原則:先解決串行化(最大效果、最小改動),再解決常數因子,最後改變演算法複雜度。如果倒過來先做 Phase 3,會得到「演算法漂亮但 mutex 仍在排隊」的結果——這是 Amdahl's law 的 corollary:被串行化包住的內部優化看不到效果。
下面這個 widget 把四個版本放在同一條 x 軸上,讓你拖動 part 數看四條曲線如何分歧。
拖動 part 數,四條曲線在不同尺度上各自分歧——修補前的曲線在 80k parts 後開始失控,Phase 1 把…
parts 超過 80k 後修補前曲線陡升;160k 時三階段修補讓延遲差距超過 30 倍。
30k parts 時四條曲線幾乎重疊——這就是為什麼問題醞釀了兩個月才被注意到,在這個尺度上四種實作彼此 indistinguishable。70k–80k 區間時修補前的曲線開始顯著拐離其它三條,超過 100k 後陡升,到 160k 時已是 Phase 3 的 30 倍以上。這個非線性拐點是「無聲災難」的典型形態。
Phase 3 幾乎是一條與 x 軸平行的線——O(log N) 在這個尺度上和 constant 沒什麼差別。曲線形狀根據 Cloudflare blog 描述的定性關係建模,y 軸是「相對值」而非 ms,blog 沒有給出絕對數字。讀者帶走四條曲線的 ordering 即可,絕對毫秒數應以自己叢集 benchmark 為準。
逐項對比:三個 PR 各修補了什麼
把三個階段拆解成表格,能看清楚每一階段精確改了什麼層次、影響哪個 invariant、有沒有合進上游:
| 階段 | 機制 | 複雜度 | 關鍵 invariant | 上游 |
|---|---|---|---|---|
| 前置 | parts 排序順序的局部調整(pre-fix) | O(N) | 讓 filterPartsByPartition 的 cache locality 好一點點 | 5% 改善——不夠,但揭露了真正瓶頸不在 CPU |
| Phase 1 | std::mutex → std::shared_lock | O(N) 但去除串行化 | parts vector 在持有 shared lock 期間不會被 mutate(寫鎖獨佔) | PR #85535(合進 ClickHouse 25.11) |
| Phase 2 | cached vector copy + 只複製過濾結果 | O(K), K = 目標 namespace 的 parts | cache regeneration 必須在寫鎖內完成,與 parts mutation 對齊 | PR #85535(合進 ClickHouse 25.11) |
| Phase 3 | 對排序 vector 做二分搜尋定位 namespace 區段 | O(log N) | partition key prefix 是 categorical、可比較、parts vector 按 prefix 單調 | Cloudflare 內部 patch(未進上游,因為 invariant 不通用) |
三階段在不同層次解決不同問題——Phase 1 拆掉串行化,Phase 2 降低複製成本,Phase 3 改變過濾的演…
三階段各解一個瓶頸層次:Phase 1 解串行化、Phase 2 降複製成本、Phase 3 把過濾從 O(N) 改為 O(log N)。
表格揭示一個關於分階段優化的觀察:每個階段針對一個獨立瓶頸層次。Phase 1 只把鎖換掉,Phase 2 只改複製策略,Phase 3 只改演算法——這種正交分解讓每個階段都能獨立 benchmark、獨立 rollback。對於 PB 級生產叢集的滾動部署,這比「漂亮的整體重構」更重要:能 rollback 比能 benchmark 重要。
表格裡的「前置」那一行也值得記下來:在切到 Real mode 之前,工程師先試了一個 parts 排序的小調整,預期顯著改善但實際只拿到 5%。這個「不夠的 5%」反而變成診斷工具——它告訴工程師「假設這個函式是瓶頸」是錯的。如果這個前置調整沒做,可能會更晚才意識到需要切換 profiling 模式。某種意義上,5% 的失敗修補是這個故事最重要的一步。
Phase 1 與 Phase 2 一起合進同一個 PR 不是巧合:Phase 2 的 cached vector 需要區分「讀者用的快照」和「寫者修改的本體」,這天然假設讀者用 shared lock、寫者用 exclusive lock。你沒法在還用 exclusive lock 的世界裡同時引入 cache。Phase 1 的 review 也要特別檢查所有寫者路徑都用了 exclusive lock——換成 shared_mutex 後,必須顯式分清楚誰用 lock() 誰用 lock_shared(),漏掉任何一個寫者就會 break 安全性。
Phase 3 的 std::lower_bound 需要一個 comparator 把 part 的 partition prefix 與目標 namespace 比較,且必須是 partial-order consistent——相同 namespace prefix 但不同 day suffix 的 parts 必須都歸為「相等」class。實作不小心可能在 day suffix 比較時 break ordering,導致跳過某些 parts。這種錯誤不會在 unit test 上輕易出現,需要特定 parts 分布才會 trigger,也是 Phase 3 適合先在內部驗證再考慮上游的原因。
關於分區鍵選擇的長期問題
第一和第二階段已作為 PR #85535 合入 ClickHouse 25.11;第三階段依賴更廣的分區鍵排序保證,目前作為 Cloudflare 內部補丁運行。Cloudflare blog 在結尾隱晦地承認:「Was this partitioning scheme the right long-term choice? Or will we eventually need to bite the bullet and move to a different architecture?」三階段修補把當下這版設計的瓶頸推到了下一個拐點,但下一個拐點還是會來——可能在 500k parts,可能在 1M,可能在某個全新的 critical path 上。
這個事件揭示三個關於 ClickHouse 大規模生產環境的教訓:第一,分區鍵選擇不只影響查詢過濾效率,也影響 part 數量,進而影響 planner 的內部機制——這是個二階效應,光看「過濾是不是更精確」沒辦法 catch。第二,trace_log 的 Real 模式在診斷 mutex 競爭時是必要工具。第三,MergeTree 的 part 數量有軟上限建議(通常「幾千到幾萬」尺度),超過進入「沒人壓力測過」的領域——當你的 part 數穿過這門檻時,應預期某個未知 bottleneck 會浮現。
對於有在跑大規模 ClickHouse 的團隊,下面是直接 transferable 的 checklist:確認版本是 25.11+;量測叢集的 parts per replica(system.parts 加 active = 1);定期切到 Real mode trace_log 採樣,看 off-CPU stack 是否有 hot mutex;如果 partition key 是 categorical prefix 且 parts 自然按該 prefix 排序,預先實作 namespace-aware binary search;建立「2× stress test」routine,每季跑一次。把這當 checklist 而不是 takeaway,是這篇能給日常營運的具體價值。
What this enables:三階段修補把 ClickHouse MergeTree query planner 的 mutex 瓶頸從 O(N) 壓縮到 O(log N),讓帳單管線在 16 萬個 parts 的叢集上仍能保持穩定延遲——這個能力讓 per-namespace 細粒度保留政策可以放心部署,不需要再為「分區更細 → parts 更多 → 查詢更慢」的權衡折衷,也讓 ClickHouse 25.11 之後的所有使用者免費繼承前兩階段的優化。