vatt'ghern jaskier's ballads
本文 1 個互動圖表在手機上以重點摘要呈現,互動版請以桌面瀏覽器開啟。

Big Pineapple 隨時放著超過兩千五百億筆 DNS 快取項目,每筆項目多浪費一個位元組,全艦隊就多燒超過250GB。Cloudflare 沒有加機器,而是對快取項目的記憶體佈局動了五刀——第一刀只是把 Vec 換成 Box,就先省下15TB。

DNS快取瘦身:五刀省下100TB

Big Pineapple 是 1.1.1.1、Gateway DNS、DNS Firewall、AS112 等多個 Cloudflare DNS 服務共用的快取層,任何時刻都放著超過兩千五百億筆 DNS 快取項目。在這個規模下,每筆項目多浪費一個位元組,全艦隊就多燒兩百五十多 GB 記憶體——這不是誇飾,是乘法算出來的下限。Cloudflare 對快取項目的記憶體佈局動了五刀,各自瞄準 Rust 型別系統裡不同層次的浪費:Vec 保留的成長空間、三份紀錄清單各自的指標與長度、多數紀錄其實不需要單獨存一份的 owner 欄位、enum 最大變體拖累所有變體的 padding,以及先解析成型別、查詢時再序列化回線路格式這條多繞的路。

他們自己包了一層 allocator,包住 Rust 的 System allocator,逐筆記錄每個快取項目配置了幾次、每次配置多大,量的是配置行為本身。這樣才量得出「每筆項目的配置量少了 58%」這種細粒度的數字。CacheKey 本身放的是 qname、qtype、是否要求 DNSSEC 驗證、還有一個 tag,這四個欄位組成快取的查詢鍵;CacheEntry 除了三份紀錄清單,還帶著時間戳、失效時間、TTL、命中次數這些純量欄位,這些欄位大小本來就固定,不在這次瘦身的範圍內。

這兩組欄位合起來,是 CacheKey 跟 CacheEntry 裡完全沒被這次瘦身碰過的部分。真正被動刀的,是 answers、authority、additional 這三個 Vec<Record>,加上一個 Vec<ExtendedError> 的 errors 欄位,以及另外幾個沒有在文中逐一點名的 Vec<u8>、String 欄位——合起來正好是前面提到的「8 個 Vec/String 欄位」。劃清楚這條邊界,才看得出來五刀省下的每一個 byte 都來自同一類型別:長度可變、寫入後不再變動的容器。

點擊切換看「沒被動刀」跟「被動刀」的欄位 · CacheKey+CacheEntry

qname
qtype
authenticated
tag
timestamp
inception
ttl
hits
answers
authority
additional
errors
+4 個 Vec<u8>/String(未逐一列名)
左邊八個是 CacheKey 的查詢鍵與 CacheEntry 的純量欄位,大小固定,這次五刀完全沒有碰;右邊八個(含未列名的那四個)才是真正被動刀、從 Vec/String 換成 Box 的容器欄位。

五刀依序處理的資料層次不一樣:第一刀砍欄位等級的死重量,第二刀砍清單結構裡多餘的指標,第三刀砍可以靠推算省掉的欄位,第四刀砍 enum 因為最大 variant 被拖累的 padding,第五刀砍「解析再序列化」這條來回路——五刀疊加起來,才是下面這張圖上的數字。

點擊排序鈕依變化幅度重新排列 · 4項指標

五刀疊加後:記憶體變小,速度沒有變慢
四項指標都取自五刀合併後的 benchmark 總表:記憶體佔用與配置量下降的同時,插入吞吐與查詢延遲也同時變好,四個方向一起改善。

Vec 的隱藏稅:capacity 欄位

CacheEntry 原本用 Vec<Record> 存 answer、authority、additional 三個 section,用 Vec<ExtendedError> 存錯誤資訊,再加上幾個 Vec<u8>、String 欄位,總共 8 個 Vec/String 欄位。這些型別的 capacity 是為了「將來還會繼續 push」保留的成長空間;一個快取項目一旦寫進快取,答案就固定了,永遠不會再被修改。capacity 欄位從此只是死重量:一個 Vec 頭部裡指標、長度、容量各佔 8 bytes,容量那 8 bytes 對一個唯讀資料結構沒有任何用處。

