聊聊高并發系統之HTTP緩存
簡介
最近遇到很多人來咨詢我關于瀏覽器緩存的一些問題,而這些問題都是類似的,因此總結本文來解答以后遇到類似問題的朋友。
因本文主要以瀏覽器緩存場景介紹,所以非瀏覽器場景下的一些用法本文不會介紹,而且本文以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-cache和Pragma: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-Since和If-None-Match,按照http/1.1規范,如果同時使用If-Modified-Since和If-None-Match則服務端必須兩個都驗證通過后才能返回304;且nginx就是這樣做的。因此實際使用時應該根據實際情況選擇。還有If-Match和If-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;
- }
此指令用于表示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:
- upstream backend_tomcat {
- server 192.168.61.1:9080 max_fails=10 fail_timeout=10s weight=5;
- }
接下來我們可以通過如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-store、Set-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-Since和If-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_purge(https://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,以便出現問題時知道哪臺服務器有問題。
【本文是51CTO專欄作者張開濤的原創文章,作者微信公眾號:開濤的博客( kaitao-1234567)】