掌握 HTTP 快取——從請求到響應過程的一切(上)

鬍子大哈發表於2017-03-02

作者:Ulrich Kautz

編譯:鬍子大哈

翻譯原文:huziketang.com/blog/posts/…

英文原文:Mastering HTTP Caching - from request to response and everything

轉載請註明出處,保留原文連結以及作者資訊


CDN類的網站曾經一度雄踞 Alexa 域名排行的前 100。以前一些小網站不需要使用 CDN 或者根本負擔不起其價格,不過這一現象近幾年發生了很大的變化,CDN 市場上出現了很多按次付費,非公司性的提供商,這使得 CDN 變成人人都能負擔的起的一種服務了。本文講述的就是如何使用這種簡單易用的快取服務。

使用內容分發網路( CDN )你需要先正確地認識 HTTP 響應頭:和 HTTP 響應頭中的哪些標籤相關?它們是怎麼起作用的?如何使用它們?文章中我會回答這些問題。

本文講的並不會像教科書那麼精確,實際上在某些情況下,為了敘述的清晰、簡潔,我會按自己的理解簡化某些問題,文章中會通過一些實際的例子來介紹快取理論。在這篇文章的基礎上,還會寫一些文章來介紹對於某些指定的 CMS 或框架如何使用 CDN 來作為快取層。

為什麼使用 CDN?

CDN 是一個全球分散式網路,它把網站內容更快地傳遞給全球範圍內的一個具體位置,而往往這個具體的位置離實際的內容伺服器距離很遠。舉個例子,你的網站主機在愛爾蘭,而你的使用者則在澳大利亞訪問。這時當你的使用者訪問你的網站的時候,延遲會很大,把你的(靜態)資料用 CDN 放到澳大利亞則會很大程度上提高使用者訪問網站的體驗。

然而 CDN 的使用並不侷限於此。其實 CDN 可以理解成一個普通快取,如代理快取(邊緣快取)。即便你並不關心使用者的具體地理位置,你也應該考慮使用 CDN 的代理快取來提高你的使用者體驗。

為什麼使用代理快取?

簡而言之,代理快取會快取你網站一些頁面,通過快取來傳輸“靜態”內容非常快。一個簡單的例子,假設你有一個帶有開始頁面的部落格,這裡面列出了所有近期的部落格列表。完成這一過程,PHP 指令碼要從資料庫中獲取到最近的文章實體,並且將它們轉換成 HTML 結果頁並返回給使用者。因此,對於一次請求(訪問)包含了:一次 PHP 執行 + 一組資料庫查詢。對於 1000 次請求(訪問)包含了:1000 次 PHP 執行 + 1000 組資料庫查詢。每一次 PHP 執行都要進行 CPU、記憶體和 I/O 操作,對於資料庫操作也是同樣。

請求的需求量和訪問使用者的多少呈線性正比關係。聽起來怎麼樣?不怎麼樣,因為這個線性關係是有最大限度的:磁碟最大隻能提供一定程度的 I/O,CPU 和記憶體也都不是無限的。這樣下去到了某個點,也就是說某個資源到了瓶頸的時候,就出現問題了:你的網站會訪問的非常慢,甚至會出現所有人都不能訪問的情況。其實這時其他資源並沒有被全部打滿。誠然,這時你可以擴充套件你的硬體規模來突破這一瓶頸,但是這將使工程變得很複雜,成本也更高。實際上還有更簡單、更便宜的解決方法。

在中間加一層代理快取,會減少資源對你的限制。拿前面的例子來講,使用代理快取只有第一次請求需要執行 PHP 指令碼、查詢資料庫和生成 HTML 結果頁。所有後面過來的請求都會從這個快取中取內容,讀取快取幾乎和直接讀取記憶體一樣快。這意味著,上面的線性規模瓶頸的問題解決了!100 個使用者或者1000 個使用者都沒關係,依然只有 1 次 PHP 執行、1 次資料庫查詢和 1 次的結果頁生成。

CDN != CDN

CDN 的型別也各有不同。網站管理者可能會好奇資料是怎麼儲存的?存放在哪?以及資料是如何分佈在 CDN 上的?是如何分發的呢?本文不是寫給網站管理者的而是寫給開發者的,所以在這我只能告訴你有“經典 CDN”和“對等 CDN”,後者是現在主流採用的方法。