Cloudflare 把這 8 個欄位換成 Box<[T]> 與 Box<str>——Box 不保留容量,堆上分配的大小剛好等於資料本身。這個換法讓每個欄位省下 8 bytes,一筆項目省 64 bytes。除了容量欄位本身,Vec 在成長過程中常常會多要一些堆記憶體以攤銷未來的 push,這部分過度配置的空間也一併消失。乘上兩千五百億筆項目,這一刀單獨就省下超過 15TB。

這一刀真正的關鍵是這 8 bytes 是不是「本來就沒用」——一個寫入後不再變動的容器,capacity 欄位從第一天起就是死重量,不會因為流量成長而回本。換句話說,把 Vec 換成 Box 沒有犧牲任何東西換記憶體:那部分保留的成長空間,從頭到尾都沒有被用上過,換型別只是把一直存在的浪費顯性化,讓編譯器不再替它保留位置。

三份清單合一:用位移取代指標

answer、authority、additional 三個 section 原本各自是一個獨立的 Vec<Record>,每個 Vec 頭部有自己的 8-byte 指標與 8-byte 長度。DNS 協定裡每個 section 的紀錄數量本來就塞得進一個 u16——不會有哪個 section 真的裝下六萬五千筆以上的紀錄,拿 u16 當計數欄位綽綽有餘。

Cloudflare 把三份清單合併成一份連續的紀錄陣列,用兩個 u16 位移標出 authority 跟 additional 各自從哪裡開始,answer 永遠從陣列開頭算起,不需要額外位移。這個改動拿掉兩份完整的指標+長度(各 16 bytes),換成兩個 2-byte 位移,一筆項目省 28 bytes。三份清單原本各自獨立配置;文章沒有交代這一刀對配置次數的影響,合理的推測是合成一份連續陣列之後,配置次數也跟著從三次降到一次。

這裡也藏著一個取捨:u16 位移能表達的最大值是 65535,如果哪天某個 section 的紀錄數量真的逼近這個上限,位移就會溢位。Cloudflare 只寫了「每個 section 的紀錄數量塞得進 u16」這一句理由,沒有解釋為什麼溢位不必擔心;合理的推測是 DNS 封包本身的大小限制,讓單一 section 塞進六萬五千筆以上紀錄這件事幾乎不可能發生。不論理由是什麼,為理論上限多留的那一手,本身都要付記憶體代價。

省掉多餘的 owner:多數紀錄跟查詢同名

DNS 紀錄理論上每一筆都該存一份自己的 owner name,但實務上多數紀錄的 owner 跟使用者查詢的那個 domain 完全相同——查 example.com 的 A 記錄,answer 裡幾乎每一筆 A 記錄的 owner 就是 example.com 本身。真正的例外是 CNAME 鏈:查 www.example.com 若被 CNAME 到 edge.example.net,後面那筆紀錄的 owner 是 edge.example.net,跟原始查詢的 qname 不一樣。

Cloudflare 把 owner 欄位改成 Option<Box<Name>>:owner 跟查詢的 domain 相同時存 None,讀取時直接從 CacheKey 的 qname 欄位推回來;只有真的不同時才配置一份完整的 Name 存進 Some。多數紀錄因此完全省掉一次 heap 配置——這是把「大多數情況」直接編碼進型別本身的做法,讓常見路徑完全不用碰 allocator。

這個省略之所以划算,是因為它命中的是常態:answer 裡多數紀錄的 owner 就是查詢本身,只有走過 CNAME 鏈之後的那幾筆才真的需要一份獨立的 Name。把「跟查詢相同」編碼成 None,等於是把資料本身的機率分布直接寫進型別——真正需要付出一次 heap 配置成本的,只剩下真正需要那份資料的少數紀錄,多數情況下讀取端只是回頭看一眼 CacheKey,不必額外碰 allocator。

