我的部落格放在 CDN 上,從中華電信連過去卻被送到美國聖荷西:用 curl、dig、ping 拆開 CDN 的原理
先講結論:CDN 沒辦法讓光跑更快,它只能讓你少跑幾趟、跑短一點
寫這篇之前,我先量了一下自己的部落格。
它放在 Cloudflare 上,而 Cloudflare 在台北有機房。照理說,台灣讀者打開它,應該是台北機房直接回應。
結果我用中華電信的網路連過去,回應我的是美國聖荷西:
$ curl -s https://bobochen.dev/cdn-cgi/trace | grep -E "^(colo|loc|http|tls)="
colo=SJC
http=http/2
loc=TW
tls=TLSv1.3
loc=TW 是 Cloudflare 看到的我:人在台灣。colo 是替我服務的機房。Cloudflare 的文件寫得很清楚,這三個字母是「離機房最近的主要國際機場」的 IATA 代碼,SJC 就是聖荷西國際機場。
同一時間、同一條網路,改連 Cloudflare 自己的 1.1.1.1,就乖乖待在台北:
$ curl -s https://1.1.1.1/cdn-cgi/trace | grep -E "^(colo|http|tls)="
colo=TPE
http=http/2
tls=TLSv1.3
兩邊各連 5 次,看拿到第一個 byte 要多久:
| 連到 | 機房 | TCP 連上 | 加密握手完成 | 收到第一個 byte |
|---|---|---|---|---|
| 1.1.1.1 | 台北 TPE | 8–18 ms | 21–31 ms | 30–45 ms |
| bobochen.dev | 聖荷西 SJC | 133–137 ms | 272–293 ms | 406–428 ms |
同一家 CDN,差了十倍。
這篇就從這個有點尷尬的數字出發,把 CDN 拆開來看。先把重點濃縮成一張表:
| 你可能以為 | 其實是 |
|---|---|
| CDN 就是把檔案複製到很多地方 | 複製只是一半,另一半是把「握手」搬到你家附近,讓封包少跑幾趟 |
| 每間機房都有自己的 IP | 1.1.1.1 在台北、東京、法蘭克福、雪梨 ping 都只要 1、2 毫秒,因為幾百間機房共用同一個 IP |
| CDN 一定會把你送到最近的機房 | 「最近」是路由說了算。從中華電信連我的部落格,會被送去聖荷西 |
no-cache 就是不要快取 | no-cache 是「可以存,但每次用之前都要回去問」;不准存的是 no-store |
| 100 個人同時要一個新檔案,源站就被打 100 次 | nginx 預設真的會被打 100 次;加一行 proxy_cache_lock on,我實測 3 輪都只回源 1 次 |
| 源站掛了,網站就跟著掛 | 我把源站砍掉:沒設定的回 502,設了 stale-if-error 那一類的照樣回 200,給的是過期的舊版 |
光速其實很慢:台北到美東,光在光纖裡來回就要 126 毫秒
先講為什麼會有 CDN 這種東西。
網路再快,封包也跑不贏光。
業界標準的康寧 SMF-28 Ultra 單模光纖,規格書上寫的有效群折射率是 1.4676。換算下來,光在裡面每秒大約跑 20.4 萬公里,只有真空中的 68%。下面我都用好算的 20 萬公里。
聽起來很快,但地球很大。
我拿台北到幾個常見機房的直線距離,算出光在光纖裡來回一趟的理論最快時間。再跟我實際連過去的時間比一比:
| 目的地 | 直線距離 | 理論最快來回 | 我實測 |
|---|---|---|---|
| 東京 | 2,095 km | 20.9 ms | 41.5 ms |
| 新加坡 | 3,250 km | 32.5 ms | 89.1 ms |
| 美國聖荷西 | 10,424 km | 104.2 ms | 133.0 ms |
| 美國奧勒岡 | 10,007 km | 100.1 ms | 163.5 ms |
| 美國維吉尼亞 | 12,620 km | 126.2 ms | 199.2 ms |
| 德國法蘭克福 | 9,378 km | 93.8 ms | 230.3 ms |
實測的做法是用 curl 連 AWS 各區域的 S3 端點,看 TCP 連線花多久,各連 5 次取最快的一次。聖荷西那列,是上一節連我部落格的數字。
實測一定比理論慢,因為海底電纜不會照著大圓航線鋪,中間還要經過一堆路由器。法蘭克福明明比維吉尼亞近,實測卻更慢。我猜是封包繞了遠路,實際走的距離比地圖上長得多。
這就是物理的天花板:頻寬可以花錢買,距離不行。
一個 HTTPS 請求,要先來回三趟才拿得到第一個 byte
更慘的是,打開一個 HTTPS 網頁,不是只跑一趟。
回去看剛剛連聖荷西的其中一筆:
bobochen.dev connect=0.135462 tls=0.273086 ttfb=0.406673
connect=0.135:TCP 三向交握,第 1 趟來回tls=0.273:TLS 1.3 加密握手,第 2 趟ttfb=0.406:送出 HTTP 請求、等到第一個 byte,第 3 趟
一般的 TCP 要握完手,才能開始傳資料,這會多花一趟。而 TLS 那一趟,其實已經是砍過的結果:TLS 1.3(2018 年 8 月的 RFC 8446,今年 7 月改由 RFC 9846 接手)把加密握手從兩趟減成一趟。
每一步剛好多 135 毫秒左右。網頁內容還沒開始傳,0.4 秒就沒了。
換成台北的 1.1.1.1,同樣三趟只要 0.03 秒。
所以 CDN 要解的題目其實很單純:把要跑的距離變短,把要跑的趟數變少。 後面講的每一招,都可以歸到這兩件事裡。
把 CDN 想成便利商店:源站是中央倉庫,邊緣節點是你家巷口的門市
想像一家網購公司只有一個倉庫,蓋在美國維吉尼亞。你每買一包衛生紙,都要從維吉尼亞寄過來。
便利商店的解法是:把最常被買的東西,先鋪到全台灣幾千間門市。你下樓就買得到,根本不用管總倉在哪。
門市架子有限,所以只放賣得動的東西。缺貨了,就跟倉庫叫貨,到貨順便上架,下一個客人就不用等。每樣商品都有保存期限,過期就得下架,或打電話回總部問一下還能不能賣。
門市一多,總倉也會被叫貨電話打爆。所以中間會再多一層區域物流中心:門市缺貨先找區域倉,區域倉也沒有,才由它統一跟總倉叫貨。
至於一年賣不到兩次的冷門商品,門市乾脆不進貨。真的有人要,就從總倉調。
CDN 做的就是同一件事,連角色都對得起來:
| 便利商店 | CDN 的說法 | 英文 |
|---|---|---|
| 總倉 | 源站,也就是你自己的伺服器 | origin |
| 巷口門市 | 邊緣節點 | edge、PoP(Point of Presence) |
| 架上有貨 | 快取命中 | cache hit |
| 缺貨、跟總倉叫貨 | 快取未命中、回源 | cache miss、origin fetch |
| 保存期限 | 快取存活時間 | TTL |
| 現貨率 | 快取命中率 | cache hit ratio |
| 區域物流中心 | 分層快取、源站護盾 | tiered cache、origin shield |
| 冷門商品不進貨 | 長尾內容幾乎每次都要回源 | long tail |
你會被帶去哪一間門市?CDN 有兩種導航法
門市開好了,下一個問題是:你在瀏覽器打網址的時候,怎麼知道要去哪一間?
第一招 DNS 導航:同一個網址,六個城市拿到六個不同的 IP
Apple 官網用的是 Akamai。查一下 www.apple.com:
$ dig +noall +answer www.apple.com @8.8.8.8
www.apple.com. 289 IN CNAME www-apple-com.v.aaplimg.com.
www-apple-com.v.aaplimg.com. 300 IN CNAME www.apple.com.edgekey.net.
www.apple.com.edgekey.net. 47 IN CNAME e6858.dsce9.akamaiedge.net.
e6858.dsce9.akamaiedge.net. 20 IN A 184.25.121.49
它不直接給你 IP,而是一路轉介(CNAME):從 Apple 自己的網域,轉給 edgekey.net,再轉給 akamaiedge.net,最後由 Akamai 的 DNS 決定給你哪個 IP。
注意最後那筆的 TTL 只有 20 秒。你的電腦 20 秒後就得重新問一次,所以 CDN 隨時可以把你改派到別的機房。
我 ping 了一下拿到的 184.25.121.49:
round-trip min/avg/max/stddev = 7.317/8.742/10.167/1.094 ms
8 毫秒,這台機器就在台灣。
那別的地方的人問同一個網址呢?我用 Globalping 同時從六個城市查 www.apple.com。Globalping 是 jsDelivr 做的免費量測服務,API 不用註冊就能打,未登入每小時可以跑 250 次測試:
| 量測點 | 拿到的 IP |
|---|---|
| 台北(中華電信) | 23.45.197.53 |
| 東京 | 23.217.69.53 |
| 法蘭克福 | 23.209.209.61 |
| 紐約 | 23.208.69.47 |
| 聖保羅 | 23.192.57.52 |
| 雪梨 | 23.223.212.236 |
前面那串 CNAME,六個城市查到的都一樣,只有最後的 IP 不同。
這就像打查號台問「最近的門市在哪」,查號台看你從哪打來,報給你不同的地址。
不過這招有個盲點:Akamai 看到的其實不是你,是替你查詢的那台 DNS 伺服器(resolver)。resolver 離你很遠的話,它就會以為你在那裡。
後來的補丁叫 EDNS Client Subnet(ECS),2016 年 5 月寫成 RFC 7871:resolver 替你查詢的時候,順便附上「使用者大概在哪個網段」。為了隱私,RFC 建議 IPv4 只附前 24 bits。
有的 resolver 會附,有的不會。Google 有一個專門測這件事的網域,一問就看得出來(我的網段打了碼):
$ dig +short TXT o-o.myaddr.google.com @8.8.8.8
"edns0-client-subnet x.x.x.0/24"
"173.194.171.28"
$ dig +short TXT o-o.myaddr.google.com @1.1.1.1
"2400:cb00:80:1024::a29e:f242"
8.8.8.8 把我的 /24 網段一起送出去了;1.1.1.1 只露出它自己的 IP。Cloudflare 的官方 FAQ 也寫明 1.1.1.1 不送 ECS,因為它主打隱私。
不送的代價,是 CDN 只能靠 resolver 的位置猜你在哪。好在 1.1.1.1 在台北就有機房,我透過它拿到的 Akamai IP 是 23.45.197.53,ping 起來 11.5 毫秒,一樣在台灣。
第二招 Anycast:1.1.1.1 在六個城市 ping,都只要 1、2 毫秒
Cloudflare 走的是另一條路:不管你在哪裡查,DNS 都給你同一組 IP。
$ dig +short bobochen.dev
172.67.209.109
104.21.37.145
我用 Globalping 從同樣六個城市查,拿到的全都是這兩個 IP。
那它怎麼把你送到附近的機房?答案是 Anycast:世界各地的機房,對外都宣稱自己擁有同一組 IP。
聽起來很像作弊,量一下就知道。我一樣用 Globalping,從六個城市同時 ping 1.1.1.1:
| 量測點 | 網路 | 最快一次 | 平均 |
|---|---|---|---|
| 台北 | 中華電信 | 1.859 ms | 2.375 ms |
| 東京 | Oracle | 1.382 ms | 1.439 ms |
| 法蘭克福 | Oracle | 0.933 ms | 0.982 ms |
| 紐約 | DigitalOcean | 1.396 ms | 1.734 ms |
| 聖保羅 | Oracle | 0.894 ms | 0.931 ms |
| 雪梨 | Oracle | 1.338 ms | 1.357 ms |
同一個 IP,六個城市都不到 2.5 毫秒。
如果 1.1.1.1 只是一台機器,這在物理上不可能。光在光纖裡 1 毫秒只跑得了大約 200 公里,來回 1 毫秒,代表機器就在 100 公里內。它不可能同時在法蘭克福跟雪梨的 100 公里內。
唯一的解釋是:六個量測點,連到的是六台不同的機器。
1.1.1.1 自己也會說它在哪。對它問一個特別的 DNS 問題:
$ dig +short @1.1.1.1 CH TXT id.server
"tpe01"
tpe01,台北。Cloudflare 的文件只說這個查詢能「辨識是哪一台伺服器處理了你的請求」,沒寫回傳格式;不過前三個字母,跟 trace 裡的 colo=TPE 對得上。
Anycast 最貼切的比喻是 119。全台灣撥的都是同一個號碼,接起來的卻是你那一區的消防局。你不用背任何一間消防局的電話,電信網路會替你接到該接的那一間。
網路世界負責「轉接」的,是路由協定 BGP。每一間機房都向鄰居宣告:「往 1.1.1.0/24 的封包,交給我。」路由器同時收到好幾條路,就挑一條它認為最好的。
它怎麼認定「最好」,是後面的關鍵:BGP 看的是各家網路自己訂的政策,路徑經過幾個網路是其中一項。地圖上的距離,不在它的考慮裡。
這個設計還附贈一個很強的防禦力:DDoS 攻擊的流量,也會被路由切成幾百份,各自落在離攻擊來源最近的機房。Cloudflare 在 2026 年 2 月的報告裡揭露,他們擋下過一次 31.4 Tbps 的攻擊,只持續了 35 秒。
「最近」是路由說了算:從中華電信連我的部落格,會被送去聖荷西
問題就出在「它認為最好的」這幾個字。
我一樣用 Globalping,從同樣六個城市連我部落格的 /cdn-cgi/trace:
| 量測點 | 網路 | 被分到的機房 | TCP 連上 | 總耗時 |
|---|---|---|---|---|
| 台北 | 中華電信 | SJC 聖荷西 | 129 ms | 415 ms |
| 東京 | Oracle | NRT 東京成田 | 2 ms | 31 ms |
| 法蘭克福 | Oracle | MUC 慕尼黑 | 6 ms | 44 ms |
| 紐約 | DigitalOcean | EWR 紐華克 | 3 ms | 41 ms |
| 聖保羅 | Oracle | EWR 紐華克 | 112 ms | 405 ms |
| 雪梨 | Oracle | SYD 雪梨 | 1 ms | 27 ms |
東京、雪梨、紐約,都被送到隔壁的機房,總耗時 30、40 毫秒。台北跟聖保羅卻都被送到美國,總耗時超過 400 毫秒。
台北那個量測點剛好也在中華電信的網路上。它量到的 415 毫秒,跟我自己量到的 406 毫秒幾乎一樣,所以不是我這條網路的問題。
我查資料時看到的說法幾乎一面倒:「CDN 會把使用者送到最近的節點。」在東京、雪梨、紐約,這句話是真的。但從中華電信連我這個個人網站,就不是。
連 Cloudflare 自己的文件都承認這件事:Anycast 網路上的請求,「不一定會被送到地理上最近的機房」;效能跟穩定衝突的時候,系統會優先選穩定。
那為什麼偏偏是中華電信?Cloudflare 自己寫過答案。2016 年 8 月,他們在官方部落格算了一遍全世界的頻寬成本,點名了台北:
Two Asian locations stand out as being especially expensive: Seoul and Taipei. In these markets, with powerful incumbents (Korea Telecom and HiNet), transit costs 15x as much as in Europe or North America, or 150 units.
翻成白話:台北跟首爾是亞洲最貴的兩個地方。在地的電信龍頭 HiNet 跟 KT,收的轉送費是歐美的 15 倍。
同一篇還寫,因為有六家電信(HiNet 是其中之一)又貴、又不肯談在地互連,他們把免費方案的客戶移出這些網路,改從別的區域服務。
這是十年前的數字,價格早就變了,今天的細節我沒辦法確認。但 2024 年 9 月的官方部落格,把規則講得更白:
Enterprise customer traffic is prioritized and served as close to end users as possible, regardless of the time of day. But our Free customers use off-cycle headroom.
企業方案的流量優先,盡量貼近使用者;免費方案用的是離峰剩下的容量,哪裡有空就從哪裡送。
所以 1.1.1.1 跟 Cloudflare 自己的官網在台北、我的部落格在聖荷西,其實一點都不矛盾:我的部落格不是企業方案。
至於聖保羅那個量測點為什麼也被送到紐華克,我沒有查到官方說法。
這件事的實用結論是:讀者如果大多用中華電信,別只看 CDN 官網的機房地圖,自己連一次 /cdn-cgi/trace,看 colo 寫的是哪裡。
門市的保存期限,寫在 HTTP 回應的標頭裡
每件商品上架前,都要先知道保存期限。CDN 看的保存期限,寫在源站回應的 Cache-Control 標頭裡。
先看我部落格的首頁:
$ curl -sI https://bobochen.dev/ | grep -i -E "^(HTTP|cache-control|cf-cache-status|etag|server|cf-ray|alt-svc)"
HTTP/2 200
cf-cache-status: MISS
cache-control: public, max-age=0, must-revalidate
etag: "ec8965ec9e6ee160d5fad34f2afd1066"
server: cloudflare
cf-ray: a3ef75531c214e35-SJC
alt-svc: h3=":443"; ma=86400
再看首頁用到的一支 CSS:
$ curl -sI https://bobochen.dev/_astro/noto-sans-tc.B7qiOdjG.css | grep -i -E "^(HTTP|cache-control|cf-cache-status|server|cf-ray|content-type)"
HTTP/2 200
content-type: text/css
cf-cache-status: HIT
cache-control: public, max-age=31536000, immutable
server: cloudflare
cf-ray: a3ef755bfef9fdda-SJC
cf-ray 最後那三個字母又是 SJC,一樣是聖荷西。cf-cache-status 則是 Cloudflare 告訴你這次有沒有從快取拿。MISS 的官方定義是:可以快取,但這次架上沒有,所以回源拿。
兩個檔案的保存期限,天差地遠:
| 首頁 HTML | CSS 檔 | |
|---|---|---|
| 網址 | 固定是 / | 檔名裡有一串亂碼 B7qiOdjG |
max-age | 0 秒 | 31,536,000 秒,也就是 365 天 |
| 另外的指令 | must-revalidate:過期了一定要回去問 | immutable:保證不會變,連問都不用問 |
這次的 cf-cache-status | MISS | HIT |
這是前端建置工具的標準招式。CSS 檔名裡那串亂碼,是用檔案內容算出來的指紋,內容一改,檔名就跟著變。舊檔名永遠對應舊內容,所以可以放心快取一整年。
HTML 的網址不能變,總不能叫讀者每次輸入不同網址。所以只好設成 0 秒,每次都回去確認。
回去確認,不等於重新下載
「每次都回去確認」聽起來很浪費,其實有省招。
回應裡那個 etag,是這個版本的指紋。下次再來的時候,把指紋帶著問:「我手上是這個版本,有變嗎?」沒變的話,對方只回一個 304,內容一個 byte 都不用傳:
$ curl -sI -H 'If-None-Match: "ec8965ec9e6ee160d5fad34f2afd1066"' https://bobochen.dev/ | grep -i -E "^(HTTP|cf-cache-status|etag)"
HTTP/2 304
cf-cache-status: HIT
etag: "ec8965ec9e6ee160d5fad34f2afd1066"
我兩種各打 3 次:
| 傳回來的內容 | 收到第一個 byte | |
|---|---|---|
| 完整下載 | 206,566 bytes | 0.460–0.511 秒 |
| 帶著 ETag 問,回 304 | 0 bytes | 0.423–0.431 秒 |
省了 200 KB,時間卻幾乎沒省,因為瓶頸是那三趟往返聖荷西,不是傳輸量。
304 省的是流量,不是來回。
no-cache 不是「不要快取」
順便拆一個超常見的誤會。
很多人以為 Cache-Control: no-cache 就是「不要快取」。HTTP 快取的規格 RFC 9111(2022 年 6 月)寫的不是這樣:
The no-cache response directive, in its unqualified form (without an argument), indicates that the response MUST NOT be used to satisfy any other request without forwarding it for validation and receiving a successful response.
翻成白話:可以存,但每次拿出來用之前,都得先回源站確認過。這其實就是上面首頁 max-age=0, must-revalidate 在做的事。
真正「不准存」的是 no-store。常用的幾個指令,用便利商店翻譯一遍:
| 指令 | 意思 | 便利商店版 |
|---|---|---|
max-age=N | N 秒內可以直接用 | 保存期限 N 秒 |
s-maxage=N | 只給 CDN 這種共用快取看的 max-age | 門市的下架時間,跟你家冰箱的不一樣 |
no-cache | 可以存,用之前要回去確認 | 每次賣之前,都先打電話問總部 |
no-store | 完全不准存 | 現做現賣,不准上架 |
private | 只有使用者自己的瀏覽器能存,CDN 不行 | 客製商品,只能交給本人 |
immutable | 內容保證不變,不用確認 | 罐頭 |
stale-while-revalidate=N | 過期後 N 秒內,先給舊的,背景再更新 | 先把架上那份給你,同時派人去補新貨 |
stale-if-error=N | 源站出錯時,N 秒內可以拿舊的頂著 | 總倉停電,門市先把架上的賣完 |
快取認的是網址,連問號後面都算
CDN 怎麼判斷兩個請求要的是不是同一個東西?看一組叫快取鍵(cache key)的身分證,通常就是網址。
為了看清楚這件事,我在筆電上架了一台迷你 CDN:一個 nginx 當邊緣節點,後面接一個故意每次都要等 2 秒才回應的源站。做法放在文末的 3 分鐘段落。
nginx 預設的快取鍵是 $scheme$proxy_host$request_uri,最後那個 $request_uri 連問號後面的參數都包含在內。同一個檔案,只換參數各打一次(標頭只留相關的行):
$ curl -s -o /dev/null -D - -w 'time_total=%{time_total}\n' "http://127.0.0.1:8080/e2/file.txt?v=1"
HTTP/1.1 200 OK
...
X-Cache-Status: MISS
time_total=2.012176
$ curl -s -o /dev/null -D - -w 'time_total=%{time_total}\n' "http://127.0.0.1:8080/e2/file.txt?v=2"
HTTP/1.1 200 OK
...
X-Cache-Status: MISS
time_total=2.007077
兩次都 MISS、都等了 2 秒。源站的紀錄也證實,它被打了兩次:
[2026-09-22T07:18:06.942Z] GET /e2/file.txt?v=1 version=16
[2026-09-22T07:18:08.962Z] GET /e2/file.txt?v=2 version=17
對快取來說,這就是兩個不同的東西。
這是把雙面刃。好處是你可以用 ?v=2 強迫大家拿新版;壞處是網址後面只要多了行銷追蹤參數(像 ?utm_source=...),每一種組合在快取裡都各自算一份,命中率就被打散了。
快取沒中的那一瞬間最危險:100 個人同時要同一個新檔案
快取有一個很要命的時刻:新東西剛上架、架上還沒有貨的那一瞬間。
想像新款手機開賣那天早上,100 個人同時衝進同一間門市問有沒有貨。如果店員每個人都幫忙打一通電話回總倉,總倉一早就會被 100 通電話淹沒。
網路上的版本更慘。一篇文章突然爆紅,或是新版 App 剛發布,上萬個請求同時打進 CDN。那一瞬間快取裡什麼都沒有,每一個都是 MISS。如果每個 MISS 都各自回源,源站會在同一秒被打爆。
這叫驚群效應(thundering herd),也有人叫它快取踩踏(cache stampede)。
正常的店員會這樣做:第一個客人問的時候打一通電話,其他 99 個人先排隊,貨到了一起給。
CDN 也是這樣做,這招叫請求合併(request collapsing)。Cloudflare 的文件是這樣描述的:同一間機房同時收到好幾個 MISS,只放第一個請求回源,其他的在原地等,貨回來之後再一起串流給所有人。
我在筆電上重現了一次:源站被打的次數,從 100 次變成 1 次
一樣用那台迷你 CDN。我用壓測工具 hey(我裝的是 0.1.4 版),對一個從來沒被快取過的網址,同時丟 100 個請求:
$ hey -n 100 -c 100 http://127.0.0.1:8080/e3/lockoff-1
Summary:
Total: 3.1050 secs
Slowest: 3.1050 secs
Fastest: 2.0274 secs
Average: 2.5613 secs
Requests/sec: 32.2060
...
Status code distribution:
[200] 100 responses
100 個請求都成功了,看起來沒事。可是一翻源站的紀錄,/e3/lockoff-1 出現了整整 100 次。nginx 預設就是讓每個 MISS 各自回源。
接著在設定裡加一行,這就是 nginx 版的請求合併:
proxy_cache_lock on;
換一個全新的網址,一樣同時 100 個請求:
$ hey -n 100 -c 100 http://127.0.0.1:8080/e3/lockon-1
Summary:
Total: 2.8030 secs
Slowest: 2.8005 secs
Fastest: 2.0274 secs
Average: 2.4017 secs
Requests/sec: 35.6759
...
Status code distribution:
[200] 100 responses
這次源站的紀錄裡,/e3/lockon-1 只有 1 行。
怕是運氣好,兩種設定我各跑了 3 輪,每輪都換全新的網址:
| 設定 | 源站被打幾次 | 平均回應時間 | 最慢的一個 |
|---|---|---|---|
proxy_cache_lock off(預設) | 100、100、100 | 2.56、2.20、2.07 秒 | 3.11、3.05、2.17 秒 |
proxy_cache_lock on | 1、1、1 | 2.40、2.40、2.04 秒 | 2.80、2.54、4.03 秒 |
源站的負擔從 100 變成 1,3 輪結果一模一樣。
但回應時間要仔細看:兩種設定都在 2 秒上下,因為大家都得等源站那 2 秒。開了鎖的那組,第 3 輪最慢的一個甚至等了 4 秒。
所以請求合併不會讓使用者變快。它保護的是源站,不是你的等待時間。
一層不夠就兩層:我從台灣抓 PyPI,標頭寫著它經過了維吉尼亞兩台、東京一台
請求合併有個限制,Cloudflare 的文件也寫明了:它只管得到同一間機房。全世界幾百間機房同時快取沒中,還是會有幾百個回源請求。
所以大型 CDN 會在門市跟總倉之間,多放一層區域物流中心。所有門市缺貨都先找它,只有它會去敲源站的門。業界叫它分層快取(tiered cache),或源站護盾(origin shield)。
以 Cloudflare 為例,它把機房分成上下兩層:下層沒有貨就問上層,只有上層可以回源。這個功能連免費方案都能開。
這在真實世界看得到。Python 的套件庫 PyPI,首頁頁尾的贊助商清單寫著「Fastly CDN」。我從台灣抓它的一個頁面:
$ curl -sI https://pypi.org/simple/requests/ | grep -i -E "^(HTTP|x-served-by|x-cache|x-cache-hits|age|via|cache-control|surrogate)"
HTTP/2 200
cache-control: max-age=600, public
x-served-by: cache-iad-kiad7000085-IAD, cache-iad-kiad7000055-IAD, cache-nrt-rjaa8190033-NRT
x-cache: MISS, HIT, HIT
x-cache-hits: 0, 44, 1
x-served-by 列出這個回應經過的快取伺服器,名字最後三個字母一樣是機場代碼:兩台 IAD(美國維吉尼亞的杜勒斯機場),一台 NRT(東京成田)。Fastly 的文件寫,服務有開 shielding 的時候,這裡會列出不只一台。shielding 就是 Fastly 版的源站護盾。
x-cache 也是一台報一個結果:一個 MISS、兩個 HIT。
我的解讀是:NRT 是離我最近的那間門市,IAD 是 PyPI 設在美國的區域物流中心。後來標準化的 Cache-Status 標頭(RFC 9211,2022 年 6 月)規定,清單的第一個是最靠近源站的快取、最後一個是最靠近使用者的,這裡的排法看起來也一樣。照這個順序讀,就是維吉尼亞那層當初回源拿過一次,之後東京從維吉尼亞拿,我要的時候東京已經有貨了。
源站掛了,CDN 還能拿過期的存貨頂著
前面那張 Cache-Control 表裡,有兩個指令我一直留著沒講:stale-while-revalidate 跟 stale-if-error。stale 是「不新鮮」的意思。它們處理的是同一個問題:保存期限過了,架上那份舊貨還能不能賣?
這兩個指令來自 2010 年 5 月的 RFC 5861。它自己舉的例子是 Cache-Control: max-age=600, stale-while-revalidate=30:600 秒內是新鮮的;過期之後的 30 秒內,快取可以先送舊的,同時在背景回源確認。
過期那一刻:老實回源要等 2 秒,先給舊的只要 3 毫秒
nginx 預設的做法很老實:過期了就回源拿新的,拿到之前,客人就在那邊等。
另一種做法,是先把架上那份舊的給客人,同時在背景派人去補貨。nginx 裡是這兩行(第一行後半的 error timeout http_500... 下一節才用得到):
proxy_cache_use_stale error timeout updating http_500 http_502 http_503 http_504;
proxy_cache_background_update on;
我把保存期限設成 10 秒,兩種設定各打三次:先放進快取、等 12 秒讓它過期、再連打兩次。
| 第 1 次:放進快取 | 第 2 次:過期後第一個 | 第 3 次 | |
|---|---|---|---|
| 預設 | MISS,version=321,2.020 秒 | EXPIRED,version=322,2.019 秒 | HIT,version=322,0.001 秒 |
| 先給舊的 | MISS,version=323,2.017 秒 | STALE,version=323,0.003 秒 | HIT,version=324,0.001 秒 |
version 是源站每回應一次就加 1 的流水號。看第 2 次那格:先給舊的那組,拿到的還是舊的 323 號,但只花 3 毫秒;背景補回來的 324 號,到第 3 次才拿到。
這是那次 STALE 回應的原始輸出(標頭只留相關的行):
HTTP/1.1 200 OK
...
X-Cache-Status: STALE
version=323
path=/e4/stale-on-demo
served_at=2026-09-22T07:20:10.392Z
time_total=0.003169
這就是 stale-while-revalidate 在做的事。代價是有一個人會拿到過期了幾秒的內容,換來沒有任何人需要等。
源站整個掛掉:沒設定的吐 502,有設定的繼續賣舊貨
更狠的情境:保存期限過了,源站剛好也掛了。
我把源站的 process 直接砍掉,再來要同一個網址。預設設定下,nginx 回的是這個:
HTTP/1.1 502 Bad Gateway
...
X-Cache-Status: EXPIRED
<html>
<head><title>502 Bad Gateway</title></head>
<body>
<center><h1>502 Bad Gateway</h1></center>
<hr><center>nginx/1.30.5</center>
</body>
</html>
HTTP_STATUS=502 time_total=0.003829
注意 X-Cache-Status: EXPIRED。那份舊貨明明還在架上,只是過期了,nginx 寧可告訴你 502。
加上前面那行 proxy_cache_use_stale error timeout ... 之後,同樣砍掉源站再試一次:
HTTP/1.1 200 OK
...
X-Cache-Status: STALE
version=1
path=/e5/error-b
served_at=2026-09-22T07:21:24.801Z
HTTP_STATUS=200 time_total=0.001598
源站死了,讀者還是拿到 200,只是內容停在源站掛掉前的那一版。這就是 stale-if-error:總倉停電,門市先把架上的賣完。
(這一輪我重開過一次源站,所以流水號從 1 重新算。)
下架召回:改了源站的檔案,CDN 不會自己知道
快取還有一個天生的副作用:CDN 記住一個檔案之後,你在源站把它換掉,CDN 完全不會知道。它會繼續賣架上那一份,直到保存期限到。
要它馬上下架,得主動叫它清掉,這個動作叫 purge。
我在 CatchPlay 的時候,清 CDN 快取是同事每天都要做的雜事之一。後來我寫了幾個小工具,其中一個就是 Purge CDN cache 工具,每一個都是把同事十分鐘的操作縮短到十秒鐘。這段寫在〈500 台 EC2 同時轉檔的日子——CatchPlay〉。
現在的 CDN 清快取快很多。Cloudflare 2024 年 9 月宣稱,依標籤、主機名稱、網址前綴來清,全球生效時間的中位數不到 150 毫秒;2025 年 4 月起,這幾種清法連免費方案都能用。
「依標籤清」特別好用:源站在回應裡替檔案貼上標籤,例如同一篇文章用到的 HTML 跟圖片都貼同一個。文章一改,一次清掉整組,不用一個一個網址點名。
回頭看我部落格那支 CSS 就懂了:檔名帶指紋的檔案,根本不需要 purge。內容一改就是新檔名,舊的放著讓它自然過期就好。
最好的 purge,是設計成不需要 purge。
就算快取沒中,CDN 也在幫你省來回
講到這裡,很容易以為 CDN 的功勞全在快取:有存到就快,沒存到就沒用。
其實不是。回想一下那三趟來回:TCP 握手、TLS 握手、HTTP 請求。如果中間有一台 CDN 機房,前兩趟只要跑到附近的機房就好,不用跑到源站。
CDN 機房跟源站之間的連線,則會留著給下一個請求用。AWS CloudFront 的文件寫得很直接:它拿到源站的回應之後,會把連線留幾秒,等下一個請求進來;這樣就省下重新建立 TCP 連線、再做一次 TLS 握手的時間。
用前面量到的數字推算一下。假設源站在維吉尼亞(來回 199 ms),CDN 機房在台北(來回算 10 ms)。這是推算,不是實測:
| 走法 | 第 1 趟 TCP | 第 2 趟 TLS | 第 3 趟請求 | 合計 |
|---|---|---|---|---|
| 直連源站 | 199 | 199 | 199 | 約 600 ms |
| 經台北機房,快取沒中 | 10 | 10 | 10+199(機房到源站的連線還開著,回源只要一趟) | 約 230 ms |
| 經台北機房,快取命中 | 10 | 10 | 10 | 約 30 ms |
就算快取沒中,也從 600 降到 230。省下來的,就是那兩趟跨太平洋的握手。
HTTP/3 最多能再省一趟,但在我這條網路上時好時壞
HTTP/3 在 2022 年 6 月寫成 RFC 9114,底下的傳輸層從 TCP 換成了 QUIC(RFC 9000,2021 年 5 月)。QUIC 把傳輸層的握手跟加密的握手合在一起做,三趟變兩趟。
我的部落格其實有在廣告自己支援 HTTP/3,就是剛剛標頭裡這行:
alt-svc: h3=":443"; ma=86400
意思是:「我也能講 HTTP/3,在 443 port,這個消息 86,400 秒內有效。」
下午 3 點多,我用支援 HTTP/3 的 curl 8.18.0 強制走 HTTP/3,結果連不上:
$ curl --http3-only -sv -o /dev/null https://bobochen.dev/cdn-cgi/trace
* ngtcp2_conn_handle_expiry returned error: ERR_HANDSHAKE_TIMEOUT
* Failed to connect to bobochen.dev port 443 after 10206 ms: Failed sending data to the peer
換連 1.1.1.1 跟 Cloudflare 的 HTTP/3 測試站,全都一樣是 ERR_HANDSHAKE_TIMEOUT。
晚上 7 點半,同一條中華電信網路、一樣被分到 SJC,我再試一次,這次連上了。HTTP/2 跟 HTTP/3 各打 10 次,看收到第一個 byte 要多久:
| 最快 | 中位數 | 最慢 | |
|---|---|---|---|
| HTTP/2 | 0.398 秒 | 0.403 秒 | 0.411 秒 |
| HTTP/3 | 0.270 秒 | 0.474 秒 | 0.807 秒 |
HTTP/3 最快的那幾次是 0.27 秒,剛好比 HTTP/2 少了一趟往返聖荷西(約 0.13 秒),跟理論完全對得上。可是 10 次裡只有 3 次這麼快,中位數反而比 HTTP/2 慢,最慢的一次要 0.8 秒。
QUIC 走的是 UDP。我猜是這條網路對 UDP 的處理時好時壞,下午整個不通,晚上通了但會掉包重送;這點我沒辦法從外面證實。
規格早就想到這種情況了。RFC 9114 寫著:
Connectivity problems (e.g., blocking UDP) can result in a failure to establish a QUIC connection; clients SHOULD attempt to use TCP-based versions of HTTP in this case.
UDP 被擋、QUIC 連不上,客戶端就應該改走 TCP 版本的 HTTP。Chrome 的做法是讓 TCP 跟 QUIC 兩條連線同時開跑,QUIC 失敗就直接用 TCP 那條,你完全不會發現。
所以「支援 HTTP/3」跟「讀者真的用上 HTTP/3」是兩件事;用上了,也不保證比較快。
壓縮:一樣的首頁,傳的量少了四分之三
CDN 還會幫你壓縮。我用不同的 Accept-Encoding 各抓一次首頁:
identity size_download=206566 content-encoding=
gzip size_download=46078 content-encoding=gzip
br size_download=46509 content-encoding=br
zstd size_download=49792 content-encoding=zstd
| 壓縮方式 | 大小 | 剩下原本的 |
|---|---|---|
| 不壓縮 | 206,566 bytes | 100% |
| gzip | 46,078 bytes | 22.3% |
| Brotli(br) | 46,509 bytes | 22.5% |
| zstd | 49,792 bytes | 24.1% |
有一個跟一般說法相反的結果:大家都說 Brotli 壓得比 gzip 小,在我的首頁上,br 反而比 gzip 大了 431 bytes。
一般說法的來源,是 Google 2015 年發表 Brotli 時的宣稱:壓縮率比 Zopfli(相容 gzip 格式、壓得最狠的做法)高 20–26%。但那是 Brotli 開到最高等級、慢慢壓出來的數字。
CDN 是在你要的當下才壓,不能慢。Cloudflare 2023 年的官方部落格寫,他們從 2017 年起,即時壓縮最高只用 Brotli level 4,gzip 則用 level 8(2020 年另一篇官方文章寫的是 level 5,總之都是低等級)。他們自己測的結果,兩者只差 1%。
所以在即時壓縮的前提下,Brotli 跟 gzip 本來就差不多,我的首頁剛好落在 gzip 小一點的那邊。要用最高等級預先壓好的靜態檔,Brotli 才會明顯比較小。
3 分鐘自己動手:先看穿一個網站的 CDN,再架一台迷你節點
三行指令,看穿任何網站的 CDN
macOS 內建的 curl(我這台是 8.7.1)跟 dig(9.10.6)就能跑,不用另外裝:
# 1. 看回應標頭:用哪家 CDN、這次有沒有命中快取
curl -sI https://www.python.org/ | grep -i -E "^(server|via|x-served-by|x-cache|cf-cache-status|age|cache-control):"
# 2. 看 DNS:是不是一路 CNAME 轉介給 CDN
dig +short www.apple.com
# 3. 如果是 Cloudflare,看你被分到哪一間機房
curl -s https://bobochen.dev/cdn-cgi/trace | grep colo
我跑出來長這樣:
server: nginx
via: 1.1 varnish, 1.1 varnish, 1.1 varnish
age: 3450
x-served-by: cache-iad-kiad7000081-IAD, cache-iad-kiad7000081-IAD, cache-iad-kiad7000081-IAD, cache-itm1220047-ITM
x-cache: MISS, HIT, HIT
www-apple-com.v.aaplimg.com.
www.apple.com.edgekey.net.
e6858.dsce9.akamaiedge.net.
125.252.234.165
colo=SJC
最小可用流程:跑第 1 行 → 看到 cf-cache-status、x-cache 這類標頭,就知道前面有 CDN,age 代表這份存貨放了幾秒 → 想知道被分到哪裡,跑第 3 行看 colo,或看 x-served-by 最後一段 → 覺得慢,就用下面的 -w 把三趟來回拆開量。
常用參數:
curl -I:只要標頭,不要內容curl -s:不印進度條curl -w 'connect=%{time_connect} tls=%{time_appconnect} ttfb=%{time_starttransfer}\n' -o /dev/null:把 TCP、TLS、第一個 byte 三個時間點拆開curl -H 'If-None-Match: "<etag>"':帶指紋去問,看會不會拿到 304dig +short:只印答案dig @8.8.8.8:指定用哪一台 resolver 查dig +short @1.1.1.1 CH TXT id.server:問 1.1.1.1 你連到哪一間機房
一個 nginx 容器,就是一台迷你 CDN 節點
前面那些實驗,用的就是下面這台。只需要 Python 3 跟 Docker,我是在 macOS 上用 OrbStack 2.2.1 附的 Docker 29.4.0 跑的,nginx 映像是 1.30.5。
先開一個空資料夾,貼上這兩個檔案:
# origin.py:故意很慢的源站,每個請求都要等 2 秒,回應裡帶一個遞增的版本號
import itertools, time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
version = itertools.count(1)
class SlowOrigin(BaseHTTPRequestHandler):
def do_GET(self):
time.sleep(2)
body = f"version={next(version)}\n".encode()
self.send_response(200)
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
ThreadingHTTPServer(("127.0.0.1", 8081), SlowOrigin).serve_forever()
# nginx.conf:邊緣節點
events {}
http {
proxy_cache_path /var/cache/nginx/mini keys_zone=mini:10m;
server {
listen 8080;
location / {
proxy_pass http://host.docker.internal:8081;
proxy_cache mini;
proxy_cache_key $scheme$proxy_host$request_uri; # 預設值,寫出來方便看
proxy_cache_valid 200 10s; # 保存期限 10 秒
add_header X-Cache-Status $upstream_cache_status always;
}
}
}
然後跑起來:
# 1. 開源站,log 寫進 origin.log
python3 origin.py 2> origin.log &
# 2. 開邊緣節點
docker run -d --name cdn-mini-edge -p 8080:8080 \
--add-host=host.docker.internal:host-gateway \
-v "$PWD/nginx.conf:/etc/nginx/nginx.conf:ro" nginx:1.30.5
# 3. 同一個網址打三次,再數源站被打了幾次
for i in 1 2 3; do
curl -s -D - -w 'time_total=%{time_total}\n' http://127.0.0.1:8080/hello.txt \
| grep -E "^(HTTP|X-Cache-Status|version|time_total)"
done
grep -c "GET /hello.txt" origin.log
我照這份從頭跑一次的輸出:
HTTP/1.1 200 OK
X-Cache-Status: MISS
version=1
time_total=2.035151
HTTP/1.1 200 OK
X-Cache-Status: HIT
version=1
time_total=0.009681
HTTP/1.1 200 OK
X-Cache-Status: HIT
version=1
time_total=0.007486
1
第一次 MISS 等了 2 秒,後兩次 HIT 都不到 10 毫秒,源站只被打了 1 次。玩完記得收掉:docker rm -f cdn-mini-edge,再 kill %1 把源站關掉。
最小可用流程:跑第 3 步 → 看到 MISS 變 HIT、源站只被打 1 次 → 想看驚群,就換一個新網址用 hey -n 100 -c 100 打,再數一次 origin.log → 在 location 裡加上下面的指令,docker rm -f cdn-mini-edge 之後重跑第 2 步,再對照一次。
常用指令:
proxy_cache_valid 200 10s:200 的回應存 10 秒,也就是保存期限proxy_cache_key:快取鍵,預設連問號後面的參數都算proxy_cache_lock on:請求合併,同一個網址同時 MISS 只回源一次proxy_cache_use_stale updating加上proxy_cache_background_update on:過期先給舊的,背景再更新proxy_cache_use_stale error timeout http_500 http_502 http_503 http_504:源站掛掉或出錯時,先拿舊的頂著add_header X-Cache-Status $upstream_cache_status always:把 HIT、MISS、STALE 印在回應標頭上
反思:CDN 做的每一件事,都是在跟光速討價還價
技術面
把整篇拆過的技術,照「它到底省了什麼」重新排一次:
| 技術 | 跑短一點 | 少跑幾趟 | 少回源 |
|---|---|---|---|
| 邊緣節點、DNS 導航、Anycast | ✅ | ||
| 在邊緣做 TLS 握手、跟源站保持連線 | ✅ | ||
| HTTP/3(QUIC) | ✅ | ||
快取+Cache-Control | ✅ | ✅ | ✅ |
| 請求合併(request collapsing) | ✅ | ||
| 分層快取(tiered cache) | ✅ | ||
stale-while-revalidate、stale-if-error | ✅ | ✅ |
ETag 跟 304 不在表上,因為它省的是流量,前面量過了,來回一趟都沒少。
如果只能帶走一件事:判斷 CDN 有沒有幫上忙,看三個數字就好。你跟被分到的機房距離多遠、握手要跑幾趟、有多少請求得回源。
CDN 官網上的機房數量,只代表第一個數字「有機會」多小。你實際被分到哪裡,要自己量。
心態面
這個部落格放在 Cloudflare 上好幾年,我從來沒量過自己的讀者連到哪一間機房。
「CDN 會把使用者送到最近的節點」這句話,我一直當成理所當然,直到為了寫這篇跑了一次 /cdn-cgi/trace。CDN 官網上的機房地圖是平均值,你的讀者不是平均值。
還有一件更該檢討的事:HTTP/3 那段,我下午測完就差點寫成「這條網路把 UDP 443 擋掉了」。晚上多測一次,它就連上了,只是不穩。
只差那一次重測,這篇就會多一個講得很篤定的錯誤結論。
有趣發現
- 法蘭克福的量測點,被分到的是慕尼黑,不是法蘭克福。「最近」連在德國境內都不一定照地圖走。
cf-ray標頭最後那三個字母,Cloudflare 的文件說就是處理這個請求的機房。不用特地連/cdn-cgi/trace,隨便抓一個回應標頭,就知道自己被分到哪裡。- 我第一次想證明「1.1.1.1 不送 ECS」,用的是 Akamai 的除錯網域
whoami.ds.akahelp.net,結果它回報 1.1.1.1 送了,我還以為抓到什麼。翻了官方 FAQ 才知道,全世界就這一個網域是例外,專門開給跨廠商除錯用。
說到底,CDN 從來不是在加速,它只是想盡辦法讓你少跑幾趟。