總覽與定位
CDN 是一群地理分散的共用 HTTP 快取。本篇用「一個請求在邊緣節點上發生的事」貫穿:先算 Cache Key,再決定 HIT/MISS/回源,最後才談過期、失效與誰去打 origin。
應用層快取(Redis、本地 LRU)服務的是你自己的服務:key 是你定的,失效是你 DEL 的。CDN 服務的是HTTP 表徵:它站在瀏覽器與 origin 之間,讀 Cache-Control、算一把從 URL 與選定標頭組出來的 key,把同一份回應賣給同一個 PoP 上的下一個陌生人。兩者都能降延遲、都能打穿 origin,但契約完全不同。
一個 GET 落到邊緣之後,生命週期幾乎總是這四步。後面每一章對應其中一步,不跳著講產品功能清單。
flowchart LR
C["Client GET"] --> P["就近 PoP"]
P --> K["算 Cache Key"]
K --> L["查快取"]
L -->|HIT| C
L -->|MISS| O["回源 / Shield"]
O --> C
看「查快取」分出的兩條邊:HIT 那條直接折返 Client,origin 完全不知道這次請求;只有 MISS 那條才往右走到回源或 Shield,而且回來之後還要寫進邊緣的 store。
HTTP/1.1 與 HTTP/2 已經講過訊息語義、狀態碼、連線多路復用,以及 freshness/validation 這對詞的定義——本篇只談共用快取怎麼執行這些指令。
使用者如何被導到某一個 PoP,只講它如何把快取切成地理分片;DNS 解析、CNAME、TTL 交給 DNS 深入解析。
Cache-Aside、Write-Through、LRU/LFU 是 快取策略 的題。Origin 被隨機空 key、熱 key 到期、TTL 對齊打死,是 快取失效模式與雪崩防護 的題——本篇只談邊緣側對應的 collapsing、stale 售賣與 Shield。
Cache Key 正規化與 Vary 的代價
命中率幾乎總是先被 key 設計殺死,而不是被 TTL 殺死。進 key 的每一個位元組,都在複製一份快取物件。
邊緣看到的不是「/hero.jpg 這張圖」,而是一把由 URL 與選定請求欄位組出來的字串。同一張圖,十種 query 排列就可能變成十份物件——每一份都 miss 一次、佔一份記憶體、過期一次。
正規化的工作是刪掉不改變表徵的維度,留下真正會讓回應不同的維度。排序 query、去掉空值、主機名小寫,是免費的;難的是決定哪些欄位「算內容」。
1 · Query 參數:內容 vs 追蹤
v/hash:資產版本。進 key 才能讓app.js?v=7與v=8分成兩份,這正是版本化 URL 要的w/h/format:圖片衍生尺寸。不同像素就是不同物件locale/currency:同一路徑下的不同表徵
utm_*、gclid、fbclid:廣告歸因。進 key 等於幫每次行銷活動複製一份快取- 隨機
_/cache-buster:客戶端自己在殺命中率 - 排序不同、值相同:
?a=1&b=2與?b=2&a=1必須正規化成同一把
左右兩欄的判準只有一條:這個參數會不會改變回應的 bytes。會改變的留下(左欄),只改變觀測的丟掉(右欄)。
2 · Header 與 Cookie
共用快取預設不該把 Cookie 放進 key。Session cookie 幾乎每人一把,key 基數 ≈ 活躍用戶數,命中率趨近於 0,還有把 A 的私人頁面賣給 B 的風險——後者該用 Cache-Control: private/no-store 直接禁止共用,而不是「用 cookie 當 key 假裝隔離」。
若產品真的需要「登入/未登入兩份 HTML」,正確做法是把身分正規化成一個低基數維度(例如 logged_in=0|1),而不是整段 Cookie 原樣進 key。
3 · Vary 的帳單
Vary 告訴共用快取:「這份回應還依哪些請求標頭而變」。它不是註解,是 key 的乘法器。
| Vary | 物件倍數 | 實務 |
|---|---|---|
Accept-Encoding |
gzip/br/identity… | 幾乎每個 CDN 都會自己談壓縮並正規化;不要再手動加出第三種編碼 |
Accept-Language |
語言數 × 品質值變體 | 瀏覽器送的字串很髒(en-US,en;q=0.9)。不正規化就爆炸。能改用 ?locale= 就不要 Vary 語言 |
Cookie |
≈ 用戶數 | 等價於這條 URL 在共用快取上不可快取 |
User-Agent |
裝置/瀏覽器長尾 | 經典碎片化。要分桌面/手機,用正規化後的裝置類別,不要原串 |
Vary 每加一個高基數標頭,命中率就按那個基數相除。面試被問「為什麼上了 CDN 命中率只有 20%」,先把 Cache Key 攤開,十次有八次答案在這裡。
不足:追蹤參數進 key,快取被廣告活動切成碎片,origin 流量看起來「怎麼清不掉」。過度:把 locale 從 key 拿掉,英文快取賣給中文用戶——這是正確性 bug,比 miss 更貴。規則是:會改變表徵的留下,只改變觀測的丟掉。
命中、未命中與回源
key 算完之後,邊緣只有三種工作:把已有的表徵賣掉、去 origin 取一份,或掛在別人正在取的那一趟上。
使用者被導到哪個 PoP,只在這一步有意義:快取是地理分片的。東京 HIT 幫不到倫敦 MISS。Anycast 或 Geo-DNS 把請求送到「近」的節點,於是熱門物件在每個 PoP 各自暖機一份;冷門物件則在很多 PoP 上永遠是 miss。解析過程本身交給 DNS 篇,這裡只留下這個後果:全球命中率 ≠ 單一 PoP 命中率,purge 也必須扇出到所有分片。
| 同一條 URL、同一把 key | TPE PoP | LHR PoP |
|---|---|---|
| 這個 PoP 本地有沒有這把 key | 有(已被前一位使用者暖過) | 沒有 |
| 這一次的判定 | HIT,停在邊緣 | MISS,仍要回源 |
| origin 看到的流量 | 0 | 1 次 GET |
| 一次 purge 要扇出到 | 這一片 | 這一片,兩片都收到才算作廢完成 |
兩欄是同一條 URL、同一把 key,結果卻相反——快取是地理分片的,所以全球命中率永遠低於單一 PoP 的命中率,purge 也必須扇出到每一片。
HIT / MISS / 回源
- HIT(fresh):物件在、且仍在 TTL 內。邊緣直接回 200,origin 完全不知道這次請求。
- MISS:這把 key 在這個 PoP 上沒有。邊緣成為 origin 的 HTTP 客戶端,取回後寫入自己的 store,再回給使用者。
- Expired + revalidate:物件在,但過期了。邊緣帶
If-None-Match問 origin;304 則刷新新鮮度、body 沿用,200 則換新表徵。
邊緣側的請求合併(Request Collapsing)
同一 PoP、同一把 key、同一瞬間的一群 miss,不必每人打一趟 origin。第一個成為 in-flight 的請求去回源,後來者掛在同一趟結果上。這是邊緣版的 Singleflight:範圍是這一台邊緣節點(或這一個 PoP),不是全球。
- 熱資產剛過期、或剛部署的新 hash 檔名,一個 PoP 可能同時湧入幾千個 miss
- 合併把這個 PoP 的 origin GET 從 N 收成 1
- 使用者仍付那一趟回源的延遲(尾部被對齊)
- 200 個 PoP 同時 miss,origin 仍可能看到 200 次回源——那是 Shield 的題
- Cache Key 碎片化後,每把 key 各合併各的,總回源次數不會下降
- origin 被隨機空 URL 打穿、被應用層熱 key 打死,是 快取失效模式 的題;邊緣 collapsing 救不了「每把 key 只出現一次」
左欄是 collapsing 真正收掉的東西——單一 PoP 內併發 miss 的乘數;右欄是它的射程邊界,跨 PoP 與 key 碎片化都不在裡面。
404/301 也可以被快取,指令仍是 Cache-Control(或 CDN 的預設 negative TTL)。這能擋住「已下線的舊資產被爬蟲反覆打 origin」,代價是上線後的短暫假陰性——跟應用層空值快取同一類窗口,只是旋鈕在 HTTP 標頭,不在 Redis SET nil。Bloom Filter、互斥重建鎖不要答進這段。
TTL 與 stale-while-revalidate/stale-if-error
過期不是一扇關上門。邊緣可以在「新鮮」「過期但仍可賣」「origin 掛了仍可賣」三個窗口裡做不同的事。
共用快取看的新鮮度,主要來自回應上的 s-maxage(沒有才退到 max-age)與物件進入快取後累計的 Age。當 Age < TTL,物件是 fresh,請求連 origin 的門都不敲。TTL 到期之後,預設行為是先驗證再賣——使用者付一次 RTT。兩個擴充指令把這扇門打開一條縫。
stale-while-revalidate:先賣舊的,背景更新
物件剛過期、但仍在 SWR 秒數內:邊緣立刻把舊表徵回給使用者(狀態仍是 200),同時對 origin 發一趟條件式 GET。更新成功就換新物件,失敗就繼續持有舊的直到窗口結束。
使用者看到的內容,與背景正在抓的內容,在這一秒內是故意分叉的。這就是 Demo 時間軸要對的帳:讀路徑的延遲被壓成 HIT,新鮮度落後最多一個 SWR 窗口。
- 行銷頁、商品列表、靜態組態、公開 JSON
- 把「TTL 到期瞬間的 herd」從使用者路徑拿掉:到期後第一個請求不再擋住所有人
- 與邊緣 collapsing 疊加:背景更新本身也可以合併
- 產品必須說得出「最多舊 N 秒可以接受」。庫存、價格、權限決策通常不行
- 窗口內 origin 一直失敗,使用者會一直看到同一份舊頁——這是功能,不是 bug,但要有告警
- 它不是應用層的邏輯過期:契約在 HTTP 標頭,任何遵守 RFC 5861 的共用快取都該懂
右欄第一條就是採用 SWR 的前提:產品說不出「最多舊幾秒可以接受」,這個窗口就不該開。
stale-if-error:origin 掛了仍賣舊的
SWR 處理的是「過期但 origin 還活著」。SIE 處理的是「origin 5xx/連不上」。在 SIE 窗口內,邊緣可以在驗證失敗時繼續回舊表徵,而不是把 origin 的 502 轉發給全世界。
這是邊緣對 origin 故障的降級,不是應用層雪崩防護的替代品。雪崩篇用 TTL 抖動與 Singleflight 保護 origin 自己;這裡是 origin 已經倒了,邊緣決定還要不要把最後一份已知良好的 bytes 賣出去。
| 窗口 | 使用者拿到 | origin 參與 | 典型標頭 |
|---|---|---|---|
| Fresh | 快取中的表徵 | 不參與 | s-maxage=N |
| SWR | 立刻拿到舊的 | 背景條件式 GET | stale-while-revalidate=M |
| SIE(origin 失敗) | 繼續拿舊的 | 失敗被吞掉 | stale-if-error=K |
| 窗口外 + origin 失敗 | 錯誤頁 | 失敗上浮 | 沒有 SIE、或 SIE 已過 |
三個數字要一起設:TTL 決定平常多久打一次 origin,SWR 決定過期瞬間還能不能零延遲賣,SIE 決定 origin 事故時還能撐多久。
上尺是 age 域:上下兩條泳道分別是 origin 正常與 origin 故障時的行為,同一個 age 在兩條泳道落在不同的窗——這就是 SWR 與 SIE 的差別。下圖是時間域:每一根棒的高度是那次請求的回應延遲,圓點顏色是它落在哪個窗,▽ 是物件被重新填入的時刻。
把 s-maxage 設成一天,命中率確實高,但你失去了「過期後主動去看一眼」的節奏。SWR 的意義是短 TTL + 過期仍可賣:平常保持相對新,尖峰與故障時不把使用者推去排隊打 origin。兩者都設很長,等於承認這份物件可以舊很久——那就直接把 TTL 寫清楚,不要用 SWR 掩飾。
Cache-Control 與條件式重新驗證
邊緣是共用快取,也是 origin 的客戶端。標頭同時管「能不能存」與「過期後怎麼問」。
HTTP 篇已經區分過新鮮度(不發請求)與驗證(發請求但省 body)。這裡只補共用快取會踩到的差異:瀏覽器看到的 max-age,跟 CDN 看到的 s-maxage,常常不是同一個數字;邊緣拿著 ETag 去問 origin,跟瀏覽器拿著 ETag 去問 CDN,是兩段獨立的驗證。
共用快取真正會看的指令
| 指令 | 對 CDN 的意思 | 面試易混 |
|---|---|---|
public |
允許共用快取存。有 Cookie 的回應預設常被當成 private,要明確 public 才進 CDN | 不是「誰都能看這個 URL」的授權語意 |
private/no-store |
CDN 不准存。private 仍允許瀏覽器自己的快取;no-store 連瀏覽器都不該留 | 登入後 HTML 用 private,金融/個資用 no-store |
s-maxage=N |
覆蓋 max-age,只對共用快取生效 | 「CDN 一小時、瀏覽器一分鐘」靠它,不是靠兩份檔案 |
no-cache |
可以存,但每次賣之前必須再驗證 | 名字不像「不要快取」。要禁止儲存用 no-store |
must-revalidate/proxy-revalidate |
過期後不准未驗證就賣。後者只綁共用快取 | 與 SWR 互斥:開了 SWR 就是在允許未驗證先賣 |
immutable |
這份表徵不會在這個 URL 上變。搭配長 TTL 的雜湊檔名 | 用在 /app.a1b2.js,不要用在 /index.html |
一條經驗:HTML 入口短 TTL 或 no-cache;雜湊資產 s-maxage 一年 + immutable;帶身分的回應不要進 CDN。
ETag 在邊緣是「origin 的驗證器」
物件過期後,邊緣帶著手上那份的 ETag 向 origin 送 If-None-Match: "v7",origin 只會有兩種回答:
| origin 判定 | 回應 | 邊緣接著做 | 省下什麼 |
|---|---|---|---|
內容沒變(ETag 仍是 "v7") |
304 Not Modified,無 body |
沿用手上的物件,依新標頭重算新鮮期 | origin 出口頻寬與產生成本;RTT 照付 |
| 內容已變 | 200 + 新表徵 + 新 ETag |
換掉物件,記下新 ETag 供下次驗證 | 什麼都沒省,等同一次完整回源 |
該看「省下什麼」欄:驗證成功時省的是 body,不是往返;兩列付出的 RTT 相同。
304 的意義是省 origin 出口頻寬與產生成本,不省 RTT。邊緣到 origin 往往已經在骨幹上,RTT 不是零;所以能靠 SWR 讓使用者不排隊的,就不要讓使用者路徑去等 304。304 適合「必須確認新舊、但內容十次有九次沒變」的 API 與 HTML。
瀏覽器對 CDN 也可以再做一次同樣的對話。那是另一跳:CDN HIT 時,瀏覽器的 If-None-Match 由邊緣回答 304,origin 仍不參與。兩段驗證不要畫成一條。
HTTP 篇提過:passthrough 的 L4 轉發看不到 URL 與 Cache-Control,邊緣快取無從開始。本篇假設 TLS 在 CDN 終止,origin 與 CDN 之間是另一段 TLS。這不是安全細節複誦,是快取能成立的前提。
主動 Purge、版本化 URL 與 Origin Shield
改了內容之後,要嘛換 URL 讓舊物件自己過期,要嘛去每個 PoP 把舊物件殺掉。回源路徑上再加一層父快取,用來收斂全球 miss。
1 · 版本化 URL(不可變資產)
建置時把內容雜湊寫進檔名:app.a1b2c3.js。這把 URL 永遠對應那一串 bytes,於是可以:
Cache-Control: public, s-maxage=31536000, immutable- 不需要 Purge。舊檔名沒人再請求,自然從 LRU 裡掉下去
- 發布 = 上傳新檔 + 改 HTML 裡的引用。HTML 本身必須短 TTL 或 no-cache,否則使用者會拿到「舊 HTML 指向舊 hash」——這是版本化能成立的那一條繩
2 · 主動 Purge
URL 不能改時(CMS 文章、頭圖、功能開關 JSON),只能叫 CDN 把物件作廢。實作上是對 PoP 扇出的失效訊息,不是單一原子操作。
- 緊急下架、錯誤內容、金鑰輪替後的公開設定
- Surrogate Key/Cache Tag 讓你一次殺「這個商品的所有衍生圖」,不必枚舉 URL
- Purge 後下一擊是 miss:搭配 Shield,避免全球 PoP 同時打 origin
- 各 PoP 收到 purge 的時間不同,存在短暫的新舊並存
- 對通配符或熱 prefix 的 purge,等於人為製造一次跨 PoP 雪崩
- API 限流、重試、冪等都要設計;purge 失敗比沒 purge 更難查
右欄三條都是 purge 的隱藏成本;決定要不要 purge 之前,先看你的失效需求能不能被左欄的 tag 模型收斂。
| 版本化 URL | 主動 Purge | |
|---|---|---|
| 正確性 | 舊 URL 永遠舊、新 URL 永遠新 | 扇出期間新舊並存 |
| 命中率 | 新發布當下新檔名是冷的;舊檔名仍可命中直到沒人用 | purge 當下該 key 全冷,下一波全是 miss |
| 適用 | JS/CSS/圖片等可改檔名的資產 | CMS、HTML、必須穩定的 API URL |
| 前置 | 建置管線產生 hash;HTML 入口不能長 TTL | tag 模型、扇出 SLA、與 Shield 搭配 |
預設選版本化。Purge 留給「URL 就是契約」的那類物件,且把 blast radius 限制在 tag,而不是 /*。
3 · Origin Shield 與分層快取
每個 PoP 都是獨立的葉子快取。一個新資產、一次全球 purge、一個短 TTL 的熱 JSON,會讓每個未命中的 PoP 各打 origin 一次。Origin Shield 是靠近 origin 的父快取:葉子 miss 先問 Shield,Shield HIT 就結束,Shield MISS 才真正進 origin。
| 請求落在哪 | 經過的跳 | origin 收到的請求 |
|---|---|---|
| 葉子 PoP HIT | 葉子直接回,不經過 Shield | 0 |
| 葉子 MISS、Shield HIT | 葉子 → Shield | 0 |
| 葉子 MISS、Shield MISS | 葉子 → Shield → origin | 同一物件全球合計 1 次(沒有 Shield 時是每個未命中的 PoP 各 1 次) |
該看最後一欄:Shield 把「每個 PoP 各打一次」收斂成「全球打一次」,多出的那一跳只出現在 miss 的兩列。
這是跨 PoP 的 collapsing。單一 PoP 內的 in-flight 合併處理「這一棟樓的電梯」;Shield 處理「整座城市只派一輛卡車去倉庫」。多一跳的代價發生在 miss 路徑上:HIT 仍由葉子直接回,不經過 Shield。
Shield 是 miss 的單點。生產上要決定 fail-open(葉子暫時直打 origin,origin 可能被瞬間打滿)還是 fail-closed(miss 變錯誤,HIT 使用者不受影響)。沒有第三種「又保護 origin 又在 Shield 死時零代價」的選項。把 Shield 放在 origin 同區域,是為了讓那唯一一跳夠短,不是為了多一個任意的快取層。
互動 Demo:URL → Cache Key 拆解器
把一條真實 URL 攤開:每一段標「進 key/被丟棄/被正規化成什麼」,再用一批帶噪音的樣本請求,算出這組規則的唯一 key 數、回源次數與命中率上限。
上半的段位移圖看兩件事:打叉的段垂到丟棄槽(那些位元組沒進 key),以及保留段的連線是否交叉——交叉就代表排序正規化把它搬了位置。下半的乘法塔是碎片的來源,熱度表最後一列越長,代表碎片化的冷 key 尾巴越重。
邊緣運算邊界與面試折衷
Edge Compute 接在「算 key 之前或 HIT 之後」的那一層。它能改請求、能改回應,但兩頭改錯都會把快取改壞:改壞請求,第 2 節的 Cache Key 正規化白做;改壞回應,每一次 HIT 都把錯的表徵回給所有人。
邊緣運算(Workers/Functions at the edge)讓你在 PoP 上跑一小段請求/回應邏輯:驗 JWT、補地理標頭、依 Cookie 做 A/B、改圖片尺寸、擋 bot。它不是把 origin 應用搬去 300 個城市。
- 讀 token、拒絕明顯非法的請求,讓 miss 不必進 origin
- 把高基數標頭正規化成低基數 Cache Key(裝置類別、登入 0/1)
- 對已 HIT 的表徵做便宜的變換:header、HTML 注入、圖片衍生(衍生結果本身要能被快取)
- 下單、扣庫存、寫主資料庫——300 個 PoP 沒有同一套事務
- 用邊緣當 Redis 替代品存 session:沒有單一一致的記憶體,節點間不共享
- 依完整 Cookie 客製 HTML 卻忘了把回應標成 private:快取會把 A 的頁賣給 B
兩欄的分界是「會不會動到權威狀態」:不動權威的正規化與門禁可以放邊緣,動權威的寫入一律留在 origin。
在 miss 路徑上跑重計算,等於把 TTFB 加回使用者身上,CDN 白開。在 HIT 路徑上若依 Cookie 分叉回應,又沒把分叉寫進 key 或標 private,正確性先壞。設計順序是:先讓物件可快取,再用最小的 compute 去正規化 key 或做便宜變換。需要強一致讀寫的,回到 origin 與應用層快取。
| 面試常問 | 建議答法骨架 |
|---|---|
| CDN 命中率上不去? | 先攤 Cache Key:utm、未排序 query、Vary Cookie/UA。不是先把 TTL 加長。 |
| TTL 到期會不會打死 origin? | 單一 PoP 靠 collapsing;跨 PoP 靠 Shield。使用者路徑再用 SWR 避開排隊。應用層雪崩手段是另一層。 |
| 更新內容怎麼讓用戶立刻看到? | 資產走版本化 URL;HTML/CMS 走短 TTL 或 Purge + tag。不要對雜湊檔名 purge。 |
| s-maxage 跟 max-age 差在哪? | 前者只綁共用快取。CDN 一小時、瀏覽器一分鐘,靠 s-maxage 覆蓋,不是兩份檔。 |
| origin 掛了 CDN 還能撐嗎? | SIE 窗口內可以繼續賣舊表徵;窗口外變錯誤。這是降級,不是永久備份。 |
| 邏輯要不要放到 Edge Worker? | 只放正規化、門禁、便宜變換。權威寫入與 session 留 origin。Compute 若破壞 cacheability,先不要加。 |
管:請求在邊緣的生命週期、Cache Key 正規化與 Vary 碎片化、HIT/MISS/PoP collapsing、SWR/SIE 的 stale 預算、共用快取視角的 Cache-Control 與 ETag 回源驗證、Purge vs 版本化 URL、Origin Shield、Edge Compute 與快取的衝突。不管:HTTP 方法/狀態碼/多路復用(HTTP/1.1 與 HTTP/2);DNS 解析與 CNAME 細節(DNS 深入解析);Cache-Aside 與 LRU/LFU(快取策略);穿透/擊穿/雪崩與 Bloom、互斥鎖(快取失效模式與雪崩防護)。流量導引只保留「快取因此變成地理分片」這一句。
被問「CDN 如何保護 origin」時,先講 key、collapsing、SWR、Shield。Bloom Filter 與邏輯過期是應用層快取的語言;硬搬過來會讓面試官覺得你沒分清共用 HTTP 快取與自己的 Redis。