Boxing 的算盤:讓 NAPTR 自己扛 136 bytes

RecordData 是一個 enum,每個 variant 對應一種紀錄型別:A 是 4 bytes 的 Ipv4Addr,AAAA 是 16 bytes 的 Ipv6Addr,TXT、NAPTR 這類變長或大型紀錄則塞著完整的 struct,其中 NAPTR 的 payload 本身就有 136 bytes。Rust 的 enum 大小取決於最大的那個 variant——不管實際存的是哪一種,整個 enum 都得撐到能裝下 NAPTR。加上 tag,enum 被撐到 144 bytes,意味著即使是最小的 A 記錄,也得跟著佔 144 bytes 的位置,其中絕大部分是純粹的 padding。

Cloudflare 把 TXT、NAPTR 這類比較大的 variant 包進 Box,enum 本身縮回 tag 加指標的大小,A、AAAA 這種小 variant 繼續 inline 存放。這一刀對 A、AAAA 記錄省下 120 bytes;TXT、CNAME 這類變體雖然還是佔著 24-byte 的 enum,但堆上配置的大小改成貼著實際資料量,不再被 padding 到 144 bytes。

A/AAAA(inline)4–16B,留在 24-byte enum 裡
TXT(boxed)依實際資料量配置
CNAME 等(boxed)依實際資料量配置
NAPTR(boxed)136B,單獨一塊 heap
NAPTR 的 136 bytes 曾經拖著每一筆 A、AAAA 記錄一起付 144 bytes 的 padding;boxing 之後,只有真的需要那塊空間的 variant 才會付錢,enum 本身縮回前排這一層。

攤開來算,boxing 之前一筆 A 記錄要佔完整的 144 bytes,其中真正用來裝 IPv4 位址的只有 4 bytes,其餘 140 bytes 大半是為了容納 NAPTR 那 136 bytes 的 payload 才被迫留下的空間;boxing 之後 enum 本身縮回 24 bytes,跟 144 bytes 的落差正好是前面提到的每筆 120 bytes。放進 56% A 加 25% AAAA 的流量比例裡看,這代表 benchmark 流量裡超過八成的紀錄,原本都在扛著一塊自己完全用不到的 padding。

Boxing 不是免費的。第一個代價是 allocator overhead——Big Pineapple 用 jemalloc,一個為多執行緒、高配置頻率工作負載設計的 allocator,jemalloc 把大小相近的配置歸進固定尺寸的 bin:一筆 TXT 紀錄要 32 bytes,剛好落進 32-byte 的 bin,一個 byte 都沒浪費;但一筆 MX 紀錄要 40 bytes,就被捨入到 48,白白多佔 8 bytes。第二個代價是記憶體局部性變差:沒有 boxing 時,一筆快取項目的所有 record enum 值擠在同一塊連續配置裡;boxing 之後,每個被 box 的 variant 各自活在獨立的 heap 區域,查詢時得多跳幾次指標才能讀到資料。Cloudflare 沒有迴避這個 tradeoff,但也沒有把淨值算給讀者看;照 A、AAAA 在 benchmark 流量裡佔了 56% 加 25%、每筆省下 120 bytes 推算,合理的判斷是 padding 省下的空間大於 boxing 付出的代價。

捨入的損失是否對小物件更傷,Cloudflare 沒有寫;不過照 32 與 40 bytes 這兩個例子推算,同樣 8 bytes 的捨入,落在幾十 bytes 的物件上換算成百分比,本來就比落在幾百 bytes 的物件上重。文章也沒有交代 A、AAAA 為什麼繼續留在 enum 裡不裝箱;合理的推測是,這種天生只有 4 到 16 bytes 的 variant 若也跟著裝箱,每一筆都要付一次獨立配置與捨入的代價,很可能反過來吃掉 boxing 原本要省下的 padding。

