vatt'ghern jaskier's ballads

十種值放在一個欄位上是十個版本,放進三個欄位就可能炸成一千種組合——Cloudflare 翻過超過 1.2 億筆回應、近 5 萬個網站的 Vary 用法後,找到有網站的 Vary 涵蓋 47 個欄位。

Vary 這一行,快取要記住幾個版本

Vary 是一個 HTTP 回應表頭,origin 拿它告訴快取:這個網址不是只有一個正確答案,請求裡某些表頭的值不同,回傳的內容也可能不同。快取如果忽略 Vary,可能把錯的內容送給請求的人;但如果把每一個原始表頭值都當成不同的版本,一小群相近的請求就可能散成上千筆很難再被用到的快取項。Cloudflare 這次替 Cache Rules 加上 Vary 支援,讓這兩種風險可以按表頭分別設定該怎麼處理。

Vary 到底要快取記住什麼

Vary 的語意很簡單:origin 用它列出可能造成回應差異的請求表頭,快取比對這些表頭的值,才知道該把哪一個快取項還給誰。一個網址可以有一個以上正確的回應——原文舉的例子是,同一個網址,伺服器可能依瀏覽器支援的圖片格式不同,送出不同版本。Cloudflare 把這個表頭形容成 HTTP 裡一直沒人改善的最醜陋部分,這句話出自 mnot.net 一篇談 HTTP linting 的文章。Vary 告訴快取哪些請求欄位可能影響回應,但沒說哪些差異真的重要。

猜到極端的情況,是 origin 把 Vary 設成單一個 *。這代表連 IP 位址這種根本不在 HTTP 訊息裡的因素,都可能讓回應不同——快取沒辦法比對這種東西,只能整個放棄快取。Cloudflare 把這條規則寫死:只要看到 Vary: *,一律 bypass。這是三個動作之外的第四種狀態:這個 URL 直接退出快取比對,沒有容忍度可言。

Vary 要解決的是快取層一直存在的兩難。快取如果完全不理會 Vary,等於假裝這個網址只有一種內容——快取可能把某個人根據自己 Accept-Language 拿到的版本,轉發給另一個偏好完全不同的人。快取如果反過來把 Vary 點名的表頭當成絕對的字面差異,任何一點大小寫或順序上的不同都算成全新的變體,快取項就會多到誰都留不住。normalize、passthrough、bypass 三個動作,是針對這兩難給出的具體選項,讓設定的人自己決定哪個表頭該用哪一種容忍度。

表頭值一多,cache key 就炸開

真正棘手的地帶,介在完全一樣跟 Vary: * 中間。如果快取直接把 Vary 點名的表頭原始位元組全部塞進 cache key,等於把請求表頭的排列組合直接複製進快取層——每多一個會變化的欄位,可能的組合數就跟著乘上去。

快取比對用的 cache key,就是網址加上 Vary 點名的那幾個表頭的值兜出來的一把鑰匙——同一把鑰匙才會命中同一個快取項。Vary 只負責點名哪些表頭要算進這把鑰匙,沒有規定值要怎麼寫進去;如果直接把請求送來的原始位元組整段抄進鑰匙,鑰匙就會比原本該有的還要精細,精細到語意相同的請求也配不出同一把鑰匙。同一個 Accept-Language,大寫小寫各送一次、順序調換一次,理論上就足夠拆出四把不同的鑰匙,即使背後代表的偏好完全一樣。

Cloudflare 翻過超過 1.2 億筆回應、近 5 萬個熱門網站之後,找到近 3,000 個網站在四個以上的欄位上出現差異,有些網站甚至在 10、23、47 個欄位上出現差異。

近三千個網站的規模,足以說明這個擱置已久的表頭值得認真處理。一個網址列到 47 個 Vary 欄位,代表這個 origin 疊了相當多層邏輯在判斷該回什麼內容——每一層邏輯,都可能對應一個列進 Vary 的表頭。表頭數量一多,光是把每一層邏輯翻譯成一個 Cache Rule 動作,就已經是不小的工程。

點擊下方按鈕看每個數字的原文出處 · 5 個資料點

