在閱讀本文前推薦你先閱讀我的前兩篇文章《 扼殺 304,Cache-Control: immutable》和《關於快取和 Chrome 的“新版重新整理”》;下面要說的兩個問題是在淘寶(包括天貓等等)任意主流頁面中都存在的,所以你可以隨便開啟一個頁面進行測試;這兩個問題我去年在微博上都簡單提到過,這裡做一下梳理總結。
一. 部分圖片檔案始終 304,無法直接讀取快取
淘寶網站上什麼型別的請求最多?當然是圖片了。拿淘寶首頁舉例,在 Chrome 的新標籤頁中先開啟開發者工具,再開啟淘寶首頁,然後滾動到頁面最底部,在開發者工具的網路皮膚中點選 Img 篩選條件後能夠看到左下角有類似如下的數字:
有高達 80% 的請求數都是圖片,淘寶的其它主流頁面也有同樣的規律。然而在這些圖片中,有一部分圖片無法直接讀取瀏覽器快取,即便已經被下載過,瀏覽器也要再發個條件請求,在收到 CDN 返回的空的 304 響應後再讀取快取:
<div><a href="javascript:location+=''">點選該連結,從而使當前頁面重新載入,下面的兩張圖片應該直接讀取快取,不發起任何 HTTP 請求</a></div> <img width=100 src="http://images2015.cnblogs.com/blog/116671/201702/116671-20170222201116820-1249825884.png"> <img width=100 src="https://img.alicdn.com/imgextra/i2/272205633/TB2J1fpaurAQeBjSZFwXXa_RpXa_!!272205633.jpg">
上面這個 demo 中有兩張圖, 一張是存在淘寶 CDN 上的,另一張是我轉存到部落格園的。當你點選測試連結時會發現,部落格園上的圖片能夠直接讀取快取(沒傳送請求),而淘寶 CDN 上的那張圖產生了個 304 請求(響應碼為 304 的請求):
這個視訊演示有下面三個要關注的點:
1. 那個 304 請求的響應有 49 個位元組。
2. 那個 304 請求的響應時間為十幾毫秒到幾百毫秒不等。
3. 頁面中直接讀取快取的圖片絲毫不動,而經過 304 後再讀取快取的圖片有明顯的閃動。
別看請求頭加上響應頭也就 100 個位元組,但架不住多啊,這種圖片的日 pv 我估計至少得用百億做單位,浪費的日流量得用 T 作單位,這種小錢也許對淘寶這種大廠來說完全可以忽略,但使用者體驗卻是沒法忽略的,在理想的網路情況下圖片展現都有明顯的閃動,那在移動端或者網路環境較差的 PC 端,延時就會更加明顯了。
原因是什麼?讓我們看看這張圖片的響應頭(已經刪掉了 x- 開頭的):
$ curl -I 'https://img.alicdn.com/imgextra/i2/272205633/TB2J1fpaurAQeBjSZFwXXa_RpXa_!!272205633.jpg' HTTP/1.1 200 OK Server: Tengine Content-Type: image/jpeg Content-Length: 94506 Connection: keep-alive Date: Mon, 31 Oct 2016 02:43:49 GMT last-modified: Fri, 09 Sep 2016 08:42:30 GMT Cache-Control: max-age=3600, s-maxage=31536000 Access-Control-Allow-Origin: * Via: cache64.l2et2[0,200-0,H], cache23.l2et2[21,0], cache4.cn395[0,200-0,H], cache3.cn395[0,0] Age: 9938377 Timing-Allow-Origin: * EagleId: 8ccd3b4314878202062633285e |
問題就出在標紅的這兩個頭上,瀏覽器看一個快取有沒有過期是通過看 Date 頭返回的時間點加上 Cache-Control 頭中 max-age 欄位指定的時間段算出的時間點有沒有小於客戶端的時間,也就是說看那個算出的時間是不是還沒有到來。對於這張圖片的話,過期時間可以通過如下的 JS 程式碼計算出來:
new Date(+new Date("Mon, 31 Oct 2016 02:43:49 GMT") + 3600) + "" // "Mon Oct 31 2016 10:43:52 GMT+0800 (CST)"
其實這個例子根本不需要筆算,口算都能算出來,2016 年 10 月份的某個時刻加上一個小時肯定還是 2016 年,小於我的客戶端時間 2017 年,所以瀏覽器剛剛獲取到這張圖片就已經過期了。Firefox 有個內部除錯工具可以看到每個快取的過期時間,在 Firefox 中開啟那張圖片後再開啟 about:cache-entry?storage=disk&context=&eid=&uri=https://img.alicdn.com/imgextra/i2/272205633/TB2J1fpaurAQeBjSZFwXXa_RpXa_!!272205633.jpg 頁面:
expires 欄位就是 Firefox 計算出來的過期時間,沒有顯示 2016 年是因為如果 Firefox 計算出的過期時間是過去的某個時間,會用當前時間來代替。
所以問題就是為什麼 CDN 返回的 Date 會是 2016 年?是因為這張圖片是在 2016 年回源的,回源的時候 CDN 快取了當時圖片源站返回的 Date 頭,作為以後給瀏覽器返回的 Date 頭,所以使用者瀏覽器接受到的 Date 就固定在了 2016 年。
既然是 CDN 的問題,為什麼 CDN 上的其它圖片和檔案沒有同樣的表現?是因為 Cache-Control。這類圖片的 Cache-Control 有個特點,那就是 max-age 比 s-maxage 小,我們知道 max-age 是 CDN 給瀏覽器用的,而 s-maxage 是源站給 CDN 用的,max-age=3600, s-maxage=31536000 代表的含義就是瀏覽器只能快取這張圖片一小時,而 CDN 會快取這張圖片一年,所以只有等到了 Mon, 31 Oct 2017 02:43:49 GMT 年的時候,這張圖片的 Date 響應頭才會更新,也就是對於使用者來說,一年中有 364 天 23 小時 訪問這張圖片都是直接過期的。
因此只要 max-age 不比 s-maxage 小,就不會有這種下載立刻過期的情況,比如 Cache-Control: max-age=2592000,s-maxage=3600,或者完全不指定 s-maxage,Cache-Control: max-age=31536000 都行。那 max-age 比 s-maxage 小就完全是錯的嗎?我覺的並不是,我猜測這麼設定的理由是:這些圖片有更新的需求,所以給瀏覽器設定的快取時間是一小時,給 CDN 設定的快取時間是一年是因為更新圖片畢竟是小概率事件,不是大批量的,所以都是通過 CDN 提供的 purge 介面進行強制回源的,不需要 CDN 因資源過期發起大量主動回源。
所以我個人覺的 CDN 返回的 Date 響應頭應該使用 CDN 伺服器的當前時間,而不是用快取的陳舊的源站時間。
二. 大部分 JS/CSS 檔案在頁面重新整理時無法傳送條件請求
也就是無法產生 304 響應。上篇文章已經說過了,Chrome 在頁面重新整理時已經不再為子資原始檔傳送條件請求了(直接讀取快取),但在國內,國產瀏覽器才是王道,尤其是移動端(沒幾個用 Chrome 的),目前這些瀏覽器仍然會在重新整理時為頁面中的 JS/CSS 檔案發起條件請求。
如果你使用 Chrome 56(當前穩定版本)的話,需要把 chrome://flags/#enable-non-validating-reload-on-normal-reload 調成已停用(更高版本的 Chrome 已經沒有這個選項,請換個瀏覽器),再執行下面的 demo:
<div>重新整理當前頁面,兩個 JS 檔案都應該是 304 響應</div> <script src="http://common.cnblogs.com/script/jquery.js"></script> <script src="https://g.alicdn.com/kissy/k/1.4.2/seed-min.js"></script>
上面這個 demo 中有兩個 JS 檔案,一個是淘寶 CDN 上的 KISSY,一個是部落格園上的 jQuery。當你重新整理頁面時,會發現 jQuery 這個請求的確是 304 響應,而 KISSY 這個每次是 200,也就是像完全沒快取一樣:
上面的視訊演示中,我為了模擬較差的網路環境,故意將網速節流成了 3G 模式。在 waterfall 列裡可以看到,獲取完整響應的 200 請求比只拿響應頭的 304 請求多了 100 多毫秒的載入時間(藍色條部分)。考慮到現在的網頁沒有 JS/CSS 基本什麼都展現不了,所以這個問題會讓頁面重新整理後的白屏時間大幅增加。
原因是什麼?我們看一下這個 JS 檔案的響應頭(已經刪掉了 x- 開頭的):
$ curl -I 'https://g.alicdn.com/kissy/k/1.4.2/seed-min.js' HTTP/1.1 200 OK Server: Tengine Content-Type: application/javascript Content-Length: 44971 Connection: keep-alive Date: Thu, 23 Feb 2017 05:50:47 GMT Vary: Accept-Encoding Accept-Ranges: bytes Cache-Control: max-age=2592000,s-maxage=3600 Access-Control-Allow-Origin: * Via: cache16.l2eu6-1[0,200-0,H], cache17.l2eu6-1[1,0], cache4.cn298[0,200-0,H], cache5.cn298[1,0] Age: 2605 Timing-Allow-Origin: * EagleId: 8ccd84cd14878316529776698e |
問題就在,響應頭裡沒有 Last-Modified 和 ETag,因此瀏覽器沒法生成 If-Modified-Since 和 If-None-Match 請求頭,所以沒法傳送條件請求,只能發個普通的非條件請求。
重新整理並不算是極端情況,比如移動端的下拉重新整理,是很常見的,因此重新整理的使用者體驗也是需要保障的。