聊聊高併發系統之HTTP快取

錢曙光發表於2016-08-22

宣告:本位來自京東張開濤的微信公眾號(kaitao-1234567),授權CSDN轉載,如需轉載請聯絡作者。
作者:張開濤,京東資深Java工程師,2014年加入京東,主要負責商品詳情頁、詳情頁統一服務架構與開發工作,設計並開發了多個億級訪問量系統。工作之餘喜歡寫技術部落格,有《跟我學 Spring》、《跟我學Spring MVC》、《跟我學Shiro》、《跟我學Nginx+Lua開發》等系列教程,目前部落格訪問量有460萬+。
責編:錢曙光,關注架構和演算法領域,尋求報導或者投稿請發郵件qianshg@csdn.net,另有「CSDN 高階架構師群」,內有諸多知名網際網路公司的大牛架構師,歡迎架構師加微信qshuguang2008申請入群,備註姓名+公司+職位。

簡介

最近遇到很多人來諮詢我關於瀏覽器快取的一些問題,而這些問題都是類似的,因此總結本文來解答以後遇到類似問題的朋友。

因本文主要以瀏覽器快取場景介紹,所以非瀏覽器場景下的一些用法本文不會介紹,而且本文以chrome為測試瀏覽器。

瀏覽器快取是指當我們使用瀏覽器訪問一些網站頁面或者http服務時,根據服務端返回的快取設定響應頭將響應內容快取到瀏覽器,下次可以直接使用快取內容或者僅需要去服務端驗證內容是否過期即可。這樣的好處可以減少瀏覽器和服務端之間來回傳輸的資料量,節省頻寬提升效能。

首先看個例子;當我們第一次訪問http://item.jd.com/1856588.html時將得到如下響應頭:

圖片描述

然後接著按F5重新整理頁面,將得到如下響應頭:

圖片描述

第二次返回的相應狀態碼為304,表示服務端文件沒有修改過,瀏覽器快取的內容還是最新的。

接下來我們看下如何在Java應用層控制瀏覽器快取。

示例

Last-Modified

如下是我們的spring mvc快取測試程式碼:

@RequestMapping("/cache")
public ResponseEntity<String> cache(
      HttpServletRequest request,
      //為了方便測試,此處傳入文件最後修改時間
      @RequestParam("millis") long lastModifiedMillis,
      //瀏覽器驗證文件內容是否修改時傳入的Last-Modified
      @RequestHeader (value = "If-Modified-Since", required = false) Date ifModifiedSince) {

    //當前系統時間
    long now = System.currentTimeMillis();
    //文件可以在瀏覽器端/proxy上快取多久
    long maxAge = 20;

    //判斷內容是否修改了,此處使用等值判斷
    if(ifModifiedSince != null && ifModifiedSince.getTime() == lastModifiedMillis) {
        return new ResponseEntity<String>(HttpStatus.NOT_MODIFIED);
    }

    DateFormat gmtDateFormat = new SimpleDateFormat("EEE, d MMM yyyy HH:mm:ss 'GMT'", Locale.US);

    String body = "<a href=''>點選訪問當前連結</a>";
    MultiValueMap<String, String> headers = new HttpHeaders();

    //文件修改時間
    headers.add("Last-Modified", gmtDateFormat.format(new Date(lastModifiedMillis)));

    //當前系統時間
    headers.add("Date", gmtDateFormat.format(new Date(now)));
    //過期時間 http 1.0支援
    headers.add("Expires", gmtDateFormat.format(new Date(now + maxAge)));
    //文件生存時間 http 1.1支援
    headers.add("Cache-Control", "max-age=" + maxAge);
    return new ResponseEntity<String>(body, headers, HttpStatus.OK);
}

為了方便測試,測試時將文件的修改時間通過millis引數傳入,實際應用時可以使用如商品的最後修改時間等替代。

首次訪問

首次訪問http://localhost:9080/cache?millis=1471349916709,將得到如下響應頭:

圖片描述

