Cloudflare 的規模大到就算在這裡工作多年,仍會覺得不真實。我們在全球有數千台伺服器,合計數 PB 的 RAM 和數百萬顆 CPU 核心,而且每一項資源都被用到極限。雖然這些資源看似無窮,實際上仍然有限。當你需要每個服務都在每個節點上執行時,根本沒有多餘空間可以浪費。
在這種規模下,小幅改善會被大幅放大,所以即使是 1%-at-a-time 式的優化都值得慶祝。但有些調整加起來效果更驚人:本文將介紹如何對單一演算法進行小幅修改,大幅降低我們某個基於 Pingora 的服務記憶體用量。這讓我們在全球回收超過 100TB 的 RAM,再加上 DNS 團隊上個月省下的 100TB 記憶體。
不浪費
在大規模組織中,維持團隊間公平的資源分配並不容易。Cloudflare 確保平衡的方式之一,是透過 Performance 團隊持續不懈的努力。
故事的起點是一張由 Ivan 開的 ticket:他發現 Pingora Backend Router 中 pingora-ketama 的記憶體用量過高。結論是我們內部的負載平衡服務 PBR 使用了遠超預期的記憶體 —— 特別是與 pingora-ketama 相關的結構,而 pingora-ketama 是我們用來處理一致性雜湊(consistent hashing)的開源函式庫。
要談怎麼解決這個記憶體過度使用問題,得先解釋一致性雜湊是什麼、我們為什麼在 PBR 用它、以及它為何變得如此耗用記憶體。過程中,我們會學到一些 Rust 知識,甚至一點數學。
一致性雜湊
一致性雜湊是廣泛用於跨多台伺服器分配任務的方法,當伺服器新增或移除時不需要大幅調整。我們內部用它根據 URL 把可快取請求路由到伺服器。這讓我們在每個資料中心只保留一份檔案副本,並提供穩定的方式找到每個檔案的位置。我們之前已經提過這個系統,但這裡值得再走一遍這個演算法為何以及如何運作。
一致性雜湊的核心概念是:雖然雜湊函式能接受任何輸入,但輸出只能是單一無號整數(依雜湊函式而定,可能是 32、64 或 128 位元)。這讓我們能夠以「一致」的方式關聯任務與伺服器。多數討論一致性雜湊的文章會把輸出空間想像成一個連續的圓環,從最大值繞回零。這種描述方便視覺化,但會讓「整數範圍」這個簡單概念顯得過於複雜。為討論方便,我們將雜湊函式的 32 位元輸出表示為一個數線。
現在,假設我們有一組伺服器 A、B、C,以及一組任務 t-z。我們可以根據代表值的雜湊(伺服器用 IP,任務用快取 key)把它們各自映射到數線上。把任務分配給伺服器,就只是找到每個任務左邊的第一個伺服器。我們可以視覺化呈現:把每台伺服器負責的雜湊範圍塗上顏色。注意伺服器 C 涵蓋的範圍繞回起點,這就是「雜湊存在於圓環」的想法。
以上就是基礎。一致性雜湊就這麼簡單 —— 但很快會發現有改進空間。注意伺服器 A 涵蓋的範圍明顯比 B 或 C 大。這是個問題,因為伺服器處理的請求比例會對應到它在數線上的範圍大小。理想上每台伺服器的範圍應該相等,但因為雜湊本質上是隨機數,我們必須用「統計」的角度談論範圍大小。
數學與後果
先別慌。我保證不會騙你,而且會安全地停留在「機率入門」的範圍內。談到統計分布時,有兩個大因素幫助我們量化不確定性:期望值與標準差。簡單來說,期望值告訴我們基於某個分布的測量值會聚集在哪個點,標準差則告訴我們大多數測量值離中心點多近。
對於一致性雜湊,我們可以為 N 台伺服器中某台的範圍比例計算這兩個因子(公式由來稍後再談)。
- 期望值:Exp = 1/N
- 標準差:SD = (1/N) × √((N-1)/(N+1))
- 變異係數:CV = SD/Exp = √((N-1)/(N+1))
在這個公式裡,當 N 很大時 CV 趨近於 1。也就是說,當伺服器數量增加,期望值變小(每台伺服器只負責一小段範圍),但標準差以同樣速度縮小。比例上,不確定性仍然很大。
這就是一致性雜湊的問題本質:為了讓範圍大小均等,你需要每台伺服器有多個雜湊值(基本上是把伺服器「偽裝」成多台)。多個雜湊值會縮小變異係數,但縮小速度遠低於直覺所想的。我們在 Cloudflare 的實作是每台伺服器 160 個雜湊,再根據伺服器儲存容量做加權,結果在 Ring 上的總雜湊數遠超 160。
我們需要的不只是「足夠多台伺服器」的一致性雜湊,還要能根據不同特徵(cache key、地理位置、資源權重等)產生不同雜湊。光是這些功能特徵的組合就會產生指數級的不同 Ring,所以記憶體用量才會爆。
儲存改進
第一個大改進來自 Zaidoon,他對 PBR 儲存雜湊的 struct 有個洞察。那個 struct 看起來像這樣:
struct Point {
hash: u32, // 4 bytes
index: u32, // 4 bytes
}
在記憶體中這表示為 8 bytes,其中 4 個是雜湊(這無法避免),4 個是指向另一個陣列中伺服器的索引。Zaidoon 的洞察是:用 32 位元儲存索引太浪費,因為 PBR 不太可能同時協調超過 2^16 ≈ 65k 台伺服器,所以 16 位元就夠了。因此我們可以把上面的 struct 改成:
struct Point {
hash: u32, // 4 bytes
index: u16, // 2 bytes
}
但 Rust 不會讓你這麼輕鬆就達成。改變索引大小對記憶體用量毫無影響,這是因為 Rust 有對齊規則,要求結構在記憶體中的大小必須是其最大(或「最對齊」)欄位大小的倍數。在這個例子中,雜湊是最大的 4 bytes,所以 Point 在記憶體中必須是 N × 4 的倍數,最小大小仍是 8 bytes。
幸好有眾所周知的解決方法。你(也就是我)可能會想用 repr(packed),但基於合理原因這是有爭議的。一個更安全但較不直讀的解法是把雜湊和索引存成原始位元組陣列,再用 getter 存取。這兩種方法編譯出的結果是一樣的。
這個簡單(雖然囉嗦)的改動一口氣把一致性雜湊的記憶體用量減少了 25%!想要再進一步,我們得回到數學 —— 各位坐穩,這是最後衝刺。
如果我們用更少的雜湊呢?
你可能注意到,前面我們給的標準差公式是針對每台伺服器只有一個雜湊的情況。推導每台伺服器有 k 個雜湊的公式並不容易,多數來源只給近似值或漸進極限,但我們不一樣。我雖然不是統計學家,但我從小有個教微積分的老媽(嗨,老媽!),我想知道真正的數值。
- Exp_k = 1/N
- SD_k = √((k+1)/(N(kN+1)) - 1/N²)
- CV_k = SD_k / Exp_k = √((N-1)/(N·k+1))
繪製 CV_k 圖會顯示「再加更多雜湊」這種心態的潛在問題(除了過度使用 RAM 之外)。每降低一步誤差邊際,需要(幾乎)數量級地增加每台伺服器的雜湊數,所以加越多雜湊,改善效益遞減。回想一下我們目前用每台伺服器 160 個雜湊作為基準,並按伺服器的儲存容量做加權。為簡化數學,我們把加權因子 m_w 定為 1。
原來我們其實遠遠超過了精準分配所需的雜湊數量!
多少雜湊才夠?
我們評估了不同 N(伺服器數量)和不同 m_w(加權因子)下,達到特定 CV 目標所需的最小 k 值。結論是:在常見工作負載下,每台伺服器只需要 16 個雜湊,就能讓 CV 降到 5% 以下,這已經足以讓範圍分配夠均衡。
這代表我們可以把每台伺服器的雜湊數從 160 降到 16,省下 90% 的雜湊空間!
不過這又引出新問題:我們有多個不同的 Ring(依功能特徵區分)。我們不能只改一個 Ring 就期待系統整體都受益 —— 我們得改所有 Ring 才能讓 PBR 整體記憶體下降。
遷移而不擊垮來源伺服器
還有一個問題:改變雜湊 Ring 會改變部分可快取請求的路由。就算新的 Ring 比較好,一次在整個網路切換,幾乎會讓所有快取內容失效。原本的記憶體優化會瞬間變成災難級的來源流量暴增。
所以我們沒有做一次性的全域切換。有一段時間,PBR 同時在記憶體裡保留兩個版本的可快取負載平衡器:舊的 ketama Ring 和新的較小 Ring。每個請求用我們一般的遷移框架來決定該用哪個 Ring。這代表切換決策對每個請求雜湊是穩定的,也給了我們乾淨的 rollback 機制。如果有任何問題,我們可以把新請求送回舊 Ring,不用重新部署 PBR。
然後我們分層逐步推出。先在小型的驗證地點開始,依序推進到越來越大的資料中心群,最後才推向全球。
關鍵在於我們獨立控制兩個維度:多少流量用新 Ring、這些流量被允許移到哪裡。單純的全域百分比 rollout 會一次把快取失效散播到所有地方;以資料中心為範圍的 rollout 把爆炸半徑維持得很小,更容易判斷某個改動是否真的安全。
遷移過程中,我們監看後端選擇追蹤、Ring 版本計數器、PBR 連線錯誤、行程記憶體、啟動時間、快取行為、來源伺服器流量。一旦遷移達到 100%,我們就移除暫時的舊 Ring 路徑,然後就大功告成了!
上圖顯示變更當週 PBR 記憶體用量,與幾週前的資料做對比,以及兩者的差異。那個急劇下降就是停用大型(現在不再使用)雜湊 Ring 的 PBR 版本的那一天。看這個差值,我們得到一個令人滿意的結果:我們的改動把記憶體用量減少了 100TB!
自己試試
本文談到的所有改動都已開源,放在 pingora-ketama crate 裡(目前是未公開的 cargo feature)。這個 Ring 有壓縮後的儲存格式、更快的排序方法,以及可調整每節點基礎雜湊數量的能力。我們做這些改動時,重點是穩定性和可控性,所以這個 Ring 跟 pingora-ketama 一直以來的版本完全相同,函式庫也允許同時跑兩個 Ring,按每個請求決定用哪個、何時用。
除了嘗試我們字面上的雜湊改動,我希望你從這篇文章得到啟發,去挖掘你自己的系統,看哪些「簡單」或「顯而易見」的決策裡藏著潛在的優化機會 —— 只要你願意深入研究數字。你的問題或許無法全部用 Rust 解決,但數學是通用的。
為什麼這件事重要
Cloudflare 的 PBR(Pingora Backend Router)撐起全球超過 300 個資料中心的快取請求路由。每個請求都要在「一致性雜湊 Ring」上查表,找到該把請求送到哪一台伺服器。這是一條「熱路徑」—— 每次錯過快取都會打回到 origin,影響延遲和成本。
Cloudflare 能在不動任何實體 RAM 的前提下,從 160 個雜湊降到 16 個,這個 90% 的減量是純軟體改善。對比一下:如果你是用戶正面對 OOM(out-of-memory)問題,多數工程師會直覺想到「加 RAM」或「加節點」。但 Cloudflare 證明了另一條路:先質疑設計本身的數學假設,再決定要不要改。
更值得學習的是他們的遷移紀律 —— 同時跑新舊兩個 Ring、按資料中心逐步 rollout、每個決策都有 rollback 路徑。這種「想做改變但不能擊垮來源伺服器」的工程文化,是大規模分散式系統的必修課。
數據解讀
- 25%:單純把
index: u32改成index: u16(搭配 byte array 技巧繞過 Rust 對齊規則)就能拿到的記憶體減量。 - 90%:每台伺服器的雜湊數從 160 降到 16,省下的比例。
- 100TB:最終在全球釋放的 RAM 總量(橫跨 300+ 資料中心)。
- 65,536:16 位元索引的理論上限,足以容納 PBR 永遠不會超過的伺服器數量。
- 5%:當 CV(變異係數)低於這個值時,雜湊分配就已經「夠均衡」,不需要更多雜湊。
- 0 元硬體成本:所有改動都是軟體層,沒有拆任何一根 RAM 模組。
編按:本文綜合整理自 Cloudflare Blog〈Saving another 100TB of RAM with math (and Rust)〉,以及 Hacker News 與 Reddit r/CloudFlare、r/programming 的社群討論,並加入 Siami 編輯部觀點與數據解讀。
社群迴響
這篇文章在工程師社群引起廣大迴響。多數留言圍繞在三個主題:
- 對 Cloudflare 工程文化的讚嘆:從 DNS 100TB(八月)到 PBR 100TB(九月),連續兩個月純軟體優化各擠出 100TB,這種「從底層榨價值」的紀律讓人印象深刻。
- Rust 對齊規則的討論:很多人第一次知道
repr(packed)有爭議,因此 byte array + getter 反而是更乾淨的解法。 - 可移植性提醒:在小規模系統上,這種優化的複雜度成本可能比省下的 RAM 還貴。留言區有人開玩笑:「我連 100GB 都還沒用過,別跟我談 100TB。」
網友熱門留言 (5)