對於開發者,相比於把資料拿到 CDN 以後做什麼來說,會對如何把資料放到 CDN 中更感興趣。說起來,有 push CDNpull CDN 兩種。顧名思義,“push CDN” 表示你要給 CDN 提供內容;“pull CDN” 表示如何從 CDN 取內容。

本文將主要介紹 pull CDN,因為在很多情況下 pull CDN 更加簡單易用,不需要費多大事就能整合到現有的網站中。

pull CDN 是如何起作用的?

我們來做個例子,假設你有一個可訪問的網站,URL 是 www.foobar.tld。在這樣的場景下,域名 www.foobar.tld 會被放到 pull CDN 伺服器中,而不是你的網站伺服器中。CDN 作為你網站伺服器的一個代理

還有一個不被公開的域名指向實際的網站伺服器。在這個例子中假設它是 direct.foobar.tld,實際網站伺服器叫做

這個 CDN 將會接受所有的請求。如果它的快取中有結果的話將會直接返回給使用者,否則會將這個請求託管給你實際的網站伺服器,然後把返回的結果快取起來為以後的請求做儲備,同時將結果返回給使用者。

掌握 HTTP 快取——從請求到響應過程的一切(上)

最簡單的 pull CDN 執行的過程如下:

  • 獲取一個頁面的請求,這個頁面:www.foobar.tld/some/page
  • 把 some/page 當做快取 key 檢查快取中是否存在
  • 在快取中則直接從快取中返回結果給使用者
  • 不在快取則請求 direct.foobar.tld/some/page,把… some/page 作為 key 寫入快取,並返回結果給使用者

靜態內容 VS 動態內容

上面的這一過程對於完全靜態的內容完全適用。靜態內容指的是如果使用者訪問同一個 URL 地址,返回的所有資料都是一樣的。比如 CSS 檔案就有這樣的特點,www.foobar.tld/public/css/… 這個檔案是一個普通檔案,對於所有訪問網站的使用者都是一樣的,那麼它就特別適合用快取存起來。

和靜態檔案相對的是動態檔案。內容在執行時才能確定,這種情況也是非常常見的。比如多語言問題,需要根據瀏覽器語言來返回內容。還有一些和 “user session” 相關的內容,比如當使用者登陸了以後,就要把“登陸”按鈕換成“退出”按鈕,你肯定不希望這個被快取。這些高度活躍的內容(如每小時或者更短時間更新的頁面)不能被快取,或者說不能在快取中停留時間過長。

這就是快取有意思的地方,理解和實現它並不難。

快取頭

絕大多數的 pull CDN 採用以“每頁”快取形式解決動態內容的問題。為了達到這樣的效果,一個簡單的方法是 HTPP 響應快取頭。

首先對於快取頭你需要知道有“舊版本”和“新版本”兩種,就是說它並不是一開始就設計成當前所使用的這個版本的,也有一個逐漸演變的過程。新版本指的是 HTTP/1.1,而舊版本指的是 HTTP/1.0。它有特別多的可選選項,每個人對這個問題都很頭疼。我認為這是大家不願意使用快取頭的最重要的原因。

言歸正傳,我們只關注 ETagCache-Control 這兩個標籤就足以了。大多數 CDN 還支援舊版本(ExpiresPragmaAge),不過這些只作為向後相容來使用。

ETag 頭

我們從最簡單的開始 ETag:它是文件版本的識別符號。通常是內容的 MD5 值,不過它也可以包含其他內容,代表的是文件的版本/日期,如: 1.0 或者 2017-02-27。這裡注意一點是,它必須用雙引號括起來,如:ETag: "d3b07384d113edec49eaa6238ad5ff00"

二次驗證

現在來考慮 ETag 的實際應用:二次驗證。我們暫時不考慮前面代理+源的架構模式,只考慮簡單的客戶端-伺服器模式。如下圖:

掌握 HTTP 快取——從請求到響應過程的一切(上)

假設客戶端請求了 www.foobar.tld/hello.txt,接…

# REQUEST
GET /hello.txt HTTP/1.1
Host: www.foobar.tld

# RESPONSE
HTTP/1.1 200 OK
Date: Sun, 05 Feb 2017 12:34:56 UTC
Server: Apache
Last-Modified: Sun, 05 Feb 2017 10:34:56 UTC
ETag: "8a75d48aaf3e72648a4e3747b713d730"
Content-Length: 8
Content-Type: text/plain; charset=UTF-8

the body複製程式碼