響應狀態碼200表示請求內容成功,另外有如下幾個快取控制引數:

Last-Modified:表示文件的最後修改時間,當去伺服器驗證時會拿這個時間去;

Expires:http/1.0規範定義,表示文件在瀏覽器中的過期時間,當快取的內容超過這個時間則需要重新去伺服器獲取最新的內容;

Cache-Control:http/1.1規範定義,表示瀏覽器快取控制,max-age=20表示文件可以在瀏覽器中快取20秒。

根據規範定義Cache-Control優先順序高於Expires;實際使用時可以兩個都用,或僅使用Cache-Control就可以了(比如京東的活動頁sale.jd.com)。一般情況下Expires=當前系統時間(Date) + 快取時間(Cache-Control: max-age)。大家可以在如上測試程式碼進行兩者單獨測試,快取都是可行的。

F5重新整理

接著按F5重新整理當前頁面,將看到瀏覽器傳送如下請求頭:

圖片描述

此處傳送時有一個If-Modified-Since請求頭,其值是上次請求響應中的Last-Modified,即瀏覽器會拿這個時間去服務端驗證內容是否發生了變更。接著收到如下響應資訊:

圖片描述

響應狀態碼為304,表示服務端告訴瀏覽器說“瀏覽器你快取的內容沒有變化,直接使用快取內容展示吧”。

注:在測試時要過一段時間更改下引數millis來表示內容修改了,要不然會一直看到304響應。

Ctrl+F5強制重新整理

如果你想強制從服務端獲取最新的內容,可以按Ctrl+F5:

圖片描述

瀏覽器在請求時不會帶上If-Modified-Since,並帶上Cache-Control:no-cachePragma:no-cache,這是為了告訴服務端說請給我一份最新的內容。

from cache

當我們按F5重新整理、Ctrl+F5強制重新整理、位址列輸入地址重新整理時都會去服務端驗證內容是否發生了變更。那什麼情況才不去服務端驗證呢?即有些朋友還會發現有一些“from cache”的情況,這是什麼情況下發生的呢?

從A頁面跳轉到A頁面或者從A頁面跳轉到B頁面時:

圖片描述

大家可以通過如上方式模擬,即從A頁面跳轉到A頁面也是情況1。此時如果內容還在快取時間之內,直接從瀏覽器獲取的內容,而不去服務端驗證。

訪問頁面http://item.jd.com/11056556.html,然後點選麵包屑中的HTTP權威指南時會跳轉到當前頁面,此時看到如下結果,頁面及頁面非同步載入的一些js、css、圖片都from cache了。

圖片描述

還有如通過瀏覽器歷史記錄進行前進後退時也會走from cache。本文是基於chrome 52.0.2743.116 m版本測試,不同瀏覽器行為可能存在差異。

Age

一般用於代理層(如CDN),大家在訪問京東一些頁面時會發現有一個Age響應頭,然後強制重新整理(Ctrl+F5)後會發現其不斷的變化;其表示此內容在代理層從快取到現在經過了多長時間,即在代理層快取了多長時間。

圖片描述

Vary

一般用於代理層(如CDN),用於代理層和瀏覽器協商什麼情況下使用哪個版本的快取內容(比如壓縮版和非壓縮版),即什麼情況下後續請求才能使用代理層快取的該版本內容,比如如下響應是告知瀏覽器Content-Encoding:gzip,即快取代理層快取了gzip版本的內容;那麼後續的請求在請求時Accept-Encoding頭部中包含gzip時才能使用改代理層快取。

圖片描述

Via

一般用於代理層(如CDN),表示訪問到最終內容經過了哪些代理層、用的什麼協議、代理層是否快取命中等;通過它可以進行一些故障診斷。

圖片描述

ETag

