[譯] 寫給大家看的 Cache-Control 指令配置

磊仔發表於2019-04-01

最好的網路請求就是無須與伺服器通訊的請求:在網站速度為王的比試裡,避免網路完勝於使用網路。為此,使用一個可靠的快取策略會給你的訪客帶來完全不同的體驗。

話雖如此,在工作中我越來越頻繁地看到很多實踐機會被無意識地錯過,甚至完全忽視做快取這件事。大概是因為過度聚焦於首次訪問,也可能單純是因為意識和知識的匱乏。不管是為什麼,我們有必要做一點相關知識的複習。

Cache-Control

管理靜態資源快取最常見且有效的方式之一就是使用 Cache-Control HTTP 報頭。這個報頭獨立應用於每一個資源,這意味著我們頁面中的一切都可以擁有一個非常定製化、顆粒化的快取政策。我們由此可以得到大量的控制權,得以制定異常複雜而強大的快取策略。

一個 Cache-Control 報頭可能是這樣的:

Cache-Control: public, max-age=31536000
複製程式碼

Cache-Control 就是報頭欄位名,publicmax-age=31536000指令Cache-Control 報頭可以接受一個或多個指令,我在本文中想要講的正是這些指令的真正含義和他們的最佳使用場景。

publicprivate

public 意味著包括 CDN、代理伺服器之類的任何快取都可以儲存響應的副本。public 指令經常是冗餘的,因為其他指令的存在(例如 max-age)已經隱式表示響應是可以快取的。

相比之下,private 是一個顯式指令,表示只有響應的最終接收方(客戶端或瀏覽器)可以快取檔案。雖然 private 本身並不具備安全功能,但它意在有效防止公共快取(如 cdn)儲存包含使用者個人資訊的響應。

max-age

max-age 定義了一個確保響應被視為“新鮮”的時間單位(相對於請求時間,以秒計)。

Cache-Control: max-age=60
複製程式碼

可在接下來的 60 秒快取和重用響應。

這個 Cache-Control 報頭告訴瀏覽器可以在接下來的 60 秒內從快取中使用這個檔案而不必擔心是否需要重新驗證。60 秒後,瀏覽器將回訪伺服器以重新驗證該檔案。

如果有了一個新檔案供瀏覽器下載,伺服器會返回 200,瀏覽器下載新檔案,舊檔案也會從 HTTP 快取中被剔除,新的檔案會接替它,並應用新快取報頭。

如果並沒有新的副本供下載,伺服器會返回 304,不需要下載新檔案,使用新的報頭來更新快取副本。也就是說如果 Cache-Control: max-age=60 報頭依然存在,快取檔案的 60 秒會重新開始。這個檔案的總快取時間是 120 秒。

注意:max-age 本身有一個巨坑,它告訴瀏覽器相關資源已經過期,但沒有告訴這個過期版本絕對不能使用。瀏覽器可能使用它自己的機制來決定是否在不經驗證的情況下釋放檔案的過期副本。這種行為有些不確定性,想確切知道瀏覽器會怎麼做有點困難。為此,我們有一系列更為明確的指令,用來增強 max-age,感謝 Andy Davies 幫我澄清了這一點。

s-maxage

s-maxage(注意 max 和 age 之間沒有 -)會覆蓋 max-age 指令,但只在公共快取中生效。max-ages-maxage 結合使用可以讓你針對私有快取和公共快取(例如代理、CDN)分別設定不同的重新整理時間。

no-store

Cache-Control: no-store
複製程式碼

如果我們不想快取檔案呢?如果檔案包含敏感資訊怎麼辦?比如一個包含你銀行賬戶資訊的 HTML 頁面,或者是有時效性的資訊?再或者是個包含實時股價的頁面?我們根本不想從快取中儲存或釋放響應:我們想要的是丟掉敏感資訊,獲取最新的實時資訊。這時候我們需要使用 no-store

no-store 是一個非常高優先順序的指令,表示不會將任何資訊持久化到任何快取中,無論是私有與否。任何帶有 no-store 指令的資源都將始終命中網路,沒有例外。

no-cache

Cache-Control: no-cache
複製程式碼

這點多數人都會困惑...... no-cache 並不意味著 “no cache”。它意味著“在你和伺服器驗證過並且伺服器告訴你可以使用快取的副本之前,你能使用快取中的副本”。沒錯,聽起來應該叫 must-revalidate!不過其實也沒聽起來這麼簡單。

