HTTP 的快取為什麼這麼設計?

語言: CN / TW / HK

作為前端開發,快取是整天接觸的概念,面試必問、工作中也頻繁接觸到,可能大家對快取的 header 記的比較熟了,可是大家有沒有思考過為什麼 HTTP 的快取控制要這麼設計呢?

首先,為什麼要有快取?

網頁中的程式碼和資源都是從伺服器下載的,如果伺服器和使用者的瀏覽器離得比較遠,那下載過程會比較耗時,網頁開啟也就比較慢。下次再訪問這個網頁的時候,又要重新再下載一次,如果資源沒有啥變動的話,那這樣的重新下載就很沒必要。所以,HTTP 設計了快取的功能,可以把下載的資源儲存起來,再開啟網頁的時候直接讀快取,速度自然會快很多。

而且,每個請求都要服務端做相應的處理,比如解析 url,讀取檔案,返回響應等,而伺服器能同時處理的請求是有上限的,也就是負載是有上限的,所以如果能通過快取減少沒必要的資源的請求,就能解放伺服器,讓它去處理一些更有意義的請求。

綜上,為了提高網頁開啟速度,降低伺服器的負擔,HTTP 設計了快取的功能。

那 HTTP 是怎麼設計的快取功能呢?

如果讓大家設計 HTTP 的快取功能,大家會怎麼設計呢?

最容易想到的就是指定一個時間點了,到這個時間之前都直接用快取,過期之後才去下載新的。

HTTP 1.0 的時候也是這麼設計的,也就是 Expires 的 header,它可以指定資源過期時間,到這個時間之前不去請求伺服器,直接拿上次下載好被快取起來的內容,

Expires: Wed, 21 Oct 2021 07:28:00 GMT

但是這種設計有個 bug,不知道大家能猜出來不。

首先這個時間是指 GMT 時間,也就是會轉化為格林尼治那個時區的時間,不存在時區問題。

服務端會把當地的時間轉化為 GMT 時間,比如當前是某個時間點 xxx,想快取的時間為 yyy,那 Expires 就設定為 xxx + yyy 的時間。

如果瀏覽器的時間是準確的,那轉化為 GMT 時間後應該也是 xxx,所以快取的時間就是 yyy。

這是理想中的情況。

但萬一瀏覽器的時間不準呢?轉化為 GMT 時間之後就不是 xxx,那具體快取的時間也就不是 yyy 了,這就是問題。

所以,這個過期時間不能讓服務端來算,應該讓瀏覽器自己算。

這也是為什麼在 HTTP 1.1 裡面改為了 max-age 的方式:

Cache-Control: max-age=600

上面就代表資源快取 600 秒,也就是 10 分鐘。

讓瀏覽器來自己算啥時候過期,也就沒有 Expires 的問題了。(這也是為什麼同時存在 max-age 和 Expires 會用 max-age 的原因)

當然,不同的資源會有不同的 max-age,比如開啟 b 站首頁你會看到不同資源的 max-age 是不同的:

比如一些庫的 js 檔案就設定了 31536000,也就是 1 年後過期,因為一般也不會變,以年為單位沒啥問題。

而業務的 js 檔案設定了 600,也就是 10 分鐘過期,業務程式碼經常會變動嘛。

細心的同學可能會發現之前都是 key: value 形式的 header,現在咋變成了 key: k1=v1,k2=v2 的形式了呢?

沒錯,這也是 HTTP 1.1 做的設計,他們想把快取相關的 header 都集中到一起,所以就包了一層,都放在 Cache-Control 的 header 裡。

所以名字上也就不一樣了,Expires: xxx 這種叫做訊息頭(header),而 Cache-Control: max-age=xxx 裡面的 max-age 叫做指令(directive)。

好了,改成 max-age 之後,瀏覽器就會在本地計算出的過期時間就去下載新的資源了。

但是這樣就行了麼?

只是到了過期時間,但是資源並不一定有變化呀,那再下載一次同樣的內容還是很沒必要。

所以要和服務端確認下是否內容真的變了,變了的話就重新下載,否則的話就不用再下載了,有這樣一個協商的過程。

所以 HTTP 1.1 又設計了協商快取的 header。

我們說到資源過期了,瀏覽器要和服務端確認下是否有更新,怎麼判斷資源過期呢?

比較容易想到可以通過檔案內容的 hash,也可以通過最後修改時間,這倆分別叫 Etag 和 Last-Modified:

服務端返回資源的時候就會帶上這倆 header。

那在 max-age 時間到了的時候,就可以帶上 etag 和 last-modified 就請求伺服器,問下是否資源有更新。

帶上 etag 的 header 叫做 If-None-Match:

If-None-Match: "bfc13a64729c4290ef5b2c2730249c88ca92d82d"

帶上 last-modified 時間的叫做 If-Modified-Since:

If-Modified-Since: Wed, 21 Oct 2015 07:28:00 GMT

服務端判斷下如果資源有變化,那就返回 200,並在響應體帶上新的內容,瀏覽器就用這份新下載的資源。