@RequestMapping("/cache/etag")
public ResponseEntity<String> cache(
      HttpServletRequest request,
      HttpServletResponse response,
      //瀏覽器驗證文件內容的實體 If-None-Match
      @RequestHeader (value = "If-None-Match", required = false) String ifNoneMatch) {

    //當前系統時間
    long now = System.currentTimeMillis();
    //文件可以在瀏覽器端/proxy上快取多久
    long maxAge = 10;

    String body = "<a href=''>點選訪問當前連結</a>";

    //弱實體
    String etag = "W/\"" + md5(body) + "\"";

    if(StringUtils.equals(ifNoneMatch, etag)) {
        return new ResponseEntity<String>(HttpStatus.NOT_MODIFIED);
    }

    DateFormat gmtDateFormat = new SimpleDateFormat("EEE, d MMM yyyy HH:mm:ss 'GMT'", Locale.US);
    MultiValueMap<String, String> headers = new HttpHeaders();

    //ETag http 1.1支援
    headers.add("ETag", etag); 
    //當前系統時間
    headers.add("Date", gmtDateFormat.format(new Date(now)));
    //文件生存時間 http 1.1支援
    headers.add("Cache-Control", "max-age=" + maxAge);
    return new ResponseEntity<String>(body, headers, HttpStatus.OK);
}

其中ETag用於傳送到服務端進行內容變更驗證的,而Catch-Control是用於控制快取時間的(瀏覽器、代理層等)。此處我們使用了弱實體W\”343sda”,弱實體(”343sda”)只要內容語義沒變即可,比如內容的gzip版和非gzip版可以使用弱實體驗證;而強實體指位元組必須完全一致(gzip和非gzip情況是不一樣的),因此建議首先選擇使用弱實體。nginx在生成etag時使用的演算法是Last-Modified + Content-Length計算的:

ngx_sprintf(etag->value.data,"\"%xT-%xO\"",

                                 r->headers_out.last_modified_time,

                                 r->headers_out.content_length_n)

到此簡單的基於文件修改時間和過期時間的快取控制就介紹完了,在內容型響應我們大多數根據內容的修改時間來進行快取控制,ETag根據實際需求而定(比如)。另外還可以使用html Meta標籤控制瀏覽器快取,但是對代理層快取無效,因此不建議使用。

總結

  1. 服務端響應的Last-Modified會在下次請求時以If-Modified-Since請求頭帶到服務端進行文件是否修改的驗證,如果沒有修改則返回304,瀏覽器可以直接使用快取內容;

  2. Cache-Control:max-age和Expires用於決定瀏覽器端內容快取多久,即多久過期,過期後則刪除快取重新從服務端獲取最新的;另外可以用於from cache場景;

  3. http/1.1規範定義的Cache-Control優先順序高於http/1.0規範定義的Expires;

  4. 一般情況下Expires=當前系統時間+快取時間(Cache-Control:max-age);

  5. http/1.1規範定義了ETag來通過文件摘要的方式控制。

Last-Modified與ETag同時使用時,瀏覽器在驗證時會同時傳送If-Modified-SinceIf-None-Match,按照http/1.1規範,如果同時使用If-Modified-SinceIf-None-Match則服務端必須兩個都驗證通過後才能返回304;且nginx就是這樣做的。因此實際使用時應該根據實際情況選擇。還有If-MatchIf-Unmodified-Since本文就不介紹了。

接下來我們看下如何使用nginx進行快取控制。

nginx快取設定

nginx提供了expires、etag、if-modified-since指令來進行瀏覽器快取控制。

expires

假設我們使用nginx作為靜態資源伺服器,此時可以使用expires進行快取控制。

       location /img {

         alias /export/img/;

         expires 1d;

       }

當我們訪問靜態資源時,如http://192.168.61.129/img/1.jpg,將得到類似如下的響應頭:

圖片描述

對於靜態資源會自動新增ETag,可以通過新增“etag off”指令禁止生成ETag。如果是靜態檔案Last-Modified是檔案的最後修改時間;Expires是根據當前服務端系統時間算出來的。如上nginx配置的計算邏輯(實際計算邏輯比這個多,具體參考官方文件):

if (expires == NGX_HTTP_EXPIRES_ACCESS ||r->headers_out.last_modified_time == -1) {

   max_age = expires_time;

   expires_time += now;

}