事實上 no-cache 一個可以確保內容最新鮮的非常智慧的方式,同時也可以儘可能使用更快的快取副本。no-cache 總是會命中網路,因為在釋放瀏覽器的快取副本(除非伺服器的響應的檔案已更新)之前,它必須與伺服器重新驗證,不過如果伺服器響應允許使用快取副本,網路只會傳輸檔案報頭:檔案主體可以從快取中獲取,而不必重新下載。

所以如我所言,這是一個兼顧檔案新鮮度與從快取中獲取檔案可能性的智慧方式,缺點是它至少會為了一個 HTTP 報頭響應而觸發網路。

no-cache 一個很好的使用場景就是動態 HTML 頁面獲取。想想一個新聞網站的首頁:既不是實時的,也不包含任何敏感資訊,但理想情況下我們希望頁面始終顯示最新的內容。我們可以使用 cache-control: no-cache 來讓瀏覽器首先回訪伺服器檢查,如果伺服器沒有更新鮮的內容提供(304),那我們就重用快取的版本。如果伺服器有更新鮮的內容,它會返回(200)並且傳送最新的檔案。

提示:max-age 指令和 no-cache 指令一起傳送是沒用的,因為重新驗證的時間限制是零秒。

must-revalidate

更令人困惑的是,雖然上一個指令說應該叫 must-revalidate,但事實上 must-revalidate 依然是不同的東西。(這次更類似一些)

Cache-Control: must-revalidate, max-age=600
複製程式碼

must-revalidate 需要一個關聯的 max-age 指令;上文我們把它設定為 10 分鐘。

如果說 no-cache 會立即向伺服器驗證,經過允許後才能使用快取的副本,那麼 must-revalidate 更像是一個具有寬期限的 no-cache。情況是這樣的,在最初的十分鐘瀏覽器不會(我知道,我知道......)向伺服器重新驗證,但是就在十分鐘過去的那一刻,它又到伺服器去請求,如果伺服器沒什麼新東西,它會返回 304 並且新的 Cache-Control 報頭應用於快取的檔案 —— 我們的十分鐘再次開始。如果十分鐘後伺服器上有了一個新的檔案,我們會得到 200 的響應和它的報文,那麼本地快取就會被更新。

must-revalidate 一個很適合的場景就是部落格(比如我這個部落格):靜態頁面很少更改。當然,最新的內容是可以獲取的,但考慮到我的網站很少更改,我們不需要 no-cache 這麼下重手的東西。相反,我們會假設在十分鐘內一切都好,之後再重新驗證。

proxy-revalidate

s-maxage 一脈相承,proxy-revalidate 是公共快取版的 must-revalidate。它被私有快取簡單地忽略掉了。

immutable

immutable 是一個非常新而且整潔的指令,它可以把更多有關我們所送出檔案型別的資訊告知瀏覽器 —— 檔案內容是可變或者不可變嗎?瞭解 immutable 是什麼之前,我們先看看它要解決什麼問題:

使用者重新整理會導致瀏覽器強制驗證一個檔案而不論檔案新鮮與否,因為使用者重新整理往往意味著發生了這兩件事之一:

  1. 頁面崩潰之類的;
  2. 內容看起來已經過期了......

......所以我們要檢查一下伺服器上是否有更加新鮮的內容。

如果伺服器上有一個更新鮮的內容可用,我們當然想下載它。這樣我們將得到一個 200 響應,一個新檔案,並且 —— 希望是 —— 問題已經修復了。而如果伺服器上沒有新檔案,我們將返回 304 報頭,沒有新檔案,只有整個往返請求的延遲。如果我們重新驗證了大量檔案且都返回 304,這會增加數百毫秒的不必要開銷。

immutable 就是一種告訴瀏覽器一個檔案永遠都不會改變的方法 —— 它是不可變的 —— 因此不要再費心重新驗證它。我們可以完全減去造成延遲的往返開銷。那我們說的一個可變或不可變的檔案是什麼意思呢?

  • style.css:當我們更改檔案內容時,我們不會更改其名稱。這個檔案始終存在,其內容始終可以更改。這個檔案就是可變的。
  • style.ae3f66.css:這個檔案是唯一的 —— 它的命名攜帶了基於檔案內容的指紋,所以每當檔案修改我們都會得到一個全新的檔案。這個檔案就是不可變的。

我們會在 Cache Busting 部分詳細討論這個問題。

如果我們能夠以某種方式告訴瀏覽器我們的檔案是不可變的 —— 檔案內容永遠不會改變 —— 那麼我們也可以讓瀏覽器知道它不必檢查更新版本:永遠不會有新的版本,因為一旦內容改變,它就不存在了。

這正是 immutable 指令所做的事情:

