跳至主要內容
技術

我的部落格放在 CDN 上,從中華電信連過去卻被送到美國聖荷西:用 curl、dig、ping 拆開 CDN 的原理

我的部落格放在 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台北 TPE8–18 ms21–31 ms30–45 ms
bobochen.dev聖荷西 SJC133–137 ms272–293 ms406–428 ms

同一家 CDN,差了十倍。

這篇就從這個有點尷尬的數字出發,把 CDN 拆開來看。先把重點濃縮成一張表:

你可能以為其實是
CDN 就是把檔案複製到很多地方複製只是一半,另一半是把「握手」搬到你家附近,讓封包少跑幾趟
每間機房都有自己的 IP1.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 km20.9 ms41.5 ms
新加坡3,250 km32.5 ms89.1 ms
美國聖荷西10,424 km104.2 ms133.0 ms
美國奧勒岡10,007 km100.1 ms163.5 ms
美國維吉尼亞12,620 km126.2 ms199.2 ms
德國法蘭克福9,378 km93.8 ms230.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 ms2.375 ms
東京Oracle1.382 ms1.439 ms
法蘭克福Oracle0.933 ms0.982 ms
紐約DigitalOcean1.396 ms1.734 ms
聖保羅Oracle0.894 ms0.931 ms
雪梨Oracle1.338 ms1.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 ms415 ms
東京OracleNRT 東京成田2 ms31 ms
法蘭克福OracleMUC 慕尼黑6 ms44 ms
紐約DigitalOceanEWR 紐華克3 ms41 ms
聖保羅OracleEWR 紐華克112 ms405 ms
雪梨OracleSYD 雪梨1 ms27 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 的官方定義是:可以快取,但這次架上沒有,所以回源拿。

兩個檔案的保存期限,天差地遠:

首頁 HTMLCSS 檔
網址固定是 /檔名裡有一串亂碼 B7qiOdjG
max-age0 秒31,536,000 秒,也就是 365 天
另外的指令must-revalidate:過期了一定要回去問immutable:保證不會變,連問都不用問
這次的 cf-cache-statusMISSHIT

這是前端建置工具的標準招式。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 bytes0.460–0.511 秒
帶著 ETag 問,回 3040 bytes0.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=NN 秒內可以直接用保存期限 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、1002.56、2.20、2.07 秒3.11、3.05、2.17 秒
proxy_cache_lock on1、1、12.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 趟請求合計
直連源站199199199約 600 ms
經台北機房,快取沒中101010+199(機房到源站的連線還開著,回源只要一趟)約 230 ms
經台北機房,快取命中101010約 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/20.398 秒0.403 秒0.411 秒
HTTP/30.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 bytes100%
gzip46,078 bytes22.3%
Brotli(br)46,509 bytes22.5%
zstd49,792 bytes24.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>"':帶指紋去問,看會不會拿到 304
  • dig +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 從來不是在加速,它只是想盡辦法讓你少跑幾趟。

留言討論

esc
輸入關鍵字搜尋文章...
查看收藏 →