if-modified-since

此指令用於表示nginx如何拿服務端的Last-Modified和瀏覽器端的If-Modified-Since時間進行比較,預設“if_modified_since exact”表示精確匹配,也可以使用“if_modified_sincebefore”表示只要檔案的上次修改時間早於或等於瀏覽器短的If-Modified-Since時間,就返回304。

nginx proxy expires

使用nginx作為反向代理時,請求會先進入nginx,然後nginx將請求轉發給後端應用。如下圖所示:

首先配置upstream:

upstream backend_tomcat {

   server 192.168.61.1:9080 max_fails=10 fail_timeout=10s weight=5;

}

接著配置location:

location = /cache {

   proxy_pass http://backend_tomcat/cache$is_args$args;

}

接下來我們可以通過如http://192.168.61.129/cache?millis=1471349916709訪問nginx,nginx會將請求轉發給後端java應用。也就是說nginx只是做了相關的轉發(負載均衡),並沒有對請求和響應做什麼處理。

假設對後端返回的過期時間需要調整,可以新增expires指令到location:

location = /cache {

    proxy_pass http://backend_tomcat/cache$is_args$args;

    expires 5s;

}

然後再請求相關的URL,將得到如下響應:

圖片描述

過期時間相關的響應頭被expires指令更改了,但是Last-Modified是沒有變的。

即使我們更改了快取過期頭,但nginx本身沒有對這些內容做快取,每次請求還是要到後端驗證的,假設在過期時間內,這些驗證在nginx這一層驗證就可以了,不需要到後端驗證,這樣可以減少後端的很大壓力。即整體流程是:

  1. 瀏覽器發起請求,首先到nginx,nginx根據url在nginx本地查詢是否有文件快取;

  2. nginx沒有找到本地快取,則去後端獲取最新的文件,並放入到nginx本地快取中;返回200狀態碼和最新的文件給瀏覽器;

  3. nginx找到本地快取了,首先驗證文件是否過期(Cache-Control:max-age=5),如果過期則去後端獲取最新的文件,並放入nginx本地快取中,返回200狀態碼和最新的文件給瀏覽器;如果文件沒有過期,如果If-Modified-Since與快取文件的Last-Modified匹配,則返回300狀態碼給瀏覽器,否則返回200狀態碼和最新的文件給瀏覽器。

即內容不需要動態(計算、渲染等)速度更快,內容越接近於使用者速度越快。像apache traffic server、squid、varnish、nginx等技術都可以來進行內容快取。還有CDN就是用來加速使用者訪問的:

圖片描述

即使用者首先訪問到全國各地的CDN節點(使用如ATS、Squid實現),如果CDN沒命中,會回源到中央nginx叢集,該叢集如果沒有命中快取(該叢集的快取不是必須的,要根據實際命中情況等決定),最後回源到後端應用叢集。

像我們商品詳情頁的一些服務就大量使用了nginx快取減少回源到後端的請求量,從而提升訪問速度。可以參考《構建需求響應式億級商品詳情頁》《京東商品詳情頁服務閉環實踐》《應用多級快取模式支撐海量讀服務》

nginx代理層快取

http模組配置:

proxy_buffering on;
proxy_buffer_size 4k;
proxy_buffers 512 4k;
proxy_busy_buffers_size 64k;
proxy_cache_path /export/cache/proxy_cachelevels=1:2 keys_zone=cache:512m inactive=5m max_size=8g use_temp_path=off;
#proxy timeout
proxy_connect_timeout 3s;
proxy_read_timeout 5s;
proxy_send_timeout 5s;

其中紅色部分是proxy_cache_path指令相關配置:

levels=1:2:表示建立兩級目錄結構,比如/export/cache/proxy_cache/7/3c/,將所有檔案放在一級目錄結構中如果檔案量很大會導致訪問檔案慢;

keys_zone=cache:512m:設定儲存所有快取key和相關資訊的共享記憶體區,1M大約能儲存8000個key;