在響應裡面,有兩個有意思的頭標識:一個是 ETag,內容的 MD5值,一個是 Last-Modified,這是 hello.txt 檔案最後一次被修改的時間。

這裡就是二次驗證起作用的地方:當客戶端在很短的時間內再次訪問上面的 URL,客戶端瀏覽器會使用 If-* 請求頭。如 If-None-Match 檢查 ETag 的內容是否有改變。也就是說,如果 ETag 發生變化,客戶端接收到的一個完整的新響應;如果 ETag 沒變化,客戶端接收到的是一個表明內容沒變化的標識。

GET /hello.txt HTTP/1.1
If-None-Match: "8a75d48aaf3e72648a4e3747b713d730"
Host: www.foobar.tld複製程式碼

如果 ETag 沒有改變,那麼服務端將會返回:

HTTP/1.1 304 Not Modified
Date: Sun, 05 Feb 2017 12:34:57 UTC
Server: Apache
Last-Modified: Sun, 05 Feb 2017 10:34:56 UTC
ETag: "8a75d48aaf3e72648a4e3747b713d730"
Content-Length: 8
Content-Type: text/plain; charset=UTF-8複製程式碼

正如上面所展示的,這次伺服器的響應裡面不是 200 ok,而是304 Not Modified,這就是說它略過包體部分,讓客戶端直接去自己的快取裡拿資料。在這個例子中,包體內容是 the body,比較小,效果不明顯。可是想象一下如果是很大的內容呢,或者是很複雜的動態生成內容呢,價值就很大了。

作為一個開發者,你可能會想:“並沒有那麼好用嘛,我還不得不掌握 IF- 類的頭標識,比以前更費事了”。

別急,這只是介紹了共享快取,也就是代理快取的由來,我們看原始的架構:<客戶端-代理-源端>,代理根據自己的快取返回給客戶端 304 Not Modified,接下來的章節詳解介紹,介紹之前我要先講一下 Last-Modfied 頭。

在處理上面那個 hello.txt 靜態檔案的例子時,客戶端還可以使用 If-Not-Modified-Since: Sun, 05 Feb 2017 10:34:56 UTC 來達到同樣的效果(返回 304 響應)。這對於靜態檔案來說也很好用,因為響應頭中的 Last-Modified 標識是根據伺服器磁碟上的“更改時間戳”自動生成的。然而,“更改時間戳”對於動態檔案通常沒什麼用,因為動態生成檔案頻繁更新,時間戳很難確定。我們都知道,你最想快取起來的是內容,生成內容的代價是最大的,所以 ETag 頭是更好的選擇。

Cache-Control

Cache-Control 頭相對來講難一些。兩個原因:第一,Cache-Control 既可以用於請求頭,也可以用於響應頭。本文中著重討論響應頭,因為這是開發者所必須要掌握的。第二,它控制著兩個快取:本地快取(又稱私有快取)和共享快取。

本地快取,是指在客戶端本地機器中的快取。站在開發者的角度,它並不完全受你的控制,通常瀏覽器會自己決定是否把某些內容放到快取中,這意味著:不要依賴於本地快取。使用者也可能在關閉瀏覽器的時候清理所有快取,而你並不知道有這樣的操作。除非你監測到了某個使用者的流量不斷上漲,導致快取內容迅速失效,這時候你才會意識到。

共享快取,也就是本文所介紹的:處於客戶端和伺服器之間的快取。即 CDN。你對共享快取擁有絕對的控制,應該好好地利用它。

現在我們來用一些程式碼作為示例深入學習一下。

  1. Cache-Control: public max-age=3600
  2. Cache-Control: private immutable
  3. Cache-Control: no-cache
  4. Cache-Control: public max-age=3600 s-maxage=7200
  5. Cache-Control: public max-age=3600 proxy-revalidate

乍一看這些程式碼很令人困惑,但是不要擔心,它並沒有那麼難,我來一點點介紹。首先你要知道 Cache-Control 有三種屬性:緩衝能力、過期時間和二次驗證。

首先是緩衝能力,它關注的是快取到什麼地方,和是否應該被快取。他的幾個重要的屬性是:

  • private:表示它只應該存在本地快取;
  • public:表示它既可以存在共享快取,也可以被存在本地快取;
  • no-cache:表示不論是本地快取還是共享快取,在使用它以前必須用快取裡的值來重新驗證;
  • no-store:表示不允許被快取。