不再解析:直接照抄 wire format

前四刀都還是在調整「已經解析成型別」的資料怎麼存,第五刀直接改變了儲存的形狀:record data 不再是一串 parsed enum variant,而是塞進單一 Box<[u8]>,每筆紀錄前面加一個 2-byte 長度前綴,後面接著原始 bytes。原本查詢快取命中時,每筆紀錄要逐欄位重新序列化回 DNS wire format 才能回給 client;新的佈局讓 A、AAAA、TXT 跟所有 DNSSEC 紀錄類型直接複製已經是 wire format 的 bytes,略過序列化這一步。

寫入端也跟著改:建立 record data buffer 時,Cloudflare 寫進一個跨越多次快取寫入、可重複使用的 scratch buffer,最後才把最終大小裁下來配置一次——原本每個 boxed record 各自配置一次,現在整個項目的 record data 只配置一次。結合記憶體局部性的提升,這一刀在 benchmark 裡讓查詢延遲降了 5%,插入吞吐單獨提升 13%。

會拆到單筆紀錄的層級,是因為 DNSSEC 紀錄只有在 client 設定 DO(DNSSEC OK)flag 時才需要附上——要嘛快取兩個版本,一個含 DNSSEC、一個不含,要嘛每次查詢都對已組好的訊息做一次 filter,兩條路都不划算。拆到單筆紀錄的層級,才留得住「要不要附 DNSSEC」這個彈性,同時仍然享受到 wire format 直接複製的好處。

這個決定也解釋了為什麼 record data 沒有進一步整併成單一整包快取——長度前綴加原始 bytes 的設計,讓查詢時可以照著 client 有沒有設定 DO flag,決定要不要把 DNSSEC 那幾筆紀錄跳過,卻不需要事先分岔出兩份不同的快取內容。換句話說,這一刀省的不只是序列化的 CPU 時間,也順帶保住了「同一份儲存、服務兩種 client」這個彈性,不必為了省一次序列化,反過來在儲存層製造出兩套資料要同步維護。

① Vec → Box

−64B/筆.全艦隊 >15TB

② 清單合一+位移

−28B/筆

③ owner 可省略

多數紀錄省一次 heap 配置

④ enum 部分裝箱

A/AAAA −120B/筆紀錄

⑤ wire format 直存

插入 +13%.延遲 −5%

五刀分屬不同資料層次:欄位死重量、清單結構、可推算欄位、enum 佈局、儲存格式本身——省下的量不是同一個尺度,但方向一致。

上線七週:43% 的 p99 記憶體降幅

把五刀疊加起來,用貼近 production 流量分佈的合成資料跑 benchmark,量出來的數字是:每筆項目的淨佔用從 953 bytes 降到 420 bytes,少了 56%;每筆項目的配置量從 1.1 KB 降到 461 bytes,少了 58%;快取插入吞吐從每秒 625,000 筆升到 893,000 筆,多了 43%;查詢延遲從 828 ns 降到 670 ns,少了 19%。至於為什麼配置量的降幅(58%)比淨佔用的降幅(56%)還大一點,合理的推測是:五刀裡有好幾刀本質上是在減少配置的「次數」——三份清單合一、record data 共用一次配置——配置次數減少對配置量這個指標的槓桿本來就比對淨佔用更大。

benchmark 的流量分佈是 56% A 記錄、25% AAAA、19% TXT,每筆項目掛 1 到 4 筆紀錄。這裡的 TXT 是 benchmark 裡所有非 A/AAAA 紀錄型別的代表,大小在 64 到 224 bytes 之間隨機,貼近他們在 production 觀察到的變長紀錄平均回應大小。作者自己說這些輸入只是「approximate production rather than reproduce it exactly」——process 記憶體實際上還要看流量組合、快取佔用率、allocator 狀態這些 benchmark 沒模擬到的變數。