inactive=5m:inactive指定被快取的內容多久不被訪問將從快取中移除,以保證內容的新鮮;預設10分鐘;

max_size=8g:最大快取閥值,“cachemanager”程式會監控最大快取大小,當快取達到該閥值,該程式將從快取中移除最近最少使用的內容;

use_temp_path:如果為on,則內容首先被寫入臨時檔案(proxy_temp_path),然後重新命名到proxy_cache_path指定的目錄;如果設定為off,則內容直接被寫入到proxy_cache_path指定的目錄,如果需要cache建議off,該特性是1.7.10提供的。

location配置

location = /cache {

     proxy_cache cache;

     proxy_cache_key $scheme$proxy_host$request_uri;

     proxy_cache_valid 200 5s;

     proxy_pass http://backend_tomcat/cache$is_args$args;

    add_header cache-status $upstream_cache_status;

}

快取相關配置:

proxy_cache:指定使用哪個共享記憶體區域儲存快取鍵和相關資訊;

proxy_cache_key:設定快取使用的key,預設為訪問的完整URL,根據實際情況設定快取key;

proxy_cache_valid:為不同的響應狀態碼設定快取時間;如果是proxy_cache_valid 5s則200、301、302響應將被快取。

proxy_cache_valid

proxy_cache_valid不是唯一設定快取時間的,還可以通過如下方式(優先順序從上到下):

  1. 以秒為單位的“X-Accel-Expires”響應頭來設定響應快取時間;

  2. 如果沒有“X-Accel-Expires”,可以根據“Cache-Control”、“Expires”來設定響應快取時間;

  3. 否則使用proxy_cache_valid設定的快取時間;

如果響應頭包含Cache-Control:private/no-cache/no-storeSet-Cookie或者只有一個Vary響應頭且其值為*,則響應內容將不會被快取。可以使用proxy_ignore_headers來忽略這些響應頭。

add_headercache-status $upstream_cache_status在響應頭中新增快取命中的狀態:

HIT:快取命中了,直接返回快取中內容,不回源到後端;

MISS:快取沒有命中,回源到後端獲取最新的內容;

EXPIRED:快取命中但過期了,回源到後端獲取最新的內容;

UPDATING:快取已過期但正在被別的nginx程式更新;配置了proxy_cache_use_staleupdating指令時會存在該狀態;

STALE:快取已過期,但因後端服務出現了問題(比如後端服務掛了)返回過期的響應;配置瞭如proxy_cache_use_stale error timeout指令後會存在該狀態;

REVALIDATED:啟用proxy_cache_revalidate指令後,當快取內容過期時nginx通過一次If-Modified-Since的請求頭去驗證快取內容是否過期,此時會返回該狀態;

BYPASS:proxy_cache_bypass指令有效時強制回源到後端獲取內容,即使已經快取了。

proxy_cache_min_uses

用於控制請求多少次後響應才被快取;預設“proxy_cache_min_uses 1;”,如果快取熱點比較集中、儲存有限,可以考慮修改該引數以減少快取數量和寫磁碟次數。

proxy_no_cache

用於控制什麼情況下響應將不被快取;比如配置“proxy_no_cache $args_nocache”,如果帶的引數值至少有一個不為空或者0,則響應將不被快取。

proxy_cache_bypass

類似於proxy_no_cache,但是其控制什麼情況不從快取中獲取內容,而是直接到後端獲取內容;如果命中則$upstream_cache_status為BYPASS

proxy_cache_use_stale

當對快取內容的過期時間不敏感,或者後端服務出問題時即使快取的內容不新鮮也總比返回錯誤給使用者強(類似於託底),此時可以配置該引數,如“proxy_cache_use_stale error timeout http_500 http_502 http_503http_504”:即如果超時、後端連線出錯、500、502、503等錯誤時即使快取內容已過期也先返回給使用者,此時$upstream_cache_status為STALE;還有一個updating表示快取已過期但正在被別的nginx程式更新將先返回過期的內容,此時$upstream_cache_status為UPDATING。