第二個是過期時間,很顯然它關注的是內容可以被快取多久。它的幾個重要的屬性是:

  • max-age=<seconds>:設定快取時間,設定單位為秒。本地快取和共享快取都可以;
  • s-maxage=<seconds>:覆蓋 max-age 屬性。只在共享快取中起作用。

最後一個是二次驗證,表示精細控制。它的幾個重要屬性是:

  • immutable:表示文件是不能更改的。
  • must-revalidate:表示客戶端(瀏覽器)必須檢查代理伺服器上是否存在,即使它已經本地快取了也要檢查。
  • proxy-revalidata:表示共享快取(CDN)必須要檢查源是否存在,即使已經有快取。

通過上面的具體解釋,現在再來描述上面 Cache-Control 的那段程式碼所表達的意思就好理解多了:

  1. 本地快取和 CDN 快取均快取 1 小時;
  2. 不能快取在 CDN,只能快取在本地。並且一旦被快取了,則不能被更新;
  3. 不能快取。如果一定要快取的話,確保對其進行了二次驗證;
  4. 本地快取 1 小時,CDN 上快取 2 小時;
  5. 本地和 CDN 均快取 1 小時。但是如果 CDN 收到請求,則儘管已經快取了 1 小時,還是要檢查源中文件是否已經被改變。

例項

理論會很單調乏味,現在用短的例項來演示如何自動注入 ETagCache-Control 頭。例子是一個 Apache 的 .htaccess 檔案,但是我希望你能夠領會要領,並且根據你自己的實際情況,應用到你自己的 Web 應用中。

# 為所有圖片設定 ETag,以及快取時間為 1 天
<FilesMatch "\.(gif|flv|jpg|jpeg|png|gif|swf)$">
    FileETag -INode MTime Size
    Header set Cache-Control "max-age=86400 public"
</FilesMatch>

# 為所有的 CSS 檔案、JS 檔案設定 ETag,以及快取時間為 2 小時,同時保證進行了二次驗證
<FilesMatch "\.(js|css)$">
    FileETag -INode MTime Size
    Header set Cache-Control "max-age=7200 public must-revalidate"
    Header unset Last-Modified
</FilesMatch>複製程式碼

上面例子,是一個對 URL:www.foobar.tld/baz.jpg 的響應。包含了一個 ETag 頭,由更改時間和檔案大小所構成,還有 Cache-Control 頭來設定快取 1 天的時間。見下面的請求和響應:

# REQUEST
GET /baz.jpg HTTP/1.1
Host: www.foobar.tld

# RESPONSE
HTTP/1.1 200 OK
Date: Tue, 07 Feb 2017 15:01:20 GMT
Last-Modified: Tue, 07 Feb 2017 15:01:15 GMT
ETag: "4-547f20501b9e9"
Content-Length: 123
Cache-Control: max-age=86400 public
Content-Type: image/jpeg複製程式碼

對於 URL: www.foobar.tld/dist/css/st… 的響應同樣也包含了 ETag 頭。由更改時間、檔案大小和限定了 2 小時的 Cache-Control 構成。Last-Modfied 頭也刪除掉以確保只有 ETag 用來做二次驗證。見下面的請求和響應:

# REQUEST
GET /styles.css HTTP/1.1
Host: www.foobar.tld

# RESPONSE
HTTP/1.1 200 OK
Date: Tue, 07 Feb 2017 15:00:00 GMT
Server: Apache
ETag: "20-547f1fbe02409"
Content-Length: 32
Cache-Control: max-age=7200 public must-revalidate
Content-Type: text/css複製程式碼

小結

本文中我們介紹了:

  1. 為什麼要使用 CDN,以及 CDN 是如何起作用的。
  2. 靜態內容和動態內容分別是什麼情況。
  3. HTTP 頭是如何解決快取問題的。

那麼想象一下這樣的場景,假設你有個網站,需要儲存使用者的登入狀態,而針對不同狀態的使用者需要不同的顯示。通常情況下,我們是用 Cookie 來解決使用者特性的問題。這時候問題就來了,如果 Cookie 也在 CDN 中快取,那麼將會導致所有使用者的 Cookie 都一樣,這不是我們想要看到的。那麼怎麼解決呢?我們會在《掌握 HTTP 快取——從請求到響應過程的一切(下)》中詳細介紹。


我最近正在寫一本《React.js 小書》,對 React.js 感興趣的童鞋,歡迎指點

相關文章