分析規模——網站數與觀察到的欄位數 分析涵蓋的網站數 近 5 萬 4 個以上欄位有變化的網站數 近 3,000 單一網站觀察到的欄位數 0 50 欄位數 4 10 23 47

點一個按鈕,看看這個數字原文怎麼寫。

資料來自 Cloudflare 對超過 1.2 億筆回應、近 5 萬個網站的分析;下排三個標記是文章點名的三個真實觀察值。

分析裡的近 3,000 個網站,只是 Cloudflare 自己畫的「4 個以上欄位」這個回報分組,原文沒有解釋為什麼用 4 這個數字。照這個邏輯推下去,欄位數字本身連續往上爬,乘法效應也跟著連續往上爬,沒有一個特定的數字會讓風險突然出現——一個網站從 3 個欄位加到 4 個,跟從 10 個加到 11 個,本質上是同一種代價,只是後者的起點已經比較貴。

語意相同的兩個請求,只要原始位元組有一點差異,快取就會拆進不同的快取項——理論上該共用的一個快取項,變成兩個各自吃到一半流量的快取項。表頭數量一多,這個對半分的過程會一路複合下去。快取項能不能熱起來,取決於短時間內有沒有足夠多請求命中同一把 key;流量拆得越細,單一項分到的份量就越撐不起原本該有的命中率。

點擊按鈕切換欄位數 · 2 種情境

10
1 個會變化的表頭、10 種值 = 10 個變體。
方塊面積代表變體數量的相對大小(兩個維度各放大 10 倍,面積放大 100 倍,對應原文「十種值放三個欄位可以炸到一千種」)。原文原句:「Ten possible values across one field create ten variants. Ten values across three fields can create 1,000 combinations.」

這個乘法式子裡真正能動的,是每個欄位的有效值數量。origin 照樣列著同一個 Vary 表頭,但 normalize 讓表頭底下寫法不同、意思一樣的值收斂成同一個值,直接把算式裡的變體數量變小——如果十種原始值裡有一半只是大小寫或順序不同、正規化後是同一個值,理論上的十種就變成五種,三個欄位相乘的一千種上限也跟著往下掉。

normalize:把語意相同的請求收在一起

Cloudflare 給的定義是,normalize 會在挑選快取變體之前先正規化請求表頭,幫語意相同的請求共用同一份快取回應。對 Accept、Accept-Language、Accept-Encoding 這三個表頭,做法是先把值轉成小寫,再依 quality value 由高到低排序,同分的話按字母序決定先後;排序完之後,把非零 quality 的項目上的參數去掉。地區型語言標籤——像 en-US——如果沒有特別設定完整標籤,就收斂回基礎語言 en。

normalize 動作不只影響 cache key,也影響 origin 實際收到的請求。為了讓 origin 選擇回應的依據跟快取比對的依據對齊,Cloudflare 把正規化後的 Accept 與 Accept-Language 值一併轉發給 origin——如果快取用正規化後的值分組,origin 卻收到原始值,origin 有可能依原始值產生不同的回應,而快取會把這些其實不同的回應當成可以互換的同一份。

原始值請求送來的 Accept-Languagede;q=0.5, FR, EN-us;q=0.9
→
1 · 小寫全部轉成小寫de;q=0.5, fr, en-us;q=0.9
→
2 · 排序依 quality value 由高到低,同分按字母序fr, en-us;q=0.9, de;q=0.5
→
3 · 去參數非零 quality 的項目去掉參數fr, en-us, de
→
4 · 收斂語言地區語言標籤收斂成基礎語言fr, en, de
最左側是請求送來的原始值;右邊四個步驟的順序直接照 Cloudflare 的說明寫——先小寫、再依 quality value 排序(同分按字母序)、排序後去掉非零 quality 項目的參數、地區語言標籤收斂成基礎語言;表頭值本身是示範用例,不是 Cloudflare 公開的原始樣本。