如果沒有變化,那就返回 304,響應體是空的,瀏覽器會直接讀快取。

這樣多了一個協商的階段,那在本地快取過期但是服務端改資源沒有變化的時候就能避免重複的下載。

那如果檔案確定不會變,不需要協商的話,怎麼告訴瀏覽器呢?可以用 immutable 的 header 來告訴瀏覽器,這個資源就是不變的,不用協商了。這樣就算快取過期了也不會發驗證的 header(If-None-Match 和 If-Modified-Since):

Cache-control: immutable

我們前面講了 HTTP 1.1 改成了 Cache-control: k1=v1,k2=v2 的形式,那除了 max-age 還有啥其他的 directive 呢?

前面我們講的都是瀏覽器的快取控制,但請求從瀏覽器到伺服器的過程中,中間可能經過很多層代理。

代理伺服器的快取怎麼控制?

瀏覽器裡的快取都是使用者自己的,叫做私有快取,而代理伺服器上的快取大家都可以訪問,叫做公有快取。

如果這個資源只想瀏覽器裡快取,不想代理伺服器上快取,那就設定 private,否則設定 public:

比如這樣設定就是資源可以在代理伺服器快取,快取時間一年(代理伺服器的 max-age 用 s-maxage 設定),瀏覽器裡快取時間 10 分鐘:

Cache-control:public, max-age=600,s-maxage:31536000

這樣設定就是隻有瀏覽器可以快取:

Cache-control: private, max-age=31536000

而且,快取過期了就完全不能用了麼?

不是的,其實也想用過期的資源也是可以的,有這樣的指令:

Cache-control: max-stale=600

stale 是不新鮮的意思。請求裡帶上 max-stale 設定 600s,也就是說過期 10 分鐘的話還是可以用的,但是再長就不行了。

Cache-control: stale-while-revalidate=600

也可以設定 stale-while-revalidate,也就是說在和瀏覽器協商還沒結束的時候,就先用著過期的快取吧。

Cache-control: stale-if-error=600

或者設定 stale-if-error,也就是說協商失敗了的話,也先用著過期的快取吧。

所以說,max-age 的過期時間也不是完全強制的,是可以允許過期一段時間的。

那如果我想強制在快取還沒協商完的時候不用過期的快取怎麼辦呢?

用這個指令 must-revalidate:

Cache-Control: max-age=31536000, must-revalidate

名字上就可以看出來,就是快取失效了的話,必須等驗證結束,中間不能用過期的快取。

可能有的同學會有疑問,快取不都是自己設定的麼,咋還一個允許過期,一個禁止過期呢?

自己會同時用這兩種和自己玩麼?

自己肯定不會,但是 CDN 廠商可能會呀,想禁止這種用過期快取的行為,就可以設定這個 must-revalidate 指令。

最後,HTTP 當然也支援禁止快取,也就是這樣:

Cache-control: no-store

設定了 no-store 的指令就不會快取檔案了,也就沒有過期時間和之後的協商過程。

如果允許快取,但是需要每次都協商下的話就用 no-cache:

Cache-control: no-store

可能有的同學對 no-cache 和 must-revalidate 的區別比較迷糊,我們理一下:

no-cache 相當於禁掉了強快取,每次都要協商下,而 must-revalidate 只是在強快取過期之後,禁止掉了用過期的快取的過程,強制必須協商。

至此,http 的快取設定我們就講完了,來總結一下:

總結

快取能加快也面的開啟速度,也能減輕伺服器壓力,所以 HTTP 設計了快取機制。

HTTP 1.0 的時候是使用 Expires 的 header 來控制的,指定一個 GMT 的過期時間,但是當瀏覽器時間不準的時候就有問題了。

HTTP 1.1 的時候改為了 max-age 的方式來設定過期時間,讓瀏覽器自己計算。並且把所有的快取相關的控制都放到了 Cache-control 的 header 裡,像 max-age 等叫做指令。

快取過期後,HTTP 1.1 還設計了個協商階段,會分別通過 If-None-Match 和 If-Modified-Since 的 header 帶資源的 Etag 和 Last-Modied 到服務端問下是否過期了,過期了的話就返回 200 帶上新的內容,否則返回 304,讓瀏覽器拿快取。

除了 max-age 的指令外,我們還學了這些指令:

  • public: 允許代理伺服器快取資源。
  • s-maxage: 代理伺服器的資源過期時間。
  • private: 不允許代理伺服器快取資源,只有瀏覽器可以快取。
  • immutable: 就算過期了也不用協商,資源就是不變的。
  • max-stale: 過期了一段時間的話,資源也能用。
  • stale-while-revalidate: 在驗證(協商)期間,返回過期的資源。
  • stale-if-error: 驗證(協商)出錯的話,返回過期的資源。
  • must-revalidate: 不允許過期了還用過期資源,必須等協商結束。
  • no-store: 禁止快取和協商。
  • no-cache: 允許快取,但每次都要協商。

雖然 HTTP 快取相關的指令還是挺多的,但是都是圍繞 max-age 和過期後的協商來設計的,思路理清的話,還是很容易就能記住的。