proxy_cache_revalidate

當快取過期後,如果開啟了proxy_cache_revalidate,則會發出一次If-Modified-SinceIf-None-Match條件請求,如果後端返回304則會得到兩個好處:節省頻寬和減少寫磁碟的次數;此時$upstream_cache_status為REVALIDATED。

proxy_cache_lock

當多個客戶端同時請求同一份內容時,如果開啟proxy_cache_lock(預設off)則只有一個請求被髮送至後端;其他請求將等待該內容返回;當第一個請求返回時,其他請求將從快取中獲取內容返回;當第一個請求超過了proxy_cache_lock_timeout超時時間(預設5s),則其他請求將同時請求到後端來獲取響應,且響應不會被快取(在1.7.8版本之前是被快取的);啟用proxy_cache_lock可以應對Dog-pile effect(當某個快取失效時,同時又大量相同的請求沒命中快取,而同時請求到後端,從而導致後端壓力太大,此時限制一個請求去拿即可)。

proxy_cache_lock_age是1.7.8新新增的,如果在proxy_cache_lock_age指定的時間內(預設5s),最後一個傳送到後端進行新快取構建的請求還沒有完成,則下一個請求將被髮送到後端來構建快取(因為1.7.8版本之後,proxy_cache_lock_timeout超時之後返回的內容是不快取的,需要下一次請求來構建響應快取)。

清理快取

有時候快取的內容是錯誤的,需要手工清理,nginx plus版本提供了purger的功能,但是對於非plus版本的nginx可以考慮使用ngx_cache_purgehttps://github.com/FRiCKLE/ngx_cache_purge)模組進行清理快取,如:

location ~ /purge(/.*) {

   allow              127.0.0.1;

   deny               all;

   proxy_cache_purge  cache$1$is_args$args;

}

注意該方法應該只允許內網可以訪問,如有必要可以考慮需要密碼才能訪問。

到此代理層快取就介紹完了,通過代理層快取可以解決很多問題,可以參考《京東商品詳情頁服務閉環實踐》《京東商品詳情頁服務閉環實踐》

一些經驗

  1. 只快取200狀態碼的響應,像302等要根據實際場景決定(比如當系統出錯時自動302到錯誤頁面,此時快取302就不對了);

  2. 有些頁面不需要強一致,可以進行幾秒的快取(比如商品詳情頁展示的庫存,可以快取幾秒鐘,短時間的不一致對於使用者來說是沒有影響的);

  3. js/css/image等一些內容快取時間可以設定的很久(比如1個月甚至1年),通過在頁面修改版本來控制過期,不建議隨機數方式;

  4. 假設商品詳情頁非同步載入的一些資料使用的是Last-Modified進行的過期控制,而服務端做了邏輯修改但內容是沒有修改的,即內容的最後修改時間沒變,如果想過期這些非同步載入的資料,可以考慮在商品詳情頁新增非同步載入資料的版本號,通過新增版本號來載入最新的資料,或者將Last-Modified時間加1來解決;而這種情況比較適合使用ETag;

  5. 商品詳情頁非同步載入的一些資料,可以考慮更長時間的快取(比如1個月而不是幾分鐘),可以通過MQ將修改時間推送商品詳情頁,從而實現按需過期資料;

  6. 服務端考慮使用記憶體快取(tmpfs)、SSD快取;考慮服務端負載均衡演算法,如一致性雜湊提升快取命中率;

  7. 快取KEY要合理設計(比如去掉引數/排序引數保證代理層快取命中),要有清理快取的工具,出問題時能快速清理掉問題KEY;

  8. AB測試/個性化需求時應禁用掉瀏覽器快取,但考慮服務端快取;

  9. 為了便於查詢問題,一般會在響應頭中新增源伺服器資訊,如訪問京東商品詳情頁會看到ser響應頭,此頭儲存了源伺服器IP,以便出現問題時知道哪臺伺服器有問題。

相關閱讀:

相關文章