這四個步驟挑的都是不影響語意、只影響寫法的差異。quality value 本來就是瀏覽器用來表達偏好順序的相對數字,不是要精確比對到小數點——把它拿來排序、排完再去掉,語意上完全等價,只是不同瀏覽器、不同版本組出來的字串長得不一樣。

除錯快取命中率時,這四個步驟給了一個具體的檢查清單。懷疑 Accept-Language 或 Accept-Encoding 把快取拆得太細,可以先看原始請求的表頭值是不是真的語意相同、只是寫法不同(大小寫、順序、有沒有帶額外參數),再決定要不要把這個表頭切到 normalize。表頭的差異如果本來就有意義,例如自訂的版本號,normalize 反而會把不該合併的請求錯誤地合併在一起。

normalize 收斂的效果,在只有一兩個欄位的網站上一樣成立——就算一個網站只在 Accept-Language 這一個欄位上變化,瀏覽器送出的值本身就有大小寫、順序、參數的自由度,正規化一樣派得上用場,只是規模不像極端案例那麼顯眼。

normalize 有代價。收斂 Accept、Accept-Language 這類表頭時,正規化過程也可能把 q=0(代表「不可接受」)這種語意一併壓縮掉——en-US;q=0 收斂後可能變成 en,原本排除某個語言的訊息就不見了。origin 如果真的照著這份排除清單決定要不要提供某個語言版本,正規化後 origin 看到的值,就不再是使用者實際送出的那份完整清單。

四個步驟合起來,結果是同一個使用者的偏好,不管請求裡怎麼排列大小寫或順序,最後都落在同一個快取項上。

passthrough 與 bypass:不該被正規化的東西

normalize 只能收斂寫法上的差異;語意本身就不同的表頭,就算轉成小寫、排序、去參數,也不會變成同一個值——這種情況需要另外兩個動作。passthrough 保留請求表頭的原始位元組——大小寫、空白、順序、重複值全部不動,直接拿去比對快取變體。這是留給值的範圍有限、但精確值會影響回應的表頭用的——包括 normalize 會壓縮掉的 q=0 排除語意,normalize 那套收斂邏輯不該誤傷這些表頭。

bypass 走的是另一個方向。origin 在 Vary 裡點名的表頭,只要設成 bypass,Cloudflare 乾脆不存這個回應。原文寫得很直接——這個動作是留給個人化、高基數,或不可預期的表頭用的,例如 Cookie 或 User-Agent。本文的看法是,這類表頭的值幾乎每個請求都不一樣,硬要進 cache key,只會製造出大量只服務過一次的快取項。

passthrough 假設這個表頭的差異值得記住;bypass 假設這個表頭的差異太多,記不完也不該記。如果對 Cookie 這種幾乎每個請求都不同的表頭用 passthrough,等於把它的高基數原封不動地搬進 cache key——延伸這個對比,一個欄位就可能撐出遠超過十種、上百種,甚至更多的實際值。

點擊模式看同一組表頭被怎麼處理 · 3 種模式

原始請求表頭

Accept-Language: EN-us;q=0.9, FR
→

cache key 記的值

fr, en

值等價的請求(大小寫、排序不同)會共用同一個快取項。

三個按鈕對應 Cloudflare 的三種動作;右側文字直接對應原文對每個動作的定義,左側表頭值是示範用例。

把三個動作的代價攤開來看,選錯的代價並不對稱:把 Cookie 這種幾乎每個請求都不同的表頭設成 normalize,等於白費力氣——Cookie 的值本來就沒有語意相同這回事,收斂不出任何共用,這種誤用很可能退化成跟沒設定一樣,一樣是大量只服務過一次的快取項。bypass 看起來最保守:乾脆不存,不會有拆散或誤判的問題,代價是這個表頭底下的所有回應都拿不到快取的好處,每一次都得回源。對 Cookie、User-Agent 這種本來就幾乎不可能重複命中的表頭來說,這個代價不算什麼;但誤把一個有重複模式的表頭設成 bypass,一樣是把原本能拿到的快取效益白白放棄。

把表頭分類寫進 Cache Rule

把三個動作放在一起看,分類的問題只有一個:這個表頭的值,對這個回應而言,差多少才算真的不同。