Cache-Control: max-age=31536000, immutable
複製程式碼

在支援 immutable 的瀏覽器中,只要沒超過 31,536,000 秒的新鮮壽命,使用者重新整理也不會造成重新驗證。這意味著避免了響應 304 的往返請求,這可能會節約我們在關鍵路徑上(CSS blocks rendering)的大量延遲。在高延遲的場景裡,這種節約是可感知的。

注意:千萬不要給任何非不可變檔案應用 immutable。你還應該有一個非常周全的快取破壞策略,以防無意中將不可變檔案強快取。

stale-while-revalidate

我真的真的希望 stale-while-revalidate 能獲得更好的支援。

關於重新驗證我們已經講了很多了:瀏覽器啟程返回伺服器以檢查是否有新檔案可用的過程。在高延遲的場景裡,重新驗證的過程是可以被感知的,並且在伺服器迴應我們可以釋出一個快取的副本(304)或者下載一個新檔案(200)之前,這段時間簡直就是死時間。

stale-while-revalidate 提供的是一個寬限期(由我們設定),當我們檢查新版本時,允許瀏覽器在這段寬限期期間使用過期的(舊的)資源。

Cache-Control: max-age=31536000, stale-while-revalidate=86400
複製程式碼

這就告訴瀏覽器,“這個檔案還可以用一年,但一年過後,額外給你一天你可以繼續使用舊資源,直到你在後臺重新驗證了它”。

對於非關鍵資源來說 stale-while-revalidate 是一個很棒的指令,我們當然想要更新鮮的版本,但我們知道在我們檢查更新的時候,如果我們依然使用舊資源不會有任何問題。

stale-if-error

stale-while-revalidate 類似的方式,如果重新驗證資源時返回了 5xx 之類的錯誤,stale-if-error 會給瀏覽器一個使用舊的響應的寬限期。

Cache-Control: max-age=2419200, stale-if-error=86400
複製程式碼

這裡我們讓快取的有效期為 28 天(2,419,200 秒),過後如果我們遇到內部錯誤就額外提供一天(86,400 秒),此間允許訪問舊版本資源。

no-transform

no-transform 和儲存、服務、重新驗證新鮮度之間沒有任何關係,但它會告訴中間代理不得對該資源進行任何更改或轉換

中間代理更改響應的一個常見情況是電信提供商代表開發者使用者做優化:電信提供商可能會通過他們的堆疊代理圖片請求,並且在他們行動網路傳遞給終端使用者前做一些優化。

這裡的問題是開發人員開始失去對資源展現的控制,而電信服務商所做的影象優化可能過於激進甚至不可接受,或者可能我們已經將影象優化到了理想程度,任何進一步的優化都沒必要。

這裡,我們是想要告訴中間商:不要轉換我們的內容。

Cache-Control: no-transform
複製程式碼

no-transform 可以與其他任何報頭搭配使用,且不依賴其他指令獨立執行。

當心:有的轉換是很好的主意:CDN 為使用者選擇 Gzip 或 Brotli 編碼,看是需要前者還是可以使用後者;圖片轉換服務自動轉成 WebP 等。

當心:如果你是通過 HTTPS 執行,中介軟體和代理無論如何都不能改變你的資料,因此 no-transform 也就沒用了。

Cache Busting

講快取而不講快取破壞(Cache Busting)是不負責任的。我總是建議甚至在考慮快取策略之前就先要解決快取破壞策略。反過來做就是自找麻煩了。

快取破壞解決這樣的問題:“我只是告訴過瀏覽器在接下來的一年使用這個檔案,但後來我改動了它,我不想讓使用者拿到新副本之前要等一整年!我該怎麼做?!”

無快取破壞 —— style.css

這是最不建議做的事情:完全沒有任何快取破壞。這是一個可變的檔案,我們真的很難破壞快取。

快取這樣的檔案你要非常謹慎,因為一旦在使用者的裝置上,我們就幾乎失去了對他們的所有控制。

儘管這個例子是一個樣式表,HTML 頁面也純屬這個陣營。我們不能更改一個網頁的檔名,想象一下這破壞力!—— 這正是我們傾向於從不快取它們的原因。

查詢字串 —— style.css?v=1.2.14

這裡依然是一個可變的檔案,但是我們在檔案路徑後加了個查詢字串。聊勝於無,但不盡完美。如果有什麼東西把查詢字串刪掉了,我們就完全回到了之前講的沒有快取破壞的樣子。很多代理伺服器和 CDN 都不會快取查詢字串,無論是通過配置(例如 Cloudflare 官方文件寫到:“......從快取服務請求時,‘style.css?something’將會被標準化成‘style.css’”)還是防禦性忽略(查詢字串可能包含請求特定響應的資訊)。

