最便宜的請求是不發請求
上一篇留給你一個赤裸裸的事實:距離與來回次數主宰了網頁感覺有多快,而你打不過光速。如果一個頁面需要八十個小檔案,每一個都要向一台隔著一片海洋的伺服器付出一趟來回,那無論你的鏈路多粗,這頁都很慢——記得延遲、吞吐量與頻寬是三件不同的事,加頻寬對「等待」毫無幫助。所以整個網頁上最強的招數,不是送得更快,而是根本不送。如果你的瀏覽器手上已經握著昨天那個 logo 的一份好端端的副本,最快的回應就是它自己就能回答的那一個——零毫秒,連網路都不必碰。
這就是快取:把某樣東西的副本留在它被使用的地方附近,這樣你就能重用副本,而不必重抓原件。這個想法你在這座階梯上已經見過兩次了。網域名稱系統會快取名稱對位址的答案,讓重複查詢省去從根開始的那趟路。交換器會快取 MAC 位址住在哪裡,於是不再對每個訊框氾濫轉送。網頁快取就是同一個本能,套用在 HTTP 所載運的東西上——網頁、圖片、腳本、樣式表、影片區塊——而它正是本段位其餘一切的地基,包括你接下來會遇到的內容傳遞網路。
夠新鮮嗎?Cache-Control 與保存期限
一個快取若無法判斷自己的副本何時過期,就毫無用處,所以伺服器必須說清楚。它透過一個回應標頭來說。最關鍵的是 Cache-Control,而其中最重要的一塊是 max-age,也就是這份副本可以重用、不必回頭查問的秒數。一個帶著 Cache-Control: max-age=3600 的回應,等於伺服器在說:「這份東西一小時內有效;直接從你的快取送出去,這段時間內別煩我。」把它想成印在牛奶盒上的有效期限:只要日期還在未來,快取就直接倒牛奶,不用打電話問乳品廠。
當一份副本還在它的 max-age 之內,它就被稱為新鮮(fresh),而一個快取能用新鮮副本滿足的請求就是一次快取命中——不碰網路、沒有來回、瞬間完成。一旦秒數耗盡,這份副本就過期(stale)了:不見得是錯的,只是不能再單憑它自己被信任。此時快取必須先查清楚原件有沒有變過,才能重用。Cache-Control 也帶著開關:no-store 意思是「這個東西完全別留任何副本」(那個銀行餘額的正確答案),而 private 意思是「只有使用者自己的瀏覽器可以快取,中間那台共享代理不行」(任何個人化內容的正確答案)。
那個 304:免費重用一份過期副本
當一份副本過期,最直覺的動作是把它再下載一次——但那往往白費了整趟傳輸,因為十次裡有九次檔案其實根本沒變。HTTP 對這種情況有一個漂亮的捷徑。快取不是去要「把檔案送來」,而是去問「只有當這個檔案和我手上的副本不一樣時,才把它送來」。這就是一個條件式請求,而當答案是「沒變」時,伺服器就回一個小小的狀態碼——304 Not Modified——而且完全沒有內容主體。接著快取就送出它手上既有的那份副本。你只付了一趟小小的來回,而不是一次完整的重新下載。
快取要怎麼表達「我手上既有的那份副本」呢?用伺服器先前交給它的一個驗證子。強的那個是 ETag——一個不透明的標籤,常常是內容的雜湊,伺服器第一次送檔案時就附上(ETag: "9f3c1")。重新驗證時,快取把它放進 If-None-Match 標頭送回去:「我有版本 9f3c1,還是現行的嗎?」如果伺服器當前的 ETag 相符,它就回 304;如果不同,它就回 200 帶著新的內容主體和一個新的 ETag。較弱的驗證子是 Last-Modified 日期,透過 If-Modified-Since 回送,但大家偏好 ETag,因為內容可能在時間戳沒能乾淨交代清楚的情況下就變了。
First visit (cache is empty): GET /logo.png --> <-- 200 OK Cache-Control: max-age=86400 ETag: "9f3c1" [12 KB body] Next day (copy is now stale, ask: did it change?): GET /logo.png If-None-Match: "9f3c1" --> <-- 304 Not Modified (no body) ...cache serves its own 12 KB copy If it HAD changed: <-- 200 OK ETag: "a7d20" [new 12 KB body]
請注意這個誠實的限制:一個 304 仍然要付一趟來回。它省下了內容主體的位元組,對一個大檔案來說是巨大的節省,但它並不省下「去問」這件事的延遲。這就是為什麼,在你用得上的時候,一個遙遠未來的 max-age(也就是指紋化檔名那一招)勝過重新驗證:一次新鮮命中花零趟來回,而一個 304 花一趟。快取有兩種不同的節省——省下位元組,與省下那趟路——而只有一次新鮮的快取命中能讓你兩樣都拿到。
代理伺服器:誰來保管那份共享副本?
到目前為止,快取都住在你自己的瀏覽器裡,而它只幫得到你一個人。但驅動快取的那個假設——同樣的東西被一再索取——放在一整群人身上甚至更強。如果一間辦公室裡有一千個人都載入同一個新聞網站,何必每個人各自下載一次?在他們與更廣的網際網路之間的路徑上放一個共享快取,第一個請求把它填滿,接下來的九百九十九個就命中它。這個共享的中間盒子就是代理(proxy):一台站在用戶端與源伺服器之間的伺服器,轉送請求,並且很貼心地把經過的東西快取起來。
代理有兩種,差別在於它站在誰那一邊。一個正向代理坐在用戶端前面、代表用戶端行事——由辦公室或校園佈署,更廣的網路根本不知道它存在,從外面看就好像所有請求都來自同一個地方。一個反向代理坐在伺服器前面、代表伺服器行事——由網站營運者佈署,用戶端以為自己直接在跟網站對話,而在它背後,這個代理可以快取回應、把負載分散到許多後端機器上,並且替伺服器卸下 HTTPS 加密。中間都是同一台機器;標籤只是在說它代表的是哪一端。
把兩者想成同一個盒子、效忠的對象卻相反。正向的情況裡,用戶端指向代理,代理代表他們伸手進整個網際網路,所以是辦公室或校園佈署它,而網路看不見它。反向的情況裡,用戶端去找它們以為的網站,實際上碰到的是代理,代理再轉送給它背後的源伺服器,所以是網站佈署它,而用戶端相信代理就是那個網站。機械一模一樣;只有它代表的那一邊變了。
要牢牢記住的是反向代理,因為它正是下一篇的種子。一個會快取內容、坐得離使用者很近、由網站(或網站付費的服務)來經營的反向代理,恰恰就是一個邊緣(edge)快取。把上千個這樣的快取撒遍全球,每一個都在一座不同城市的附近持有某網站內容的副本,你就建成了網頁世界那一連串在地倉庫——而下一篇整篇講的就是它。你剛學到的關於 Cache-Control、ETag 和 304 的一切,正是這些倉庫為了保持有貨、保持最新所說的那套通訊協定。
把它湊起來,以及那些陷阱
讓我們把一張圖片走過整台機器一遍,從你的瀏覽器出發、到一個共享快取、再回來,好讓各個零件咬合就位。
- 你的瀏覽器需要 /logo.png。它先檢查自己的快取。如果它握著一份新鮮副本(還在 max-age 之內),就以零趟來回送出,故事到此結束——最便宜的結果。
- 沒有新鮮副本,於是它送出請求。請求在路上遇到一個共享快取(一台代理或一個 CDN 邊緣節點)。如果那個快取握著新鮮副本,它就立刻回答——你根本不必抵達那台遙遠的源伺服器。
- 共享快取只握著一份過期副本。它不會盲目地重新下載;它向源伺服器送出一個條件式請求,帶著 If-None-Match 與它存下的那個 ETag。
- 源伺服器比對 ETag。如果沒變,它回一個 304、沒有內容主體,快取就刷新它的計時器並送出它原本就有的副本。如果變了,它回 200 帶著新的位元組與一個新的 ETag,快取把它存下並轉送。
用兩個誠實的陷阱來收尾。第一,一個共享快取絕不能把某個使用者的私人資料交給另一個人——所以個人化或已登入的回應必須標上 private 或 no-store,否則你就會撞上那個經典又嚇人的臭蟲:某人看到了陌生人的帳戶頁面。Vary 標頭之所以存在,是出於同樣的理由,讓回應依照像語言或編碼這樣的條件被分開保存。第二,快取出了名地難以收回:一旦一份帶著長 max-age 的副本新鮮地散到了世界上,你無法伸手進上百萬個瀏覽器與上千個邊緣快取去刪掉它。它會一直被重用到過期為止。這正是為什麼指紋化檔名那一招這麼受寵——你不去讓舊副本失效,你只是發佈一個新網址,讓舊的那個原封不動地自然老去。