三種動作的快速對照,方便設定 Cache Rule 時查閱。
動作做什麼適合配的表頭
normalize先把等價的表頭值正規化,再選快取變體,讓語意相同的請求共用同一份快取。Accept、Accept-Language、Accept-Encoding 這類值不同、意思一樣的表頭。
passthrough用請求表頭的原始位元組比對快取變體,大小寫、空白、順序、重複值都不變。值的範圍有限,但精確值會影響回應的表頭。
bypassorigin 在 Vary 裡點名這個表頭時,直接不存這筆回應。個人化、高基數或不可預期的表頭,例如 Cookie、User-Agent。

三個動作是按 Vary 點名的每個表頭分別掛的——同一個 Vary 回應裡,origin 可能同時列出 Accept-Language 跟 Cookie,兩者的性質完全不同:一個是語言偏好,值的空間有限;另一個幾乎每個使用者都不一樣。Cache Rule 對每個表頭各自套用設定好的動作。沒有另外設定的表頭,走 Cache Rule 整體的預設動作:設定時得先選一個預設值,再逐一為需要的表頭指定例外。對一個列了十幾個 Vary 表頭的 origin 來說,大部分表頭多半用不到什麼特別處理,真正值得花時間個別設定的,通常只有那幾個高基數或語意需要精確比對的表頭。

實務上,這代表設定 Cache Rule 的人得先逐一看過 origin 實際送出的 Vary 表頭清單,而不是套一個統一規則了事。同一個表頭在不同服務可能該配不同動作——一個把 Accept-Language 當成純粹語言偏好的 API,適合 normalize;另一個把 Accept-Language 的精確 q 值也拿去做語言排除邏輯的服務,可能就得用 passthrough,即使兩者用的是同一個表頭名稱。同一份 Cache Rule 範本,套用到兩個不同 API 上,可能得到兩種完全不同的表頭分類結果——以這兩個例子看,按表頭設定動作,正好能處理這種差異。

設定完不代表可以放著不管。Cloudflare 建議上線後主動測試:用不同的表頭值打同一個網址,確認理論上該正規化成同一個變體的請求,實際上真的落在同一個快取項——作法是從同一個 client 送出測試請求,確認回應的格式與語言符合預期,並檢查 CF-Cache-Status,看命中的是不是同一個快取項。這裡的推論是:用同一個 client 之所以重要,是因為不同瀏覽器本來就可能送出寫法不同但語意相同的 Accept-Language,換了 client,落差可能來自瀏覽器差異,而不是 Cache Rule 設定本身的問題;同樣道理,如果兩個理論上該正規化成同一個快取項的請求卻各自 MISS,很可能代表某個表頭的動作設定跟 origin 實際送出的值對不上。

實際設定的路徑很具體:在 dashboard 裡進 Caching、Cache Rules,新增或編輯一條規則,把回應設成可以快取,加上 Vary 設定,先定好預設行為,再把 origin 實際會用到的表頭一個個加進去。這個設定入口在 Free、Pro、Business、Enterprise 四個方案都已經開放,除了 dashboard,也可以透過 Rulesets API 或 Terraform 設定。用 Terraform 管理 Cloudflare 設定的團隊,可以把表頭分類直接寫進版本控制,跟其他 Cache Rule 一起 review、一起部署。

Cloudflare 自己也承認,手動設定支援的格式與語言,不一定適合每一種應用情境。文章末尾提到,團隊正在評估已經過期的 Availability Hints 草案能不能把這段工作省下來,讓 origin 直接描述自己會提供哪些版本,而不是每個表頭都得靠 Cache Rule 逐一分類。眼下,normalize、passthrough、bypass 這三個選項還是得靠人工配置出來。

The unlock:Vary 從「忽略就可能送錯、照字面比對就打散快取」的兩難,變成三個可以按表頭分別設定的動作——normalize、passthrough、bypass 各自對應值等價就該共用、值範圍有限但精確值會影響回應、根本不該進快取三種情境,設定的人只要照著這三個問題,把 origin 實際用到的表頭分類就好。