指紋 —— style.ae3f66.css

新增指紋是目前破壞檔案快取的首選方法。每次內容變更,檔名都會隨之修改,嚴格地講我們什麼都不快取:我們拿到的是一個全新的檔案!這很穩健,並且允許你使用 immutable。如果你能在你的靜態資源上實現這個,那就去幹!一旦你成功實現了這種非常可靠的快取破壞策略,你就可以使用最極致的快取形式:

Cache-Control: max-age=31536000, immutable
複製程式碼

實施細節

這種方法的要點就是更改檔名,但它不非得是指紋。下面的例子都有同樣的效果:

  1. /assets/style.ae3f66.css:通過檔案內容的 hash 破壞。
  2. /assets/style.1.2.14.css:通過發行版本號破壞。
  3. /assets/1.2.14/style.css:改變 URL 中的目錄。

然而,最後一個示例意味著我們要對每個版本進行版本控制,而不是獨立檔案。這反過來意味著如果我們只想對我們的樣式表做快取破壞,我們也不得不破壞了這個版本的所有靜態檔案。這可能有點浪費,所以推薦選項(1)或(2)。

Clear-Site-Data

快取很難失效 —— 這是聞名於電腦科學界的難題 —— 於是有了一個實現中的規範,這可以幫助開發者明確地一次性清理網站域的全部快取:Clear-Site-Data

本文我不想深入探究 Clear-Site-Data,畢竟它不是一種 Cache-Control 指令,事實上它是一個全新的 HTTP 報頭。

Clear-Site-Data: "cache"
複製程式碼

給你的域下任何一個靜態檔案應用這個報頭,就會清除整個域的快取,而不僅是它附著的這個檔案。也就是說,如果你需要給你整個網站的所有訪客的快取來個大掃除,你只需把上面這個報頭加到你的 HTML 上即可。

瀏覽器支援方面,截止到本文寫作只支援 Chrome、Android Webview、Firefox 和 Opera。

提示:Clear-Site-Data 可以接收很多指令:"cookies""storage""executionContexts""*"(顯然,意思是“上述全部”)。

栗子及其食用方法

Okay,讓我們看一些場景,以及我們可能使用的 Cache-Control 報頭的型別。

線上銀行網頁

線上銀行之類的應用頁面羅列著你最近交易清單、當前餘額和一些敏感的銀行賬戶資訊,它們都要求實時更新(想象一下,當你看到頁面裡羅列的賬戶餘額還是一週前的你啥感覺!)而且要求嚴格保密(你肯定不想把你的銀行賬戶詳情存在共享快取裡(啥快取都不好吧))。

為此,我們這樣做:

Request URL: /account/
Cache-Control: no-store
複製程式碼

根據規範,這足以防止瀏覽器在所有私有快取和共享快取中把響應持久化到磁碟中:

no-store 響應指令要求快取中不得儲存任何關於客戶端請求和服務端響應的內容。該指令適用於私有快取和共享快取。上文中“不得儲存”的意思是快取不得故意將資訊儲存到非易失性儲存器中,並且在接轉後必須盡最大努力盡快從易失性儲存器中刪除資訊。

但如果你還不放心,也許你可以選擇這樣:

Request URL: /account/
Cache-Control: private, no-cache, no-store
複製程式碼

這將明確指示不得在公共快取(例如 CDN)中儲存任何資訊、始終提供最新的副本並且不要持久化任何東西。

實時列車時刻表頁面

如果我們打算做一個顯示準實時資訊的頁面,我們要儘可能保證使用者總是看到最準確的、最實時的資訊,我們使用:

Request URL: /live-updates/
Cache-Control: no-cache
複製程式碼

這個簡單的指令會讓瀏覽器不直接未經伺服器驗證通過就從快取顯示響應。這意味著使用者將絕不會看到過期的資訊,而如果伺服器上有最新資訊與快取中的相同,他們也會享受從快取中抓取檔案的好處。

這幾乎對所有網站來說都是一個明智的選擇:儘可能給我們最新的內容,同時儘可能讓我們享受快取帶來的訪問速度。

FAQ 頁面

像 FAQ 這樣的頁面可能很少更新,而且其內容不太可能對時間敏感。它當然沒有實時運動成績或航班狀態那麼重要。我們可以將這樣的 HTML 頁面快取一段時間,並強制瀏覽器定期檢查新內容,而不用每次訪問都檢查。我們這樣設定:

Request URL: /faqs/
Cache-Control: max-age=604800, must-revalidate
複製程式碼

這會允許瀏覽器快取 HTML 頁面 一週時間(604,800 秒),一旦一週過去,我們需要向伺服器檢查更新。

當心:給同一個網站的不同頁面應用不同的快取策略會造成一個問題,在你設定 no-cache 的首頁會請求它引用的最新的 style.f4fa2b.css,而在你的加了三天快取的 FAQ 頁依然指向 style.ae3f66.css。這種情況可能影響不大,但不容忽視。

靜態 JS(或 CSS)App Bundle

比方說們的 app.[fingerprint].js,更新非常頻繁 —— 幾乎每次釋出版本都會更新 —— 而我們也投入了工作,在檔案每次更改時對其新增指紋,然後這樣使用:

Request URL: /static/app.1be87a.js
Cache-Control: max-age=31536000, immutable
複製程式碼

無所謂我們有多頻繁的更新 JS:因為我們可以做到可靠的快取破壞,我們想快取多久就快取多久。這個例子裡我們設定成一年。之所以是一年首先是因為這已經很久了,而且瀏覽器無論如何也不可能把一個檔案儲存這麼久(瀏覽器用於 HTTP 快取的儲存空間是限量的,他們會定期清空一部分;使用者也可能自己清空快取)。超過一年的配置大概率沒什麼用。

進一步講,因為這個檔案內容永不改變,我們可以指示瀏覽器這個檔案是不可變的。一整年內我們都無須重新驗證它,哪怕使用者重新整理頁面都不需要。這樣我們不僅獲得了使用快取的速度優勢,還避免了重新驗證造成的延遲弊端。

裝飾性圖片

想象一個伴隨文章的純裝飾性照片。它不是資訊圖表,也不含影響頁面其他部分閱讀的關鍵內容。甚至如果它完全不見了使用者都關注不到。

圖片往往是要下載的重量級資源,所以我們想要快取它;因為它在頁面中沒有那麼關鍵,所以我們不需要下載最新版本;我們甚至可以在這張照片過時一點後繼續使用。看看怎麼做:

Request URL: /content/masthead.jpg
Cache-Control: max-age=2419200, must-revalidate, stale-while-revalidate=86400
複製程式碼

這裡我們告訴瀏覽器快取 28 天(2,419,200 秒),28 天期限過後我們想向伺服器檢查更新,如果圖片沒有超過一天(86,400 秒)的過期時間,那麼我們就在後臺請求到最新版本後再替換它。

要牢記的要點

  • 快取破壞極其極其極其重要。開始做快取策略之前,先解決好快取破壞策略。
  • 一般來說,快取 HTML 內容是個餿主意。HTML URL 不能被破壞,畢竟 HTML 頁往往是訪問頁面其他子資源的入口點,你會把通往靜態檔案的引用宣告也快取下來。這會讓你(和你的使用者)......一言難盡。
  • 快取 HTML 時,如果一類頁面從不快取而其他類頁面有時要用快取,這種同站不同型別的 HTML 頁的不同快取策略會導致不一致性。
  • 如果你能夠給你的靜態資源可靠地做快取破壞(使用指紋),那你最好一次性把所有的東西都快取好幾年,以求最優。
  • 非關鍵內容可以用 stale-while-revalidate 之類的指令給一個不新鮮寬限期。
  • immutablestale-while-revalidate 不僅能帶來快取的傳統效益,還讓我們在重新驗證時降低延遲成本。

儘可能避免使用網路會為使用者提供更快的體驗(也會給我們的基礎設施更低的吞吐量,兩開花)。通過對資源的詳細瞭解和可用內容的總覽,我們可以開始針對我們的應用設計做一個顆粒化、定製化且有效的快取策略。

快取在手,一切盡在掌控。

參考文獻和相關閱讀

依吾言行事,勿觀吾行仿之

在某人因我的言行不類開噴之前,有必要一提的是我自己部落格的快取策略這麼差強人意,以至於我自己都看不下去了。

如果發現譯文存在錯誤或其他需要改進的地方,歡迎到 掘金翻譯計劃 對譯文進行修改並 PR,也可獲得相應獎勵積分。文章開頭的 本文永久連結 即為本文在 GitHub 上的 MarkDown 連結。


掘金翻譯計劃 是一個翻譯優質網際網路技術文章的社群,文章來源為 掘金 上的英文分享文章。內容覆蓋 AndroidiOS前端後端區塊鏈產品設計人工智慧等領域,想要檢視更多優質譯文請持續關注 掘金翻譯計劃官方微博知乎專欄

相關文章