楔子
想象一下這樣的場景:當你使用瀏覽器訪問某個web站點的時候,你的瀏覽器需要下載很多的資源文件。而你下載的這些資源文件中有一部分在某一段時間甚至是很長時間內都不會發生變化,所以如果你每次訪問這個站點都需要把它們重新下載一遍的話,那將是一種巨大的資源浪費,這不僅僅會拖慢你打開網頁的速度,還會占用你的網絡帶寬,浪費你寶貴的流量,對于響應你的請求的源服務器也是一種巨大的負擔。所以,瀏覽器可以將這部分資源文件緩存到本地,以便將來你需要的時候可以直接從瀏覽器緩存讀取,而不用重新再從源服務器下載了,這就是瀏覽器緩存。
本文的目標,旨在盡可能闡述清楚你需要知道的關于瀏覽器緩存的所有重要知識點,包括什么是瀏覽器緩存、它的意義和價值、緩存的目標、緩存的工作原理、緩存的更新機制等等。在寫這篇文章之前,我看過很多官方和個人介紹瀏覽器緩存的博客文章,它們是本文重要的信息來源,而且我會在它們的基礎之上,結合一些實際測試的結論,補充講解一些它們未曾涉及到但是卻非常重要的、與實戰緊密相關的知識點,來幫助大家更好地理解瀏覽器緩存的運行機制,以便將來可以在實際的工作當中更好地運用瀏覽器緩存。
本文講解的重點是瀏覽器緩存,但是瀏覽器緩存也只是HTTP緩存的一種。所以在此之前,我們有必要先來了解一下HTTP緩存(以下簡稱緩存)。
1. 緩存的概念
緩存是指將某個請求的響應內容(包括響應頭和響應主體)緩存下來,以便下次相同請求過來時能直接使用緩存進行響應,而不用重新再從源服務器下載。這樣做有三個好處:
第一,加快了響應的速度。由于緩存服務器通常距離客戶端更近,所以響應速度也更快。瀏覽器緩存其實就充當了一個安裝在客戶端本地的緩存服務器,所以響應的速度是極快的。
第二,減輕了源服務器的壓力,相同的請求不用每次都由源服務器來進行響應,特別是一些很長時間甚至是根本不會發生變動的資源文件,例如:JavaScript庫文件、CSS庫文件以及圖片文件等等;
第三,節省了用戶的流量資源,降低了對網絡資源的占用,可以有效緩解網絡擁堵的情況。
所以,對于我們web開發者而言,深入地了解和利用好緩存是非常重要的,它對于提升我們的web應用的性能具有非常重要的意義。
2. 緩存的分類
緩存主要分為兩大類:公有緩存(Shared Cache)和私有緩存(Local Cache)。顧名思義,公有緩存就是可以服務多個客戶端請求的緩存服務器,例如CDN緩存;私有緩存就是只服務單個客戶端的緩存服務器,例如瀏覽器緩存。其他的緩存類型包括:網關緩存、反向代理緩存以及負載均衡器等等。由于CDN緩存經常會在瀏覽器和源服務器的通信中間扮演一個重要的角色,而且會對瀏覽器的緩存行為產生一些重要的影響,所以后面我們也會重點關注一下CDN緩存。
3. 緩存的目標
理論上所有類型請求(GET、POST、PUT等等)的響應內容都可以被緩存下來,但是通常我們只緩存GET請求的響應。一般情況下緩存的key值就是請求的URI(也有URI和請求頭的組合key值的情況,后面會講到)。緩存的目標包括以下幾種:
1、響應碼為200的GET請求的響應內容,例如:HTML文檔、圖片等;
2、響應碼為301的永久重定向響應;
3、響應碼為404的空頁面響應;
4、響應碼為206的部分響應;
5、其他適合緩存的非GET請求的響應。
以我們的貝貸首頁(https://jr.beibei.com/loan/borrow-index.html)為例,來看看瀏覽器都為它緩存了哪些內容。第一步先清空瀏覽器緩存(推薦使用Chrome插件Clear Cache),然后訪問貝貸首頁,第二步直接左上角刷新頁面,我們從下面的截圖中來看看它們的網絡資源訪問情況。
從上圖中可以看到,第一次訪問頁面時,由于沒有瀏覽器緩存,所以所有的資源文件都是新下載的,可以從紅框中看到它們的文件大小和加載耗時;第二次訪問頁面時,由于部分資源文件已經被瀏覽器所緩存,所以它們是從瀏覽器緩存中直接讀取的,不會真正發送網絡請求(如果此時你用Charles抓包看的話,你會發現根本看不到它們的請求)。從圖中可以看到,它們中有的是從內存中(from memory cache)讀取的,且加載耗時都是0ms,有的是從磁盤中(from disk cache)讀取的,耗時也不過幾毫秒。至于為什么有些緩存文件會放在內存中,有些會放在磁盤上,這估計就跟Chrome瀏覽器的緩存管理機制有關了。
4. 緩存的機制
4.1 基本原理
前面提到,最大限度地利用緩存可以有效提升客戶端頁面的打開速度,還能減輕源服務器的壓力,進而提升源服務器的響應效率。所以通常情況下,我們希望緩存文件存活的時間越長越好。但是當源服務器上的資源文件有更新時,我們也希望能盡快更新緩存服務器上的緩存文件。
但是,由于HTTP協議是一種基于請求/響應模式的網絡協議,它只能由客戶端主動向服務器發起請求,然后服務器才能給予響應,服務器是沒有辦法主動與客戶端進行通信的。所以源服務器在資源文件有更新時是無法主動通知各級緩存服務器更新緩存文件的。基于這個前提之下,源服務器在響應緩存服務器的請求時,必須要指明緩存文件的有效期。
雙方約定,在這個有效期內的緩存文件是有效的,緩存服務器可以直接用來響應客戶端的請求,超過這個有效期的緩存文件就失效了,緩存服務器就不能直接用來響應客戶端的請求了,它必須先向源服務器發送校驗請求以確認緩存文件的有效性,然后再決定該如何響應客戶端的請求。
當源服務器收到緩存服務器發來的緩存文件有效性校驗請求時,它要么確認緩存文件依然有效,然后返回304 Not Modified以及新的響應頭(不包含響應主體,即不包含資源文件內容)更新緩存文件的有效期,要么發現緩存文件已經失效,此時直接返回200 OK以及完整的響應內容(包含響應頭以及響應主體),此時就好像響應一個普通的請求一樣。當緩存服務器接收到源服務器返回的304 Not Modified響應時,它會先更新緩存文件的有效期,然后使用緩存文件響應客戶端的請求,如果它接收到的是200 OK響應,那么它會重新緩存文件,并用新的緩存文件響應客戶端的請求。
下面,我用一張圖來解釋一下“一般性的緩存工作基本原理”(注意,下圖中的緩存服務器可以是CDN緩存服務器或者是瀏覽器緩存服務器):
我們還是以貝貸首頁的HTML文件為例,來看看真實的情況如何。同樣,我們還是分兩步走,第一步清空緩存后訪問頁面,第二步直接訪問頁面,然后我們來看看它們的響應內容為何物(為了方便一張圖觀察到全部的重要信息,這里我使用的是Charles抓包截圖):
可以看到,第一次請求時,服務器返回了200 OK以及完整的響應內容,耗時128ms,文件大小2.37KB,而第二次請求時,服務器只返回了304 Not Modified以及部分響應頭,并沒有響應主體,耗時59ms,文件大小不足1KB(因為只有304 Not Modified和部分響應頭而已)。這說明了三個問題:第一,該HTML文件被瀏覽器緩存住了;第二,雖然它被緩存住了,但是當它被請求時,瀏覽器依然向源服務器發送了校驗請求;第三,源服務器在接收到校驗請求之后,驗證緩存依然有效,所以只返回了304 Not Modified以及部分響應頭。
上面的例子講的是瀏覽器緩存,這里再附上一張來自MDN的講解CDN緩存機制的示意圖,其實大致的過程都是一樣的:
4.2 緩存的控制
我們知道,為了能夠在HTTP協議請求/響應模式的限制之下,盡可能地平衡好“讓緩存的時間更長”和“資源有更新時盡早刷新緩存”的沖突,源服務器會在響應請求時,通過加入一些緩存控制字段來指明“響應是否可緩存”、“如果可以緩存有效期是多長”、“如果緩存過期失效該如何發送校驗請求”等等重要信息。具體字段可以參考一下下面的這張圖:
看過上面的這張圖之后,你的腦海中也許會有一些疑問,我曾經也有過這些疑問,那么我就來嘗試解答一下你的這些問題(下面的這些結論,我都使用Chrome瀏覽器親自驗證過)。
問題一:Cache-Control: no-cache和Cache-Control: no-store都是禁止緩存的意思嗎?
不是。只有Cache-Control:?no-store才是真正的禁止緩存,響應頭中有這個字段的話,無論是公有緩存還是私有緩存都不會緩存這個響應。而Cache-Control: no-cache的真正含義是:在使用緩存之前,必須先向源服務器發起請求校驗緩存的有效性。
問題二:那Cache-Control: no-cache和Cache-Control: must-revalidate的作用是完全一樣的嗎?
不是。它們之間的共同點在于:使用緩存之前都會向源服務器發送請求校驗緩存的有效性。但是它們之間不同的是,Cache-Control: must-revalidate是在緩存已過期時才會向源服務器發起校驗請求,而Cache-Control: no-cache則強制要求必須要向源服務器發起校驗請求,無論緩存是否已過期。
問題三:Cache-Control: max-age=N和Cache-Control: s-maxage=N有什么相同點和不同點?
他們的相同點是,都可以用來指示緩存的有效時長是多少秒,且都對公有緩存生效。但Cache-Control: s-maxage=N只對公有緩存生效(這個s應該指的是server),對私有緩存不生效。當響應頭中同時包含max-age和s-maxage時(例如:Cache-Control: max-age=100, s-maxage=100),公有緩存優先讀取s-maxage的值。
問題四:那Expires和上面的兩個值又有什么相同點和不同點呢?
它們的不同點在于,Expires指定的是緩存的過期時間點,是一個絕對時間,而max-age/s-maxage指定的是緩存的有效時長,是一個相對時間。
它們有幾個相同點,第一是都是用于指定緩存的過期時間,第二是緩存服務器拿到它們之后,都是基于本地時間去計算緩存的實際過期時間的,無論是CDN緩存服務器還是瀏覽器緩存服務器都是如此。所以,如果使用絕對時間的話,那么由于每個緩存服務器的本地時間各不相同,會導致各自的實際緩存過期時間千差萬別。但是,如果使用相對時間的話,那么緩存服務器會依據本地的相對時間差值來計算緩存的過期時間,相比之下誤差會更小。
另外,緩存服務器會優先使用max-age/s-maxage計算緩存的失效時間,如果max-age/s-maxage不存在才會去取Expires的值計算緩存的失效時間。
問題五:Cache-Control: public和Cache-Control: private有哪些相同點和不同點?
它們的不同點是,public針對公有緩存和私有緩存都生效,而private僅針對私有緩存生效。它們的相同點是,實際中很少會被用到,所以對它們僅作了解即可。
問題六:Cache-Control: no-cache和Pragma: no-cache有什么相同點和不同點?
相同的是它們的作用一模一樣,但是Pragma是HTTP/1.0的規范,所以添加它一般只是為了向下兼容。
問題七:Date和Age對于緩存失效時間的計算有什么影響?
Date是源服務器響應請求時的源服務器系統時間(注意!是源服務器,不是緩存服務器!)。Age是緩存文件在CDN服務器上已消耗的有效時長,如果瀏覽器接收到的響應含有Age字段,說明該響應來自CDN服務器,而不是來自源服務器。這兩個值對于緩存過期時間的計算至關重要,接下來會詳細說明。
4.3 瀏覽器緩存過期時間的計算(基于Chrome v70測試)
接下來,我們一起來探討一下,瀏覽器緩存的過期時間是如何計算的。先來看一下下面的流程圖:
可以看到,整個計算過程還是比較復雜的,我們一起來把其中的重要環節梳理一遍:
1、瀏覽器客戶端發起網絡請求,此時先不考慮命中瀏覽器緩存的情況,于是瀏覽器順利收到響應,先記錄一下此時的客戶端系統時間為CDate,接下來開始判斷是否需要緩存該響應。
2、檢查響應頭中是否有Age字段,如果沒有則把Age置為0。如果存在Age值,則說明該響應來自CDN服務器。這個值代表的是,CDN服務器從上一次向源服務器發起請求更新緩存(可能是200 OK也可能是304 Not Modified)到本次響應瀏覽器請求所經過的時長,單位為秒。
3、檢查響應頭中是否有Date字段,如果沒有則取Date為客戶端系統時間。注意,這個Date值是源服務器輸出該響應時的源服務器系統時間,如果該響應來自CDN服務器,那么說明你收到的響應是CDN服務器上的緩存,而這個Date值是上一次CDN服務器向源服務器發起請求更新緩存(可能是200 OK也可能是304 Not Modified)時源服務器添加在響應頭中的源服務器系統時間,而不是此次CDN服務器響應瀏覽器請求時的CDN服務器時間,這一點一定要搞清楚!
4、檢查響應頭中是否有Cache-Control字段,如果有的話:
? ? ? ? 4.1 檢查它的值是否包含no-store,包含的話則說明該響應不允許被緩存,任何緩存服務器不得緩存該響應。
? ? ? ? 4.2 否則,再檢查它的值是否包含no-cache,如果包含的話,再檢查響應頭中是否有ETag或者Last-Modified字段,包含則緩存該響應并標記為已失效,否則就不緩存該響應。
? ? ? ? 4.3 否則,再檢查它的值是否包含max-age字段,如果包含的話,再結合Age字段、Date字段以及客戶端本地時間CDate值進行后續的緩存過期時間的計算,完整的判斷過程都在圖中,主要分為三種情況,我在這里說一下我個人的一個理解。第一種情況:Date + MaxAge <= CDate,說明此時即便服務器時間加上緩存的完整有效時長都比客戶端本地時間要晚,因為計算出來的緩存過期時間永遠是跟客戶端本地時間做對比的,所以客戶端可以直接認為這份緩存已經失效了,然后再查看響應頭中是否有ETag或者Last-Modified字段來判斷是緩存該響應并標記為已失效還是直接不緩存該響應;第二種情況:Date + MaxAge > CDate &&?Date + Age <= CDate,這種情況下我測試出來的緩存過期時間為Date + MaxAge,其實Date + Age就是此時源服務器的當前時間,也就是說此時源服務器的時間要早于客戶端本地時間,基于目前測試的結果,緩存過期時間是Date + MaxAge而不是CDate + LeftAge,我只能暫且認為,源服務器寧可讓客戶端本地緩存早點過期早點發緩存有效性驗證請求,也不希望當源服務器資源有更新時,客戶端緩存不能及時更新;第三種情況:Date + Age > CDate,說明此時源服務器的時間要晚于客戶端本地時間,那么緩存的過期時間為CDate + LeftAge也在意料之中,因為這樣瀏覽器緩存和CDN緩存就都會在LeftAge秒之后過期,跟源服務器一開始約定好的緩存過期時間點就匹配上了。
5、如果響應頭中不包含Cache-Control但包含Expires的話,那么緩存的過期時間就等于Expires減去Age的值,也就是Expires往前推Age秒之后的時間值。
6、如果Cache-Control和Expires都沒有的話,那么查看是否存在Last-Modified字段,如果存在的話,那么先使用其他博客中所謂的“啟發式緩存算法”計算出LeftAge的值,也就是Date和Last-Modified差值的十分之一,然后判斷Date和CDate誰更小,用那個更小的值加上LeftAge就可以計算出緩存的過期時間了。
7、如果最后連Last-Modified都沒有的話,那么再查看是否有ETag字段來判斷是緩存該響應并標記為已失效還是直接不緩存該響應。
8、上面某些情況下緩存過期時間計算出來之后有可能是小于客戶端本地時間的,也就是說緩存會立即變成失效狀態,那么這個時候仍然要通過判斷ETag和Last-Modified字段是否存在來判斷是緩存該響應并標記為已失效還是直接不緩存該響應。
好了,以上就是瀏覽器緩存過期時間計算的整個過程了,需要提前說明的是,上面的所有結論我都用Chrome瀏覽器親自驗證過,但我不保證所有的瀏覽器或者web內核的處理方式都是這樣的,所以整個過程僅供參考。
接下來,我會嘗試用一段偽JS代碼來描述整個過程:
4.4 緩存的有效性校驗
上一節我們講的是瀏覽器如何計算緩存的過期時間,這一節我們再來聊聊當緩存過期時,緩存服務器如何向源服務器發送校驗請求,源服務器又是如何響應緩存服務器的校驗請求的。其實主要的過程已經在4.1 基本原理中說明過了,這里我們只需要把一些技術細節和注意事項補充講解一下就可以了。
源服務器在響應緩存服務器的請求時,會在響應頭中加入一些字段,一些是為了指明緩存的過期時間,還有一些就是為了在緩存失效時做校驗用的。這些校驗字段分為強校驗字段(ETag)和弱校驗字段(Last-Modified)兩種。
強校驗字段ETag,通常是由源服務器根據資源文件的內容生成的唯一hash值,資源文件內容不變時hash值不變,資源文件內容有任何變化時hash值也會跟著變化,所以可以用來檢驗文件是否有改動。緩存服務器在發現緩存過期且緩存響應頭包含ETag時,會向源服務器發送帶有If-None-Match請求頭的校驗請求,它的值就是緩存響應頭ETag的值,源服務器通過比對If-None-Match的值和當前資源文件的hash值是否相同即可判斷出緩存服務器上的緩存文件是否需要更新。
弱校驗字段Last-Modified,顧名思義,就是文件的最后修改時間,也可以用來檢測文件是否有改動。同樣,緩存服務器在發現緩存過期且緩存響應頭包含Last-Modified時,會向源服務器發送帶有If-Modified-Since請求頭的校驗請求,它的值就是緩存響應頭Last-Modified的值,源服務器通過查看當前資源文件的Last-Modified日期是否等于If-Modified-Since日期即可判斷出緩存服務器上的緩存文件是否需要更新。
同樣,我們還是用一張流程圖來說明整個過程:
我們還是以貝貸首頁為例,先清空緩存再訪問頁面,返回了200 OK響應,如下圖所示:
分析一下上面的截圖,有以下幾個關鍵信息:
1、返回了Cache-Control: max-age=0, s-maxage=300,說明源服務器希望瀏覽器緩存該文件但立即過期,也說明它希望CDN能緩存該文件且有效時長是300秒;
2、沒有Age字段的返回,說明該響應來自源服務器而不是CDN服務器,事實上我們也沒有把它放到CDN上,所以上面的s-maxage=300其實是無用的;
3、返回了Date: Sun, 09 Dec 2018 01:06:49 GMT就是此時源服務器的時間;
4、返回了Last-Modified: Thu, 06 Dec 2018 09:39:33 GMT,指明了緩存過期后發起校驗請求的方式;
接下來,我們刷新頁面,返回了304 Not Modified響應,如下圖所示:
仔細看你就會發現,第二次請求時請求頭中多了一個If-Modified-Since字段,它的值就是第一次請求時返回的Last-Modified的值,而且本次請求返回的Last-Modified值跟上次一樣無變化,說明文件的最后修改時間沒有發生變化,且響應主體也是空的。
之所以稱Last-Modified是弱校驗字段,原因就在于它只能精確到秒,如果資源文件在一秒鐘之內被修改了多次,那么就有可能導致緩存服務器讀取到的資源文件內容不是最新的,而且由于Last-Modified值沒變化,從而導致緩存無法被更新。另外,有些資源文件是服務器動態生成的,那么就會出現某些資源文件雖然內容無變化但是Last-Modified值卻一直在變的情況,導致一些不必要的刷新緩存操作。而且,在分布式系統當中使用Last-Modified時,也務必要保證各個服務器的時間是同步的,否則也會造成一些“該刷新的沒刷新,不該刷新的卻刷新了”的誤判情況。
不過,使用ETag也有一些情況需要注意一下,也同樣是在分布式系統當中,我們要保證各個系統生成資源文件的ETag值的算法是一致的,否則也會造成誤判的情況。
除了ETag/If-None-Match和Last-Modified/If-Modified-Since之外,其實還有一些其他的不怎么常用的校驗字段,這里就不一一列舉了,有興趣的小伙伴可以自行查閱。
五、Vary響應頭
前面我們說過,緩存的key值就是請求的URI,這樣的設計足夠簡單,也有利于緩存機制的推廣,但是也會有不滿足需求的情況。
例如,某電商網站提供了多國語言版本的官網,需要在用戶訪問同一官網地址時根據用戶設定的瀏覽器語言返回相對應的版本。如果它也想使用緩存的話,那么只把請求的URI作為緩存的key值顯然是不行的。所以,HTTP協議設計了Vary響應頭,它指示緩存服務器命中該緩存的條件除了URI要相同之外,Vary中指定的請求頭也都要匹配得上才能命中緩存。所以,針對上述案例,我們可以在響應頭中加入Vary:?Accept-Language,這樣緩存服務器就會緩存不同語言版本的官網,然后根據用戶請求時傳過來的Accept-Language值返回相對應語言版本的官網。
再次盜用一張MDN的圖,雖然它是以文件編碼格式Accept-Encoding為例做的講解,但其實意思是一樣的:
六、如何禁用瀏覽器緩存
在Chrome的開發者工具的Network中勾選Disable cache可以讓所有的請求都不檢查瀏覽器緩存而直接發出,但是返回的資源該緩存的仍然會被緩存,只不過這個選項被勾中的話,在發請求階段不會去檢查瀏覽器緩存而已。它的實際做法就是在發請求階段,給所有的請求頭都加上Pragma: no-cache和Cache-Control: no-cache來達到跳過檢查瀏覽器緩存階段的效果的。
或者,你也可以使用Chrome插件Clear Cache來清空Chrome的瀏覽器緩存,這個插件的做法才是真正的清空瀏覽器緩存。
在Charles中勾選No Caching也可以達到相同的效果,它們都是通過在請求頭中加入Pragma: no-cache和Cache-Control: no-cache來達到禁用緩存的效果的。在Chrome瀏覽器中,你還可以通過刷新按鈕的右鍵菜單選擇強制刷新,或者干脆使用來得更方便。
另外,如果你直接在瀏覽器的地址欄訪問某個本可以被緩存的資源文件地址的話,它是不會直接走瀏覽器緩存的,而是每次刷新時,瀏覽器都會向源服務器發送校驗請求,就好像這個資源文件的響應頭被設置了Cache-Control: no-cache一樣(其實并沒有)。