這個 64 到 224 bytes 的區間,跟第四刀談的 NAPTR 那 136 bytes 不是同一回事:一個量的是 benchmark 合成出來的變長紀錄大小,另一個量的是 RecordData enum 裡最大 variant 的 payload,文章沒有把這兩個數字擺在一起比較。benchmark 真正說明的是型別分佈——56% A 加 25% AAAA,超過八成是那兩種天生短小的紀錄,正好是第四刀之前被 padding 拖累最兇的一群;而 A、AAAA、TXT 加上所有 DNSSEC 型別,也正好都落在第五刀可以直接複製 wire format bytes 的名單裡。

播放看上線期間 p99/p90 記憶體曲線 · 七週

2026-05-18
p99/p90 是上線前後量到的兩個端點(2026-05-18 起、2026-07-06 全服務完成);動畫呈現的是兩點間的過渡,不是逐日的真實遙測。

p99/p90 是上線前後量到的兩個端點(2026-05-18 起、2026-07-06 全服務完成);動畫呈現的是兩…

p99記憶體從9.3GB降到5.3GB,上線七週內全艦隊完成瘦身,插入吞吐同時提升。

上線期從 2026 年 5 月 18 日開始,7 月 6 日在所有服務上完成,橫跨約七週。量到的 p99 resident memory 從 9.3 GB 降到 5.3 GB,降了 43%;p90 從 6.5 GB 降到 3.8 GB,降了 42%——跟 benchmark 裡插入吞吐提升的 43% 不是同一個數字,只是量級剛好接近;文章沒有把這兩個 43% 放在一起討論,讀成「benchmark 沒有脫離真實機器上的行為」是本文自己的推論。

p99 的降幅(43%)跟 p90(42%)幾乎一樣大。Cloudflare 對艦隊內部的分布只寫了一句「Instances with fuller caches saw the largest absolute savings」——快取愈滿的機器,省下的絕對量愈大。所以比例接近並不等於節省均勻攤在每一台機器上;合理的推測是,兩者其實可以同時成立:每筆項目省下的 bytes 是固定的,機器裡裝的項目愈多,乘出來的絕對量自然愈大,而相對於原本的佔用,降幅比例仍然落在同一個區間。

拖曳滑桿調整快取筆數 · 對比粗算與官方100TB

250
0 160TB 粗算 官方公布 約 100TB
粗算=快取筆數 × 533 bytes(五刀合計每筆節省的量);跟官方數字對不齊的落差,來自 benchmark 本來就聲明的「近似」而非精確重現。

把每筆項目省下的 533 bytes 直接乘上兩千五百億筆項目,粗算會超過 130TB;Cloudflare 公布的實際數字是「roughly 100 terabytes」,相當於 130 台 Gen 13 伺服器的記憶體總和。兩個數字對得上量級,但不會剛好相等——process 記憶體還要扣掉快取以外佔用的部分,也要算進 allocator 本身的捨入開銷,這正是前面 benchmark 那句「近似而非精確重現」的說法,在艦隊尺度上的體現。

五刀省下的每一 byte,起點都是「同樣的資料用更誠實的型別存」——capacity 欄位、重複的指標、推算得出的 owner、被最大 variant 拖累的 padding、多繞一圈的序列化,本質上都是型別替資料多背的重量,跟資料本身要表達的內容無關。省下來的空間因此可以拿去多裝真正有用的東西。

把五刀擺在一起看,能抽出來的是一套檢查清單:容器有沒有留著永遠用不到的成長空間、平行的多份清單能不能合併成一份位移表、欄位是不是能從別的地方推算出來而不必存、enum 有沒有被單一肥大的 variant 拖累所有變體、儲存格式跟輸出格式之間是不是還隔著一次不必要的轉換。任何維護長駐、寫入後不再變動的快取資料結構的人,都可以照著這個順序,回頭檢查自己的型別定義踩了幾個同樣的坑。

接下來能做什麼:省下來的記憶體沒有拿去縮編機器,Cloudflare 打算把它重新投進快取容量——同樣的機器上塞進更多快取項目,直接推高命中率,壓低往上游 authoritative server 打的查詢量。