善用 HTTP 緩存利器 Varnish
善用HTTP緩存利器-Varnish
在日常的WEB開發中,我們會經常性的使用緩存,而緩存的方式有多種多樣(如數據庫緩存,接口緩存,函數緩存等等),一般而言,越接近使用者緩存越高效。對于 REST 架構的WEB開發,使用 HTTP 緩存則是提升系統性能的首要手段。
本文將通過講解如何使用 varnish ,以及如何配置才能讓 varnish 變得更加通用合理。
強悍的性能表現
Varnish has a modern architecture and is written with performance in mind. It is usually bound by the speed of the network, effectively turning performance into a non-issue. You get to focus on how your web applications work and you can allow yourself, to some degree, to care less about performance and scalability. 一直以來,我都覺得這句話極其簡單的概括了 varnish 性能上的強悍,在我的實踐中,對于 HTTP 緩存的處理的確瓶頸是在網絡上,而非 varnish 或者其它的硬件瓶頸。
我使用自己的 HP Gen8 做了一次測試(未對系統做調做優,也只是本機壓本機),請求5KB(gzip之后)的數據, siege -c 2000 -t 1m "http://127.0.0.1:8001/" ,CPU Usage 在5%以下
Transactions: 238538 hits
Availability: 100.00 %
Elapsed time: 59.81 secs
Data transferred: 1171.79 MB
Response time: 0.00 secs
Transaction rate: 3988.26 trans/sec
Throughput: 19.59 MB/sec
Concurrency: 5.06
Successful transactions: 238538
Failed transactions: 0
Longest transaction: 0.20
Shortest transaction: 0.00
緩存TTL的創建
使用 varnish ,首先需要了解緩存ttl的創建,以前一直沒有很清晰的了解,在官方的文檔中也沒看到相關的說明(有可能我遺漏了),后面看了一下 varnish 的代碼,找到以下的代碼,從代碼中能了解到ttl是怎么創建的:
void
RFC2616_Ttl(struct busyobj bo, double now, double t_origin,
float ttl, float grace, float *keep)
...
default:
ttl = -1.;
break;
case 302: / Moved Temporarily /
case 307: / Temporary Redirect /
/
* https://tools.ietf.org/html/rfc7231#section-6.1
*
* Do not apply the default ttl, only set a ttl if Cache-Control
* or Expires are present. Uncacheable otherwise.
*/
*ttl = -1.;
/* FALL-THROUGH */
case 200: / OK /
case 203: / Non-Authoritative Information /
case 204: / No Content /
case 300: / Multiple Choices /
case 301: / Moved Permanently /
case 304: / Not Modified - handled like 200 /
case 404: / Not Found /
case 410: / Gone /
case 414: / Request-URI Too Large /
/*
* First find any relative specification from the backend
* These take precedence according to RFC2616, 13.2.4
*/
if ((http_GetHdrField(hp, H_Cache_Control, "s-maxage", &p) ||
http_GetHdrField(hp, H_Cache_Control, "max-age", &p)) &&
p != NULL) {
if (*p == '-')
max_age = 0;
else
max_age = strtoul(p, NULL, 0);
*ttl = max_age;
break;
}
/* No expire header, fall back to default */
if (h_expires == 0)
break;
/* If backend told us it is expired already, don't cache. */
if (h_expires < h_date) {
*ttl = 0;
break;
}
if (h_date == 0 ||
fabs(h_date - now) < cache_param->clock_skew) {
/*
* If we have no Date: header or if it is
* sufficiently close to our clock we will
* trust Expires: relative to our own clock.
*/
if (h_expires < now)
*ttl = 0;
else
*ttl = h_expires - now;
break;
} else {
/*
* But even if the clocks are out of whack we can still
* derive a relative time from the two headers.
* (the negative ttl case is caught above)
*/
*ttl = (int)(h_expires - h_date);
}
}
...
}</code></pre>
從上面的代碼可以看出,只對于特定的 HTTP Status Code ,才會根據 Cache-Control 或者 Expires 來設置緩存時間,非上述狀態碼的響應,就算是設置了也無效。 Cache-Control 中可以設置max-age與s-maxage分別配置客戶端緩存與 varnish 緩存ttl的不同(至于后面的Expires我不使用該字段,也不建議大家使用)。
緩存KEY的生成與配置
varnish的緩存是根據什么來保存的,怎么區分是否同一個緩存?對于這個問題,最簡單的方式就是直接上 vcl_hash 的配置說明:
sub vcl_hash{
hash_data(req.url);
if (req.http.host) {
hash_data(req.http.host);
} else {
hash_data(server.ip);
}
return (lookup);
}
由上面的配置可以看出, varnish 是使用請求的url + 請求的HTTP頭中的host,如果沒有host,則取服務器的ip。這里需要注意,盡量保證經過 varnish 的請求都有Host,如果是直接取 server.ip ,對于多backend的應用,就會導致每個backend的緩存都會保存一份。當然如果你能保證該 varnish 只是一個應用程序使用,只需要根據 req.url 部分就能區分,那么可以精簡 vcl_hash 的判斷(不建議):
sub vcl_hash{
hash_data(req.url);
return (lookup);
}
也可以增加更多的字段來生成緩存key,如根據不同的user-agent來生成不同的緩存:
sub vcl_hash{
hash_data(req.url);
hash_data(req.http.User-Agent);
if (req.http.host) {
hash_data(req.http.host);
} else {
hash_data(server.ip);
}
return (lookup);
}
知道 varnish 的緩存key生成方式之后,下面來看以下問題:
- /books?type=it&limit=2 與 /books?limit=2&type=it 這兩個url是否使用同一份緩存
- 如果有人惡意的使用不同的參數,那么是不是導致 varnish 的緩存會一直在增加(后面會說我曾經遇到過的場景)
對于第一個問題,兩個url被認為是不相同的,使用的緩存不是同一份,那么這個應該怎么解決呢? varnish 提供了 querysort 的函數,使用該函數在 vcl_recv 中將 req.url 重新調整則可。
那么第二個問題呢? varnish 上為了保證配置文件的通用性,不應該對請求的參數做校驗,所以我使用的方式是在后端對參數做嚴格的校驗,不符合的參數(有多余的參數)都直接響應失敗。
注:大家是否都想去試試通過增加時間戳的方式請求別人的 varnish ,把別人的 varnish 擠爆?如果大家想試,最好試自己的 varnish 就好,大家可能會發現,響應的請求數據大部分才幾KB,過期時間也不會很長,內存又大,還沒擠完,舊的就已過過期了。^-^
使用過程中不當的配置方式
使用 varnish 做緩存來提升系統的并發能力,它配置簡單,性能強悍,因此很多后端開發者都喜歡使用它,但是很多人都沒有真正的使用好,配置文件復雜且無法通用
-
使用了非通用的配置來指定url是否可以緩存
我在最開始使用 varnish 的時候,在 vcl_recv 中判斷 req.url ~ "/users/" 則 pass ,每次配置的時候都要和后端開發定義好一系列的規則來判斷請求是否能緩存,規則越來越多,越來越復雜,最后只能是無法維護,每次修改心驚膽顫,相看兩生厭。
-
緩存的時間使用 default_ttl ,而非通過響應頭的 Cache-Control 來定義
最開始的時候與后端開發定義好, defualt_ttl 為300s,覺得這能滿足了普遍的情況,慢慢各類的緩存請求越來越多而且各自對緩存時間的要求不一,發現 default_ttl 已經基本沒有意義了,還容易因為后端未設置好,把本不該緩存的請求緩存了(可以緩存的請求未緩存,只是性能降低,把不能緩存的請求緩存了,就有可能導致某些用戶數據出錯了),最后設置為0,緩存時間全部通過接口響應的 Cache-Control 來調整,未設置的則以不可緩存請求來處理
-
對于max-age較長的響應(如靜態文件),未合理使用s-maxage
對于靜態文件(文件名中帶有版本號,jquery-1.11.js),希望在客戶端緩存的時間越長越好,因此后端響應該請求的時候設置了一年的緩存時間: max-age=31536000 。本來這是挺合理的,但是因為該請求也經過了 varnish 而被 varnish 緩存起來了,一開始我也沒發現有什么不好之處。后來有一天 varnish 占用的內存一直在漲,才發現原來有人惡意請求靜態文件,通過添加后綴 ?t=xxxx 這樣,導致請求相同的靜態文件,但是由于url不一致,每次都生成了新的緩存,一直漲一直漲...后端調整代碼,對于響應 max-age 超過3600的,要求設置 s-maxage 。(本來更應該是后端對接口參數做更嚴格的檢查,如果有多余參數返回出錯400)
打造更通用合理的配置
不可緩存的請求的處理
varnish 中判斷請求不可緩存的方式有兩種,一種是在 vcl_recv 處理函數中定義規則對哪些HTTP請求不做緩存,還有一種就是在 vcl_backend_response 設置該請求的響應為 uncacheable (hit_for_pass),并設置 ttl 。這兩種方式中,第一種方式效率更高,我一開始使用的時候,經常是每多一個(一類)請求,就增加一條判斷(上面所說的不當配置),如:
sub vcl_recv {
...
if(req.url ~ "/users/" || req.url ~ "/fav/book") {
return (pass);
}
...
}</code></pre>
后來發現這種做法實在是太麻煩了,在使用 varnish 時,希望多個項目可以共享,因此配置文件是公共的,每個項目的url規則不一,配置越來越多,簡直有點無從管理了。后續開始偷懶,調整為后一種方式,要求后端對所有響應的請求都必須設置 Cache-Control 字段,通過該字段來判斷該請求是否可用,配置文件如下:
sub vcl_backend_response {
...
The following scenarios set uncacheable
if (beresp.ttl <= 0s ||
beresp.http.Set-Cookie ||
beresp.http.Surrogate-Control ~ "no-store" ||
(!beresp.http.Surrogate-Control &&
beresp.http.Cache-Control ~ "no-cache|no-store|private") ||
beresp.http.Vary == "*"){
# Hit-For-Pass
set beresp.uncacheable = true;
set beresp.ttl = 120s;
set beresp.grace = 0s;
return (deliver);
}
...
}</code></pre>
通過各后端配置HTTP響應頭來判定緩存的使用問題,使用了一段時間,并沒有發現有什么異常之處,但是總覺得這種判斷有點事后補救的方式,因為很多請求在使用的時候就已經知道該請求是不能緩存的,因此完善了一下 vcl_recv 的配置,調整為:
sub vcl_recv {
...
no cache request
if(req.http.Cache-Control == "no-cache" || req.url ~ "\?cache=false" || req.url ~ "&cache=false"){
return (pass);
}
...
}</code></pre>
調整之后,提供了通用的方式可以直接在 vcl_recv 階段直接不使用緩存,在開發者調用接口的時候,如果確認該請求是不可緩存的,則設置HTTP請求頭的 Cache-Control:no-cache (建議使用此方式)或者增加url query的方式,經過此調用之后,對于不可緩存的請求的處理已經是一種通用的模式, varnish 對接的是多少個應用也不再需要重復配置了。對于上面這樣的配置,存在兩個問題(如果有惡意調用或者調用出錯):
-
對于不可緩存請求,如果請求時沒有添加 Cache-Control:no-cache 或者 cache=false 的query參數,會導致無法在 vcl_recv 階段做判斷
對于這個問題,我使用的解決方案是:對于不可緩存請求的處理,在后端應用程序的處理函數中,對于 Cache-Control:no-cache 或者 cache=false 的query參數做校驗,如果無此參數,則返回出錯信息
-
對于可緩存請求,如果請求時添加 Cache-Control:no-cache 或者 cache=false 的query參數,會導致無法在 vcl_recv 階段判斷為是不可緩存請求,直接 pass 到 backend
對于這個問題,我使用的解決方案和上面的類似:對于可緩存請求,在后端應用程序的處理函數中,對于 Cache-Control:no-cache 或者 cache=false 的query參數做校驗,如果有此參數,則返回出錯信息
使用m-stale提升過期數據的響應
在真實使用的環境中,數據在剛過期的期間(如2秒以內),為了更好的響應速度,我希望能夠直接使用剛過期數據返回(因為剛過期,時效性還是能保證的),同時去更新緩存的數據,因此調整 vcl_hit 的配置,從 Cache-Control 中獲取 m-stale :
sub vcl_hit {
...
backend is healthy
if (std.healthy(req.backend_hint)) {
# set the stale
if(obj.ttl + std.duration(std.integer(regsub(obj.http.Cache-Control, "[\s\S]*m-stale=(\d)+[\s\S]*", "\1"), 2) + "s", 2s) > 0s){
return (deliver);
}
}
...
}</code></pre>
當backend掛了的時候,暫時使用過期的緩存數據響應
varnish 可以配置如果當 backend 掛了的時候,使用過期的數據先響應(因為一般緩存的數據都是用于首頁之類的展示,與用戶無關的數據),這樣可以避免所有接口都出錯,用戶看到空白出錯頁面
sub vcl_hit {
...
if (std.healthy(req.backend_hint)) {
...
} else if (obj.ttl + obj.grace > 0s) {
# Object is in grace, deliver it
# Automatically triggers a background fetch
return (deliver);
}
...
}</code></pre>
后端響應不要設置過長的緩存時間
對于這個問題,首先需要明確一點是: varnish 是用于提高多并發下的性能,盡量不要把它當成是提升接口的性能的工具(如某個接口的響應時間需要5秒,通過一個定時程序去定時調用,讓它一直在varnish中有緩存),所以在使用時首先要保障的是后端接口的性能是高效的。
對于數據的緩存,我剛開始使用的時候,也是有多長時間設置多長時間的,后來發現,其實完全沒有這個必要,請求 /books ,如果設置 Cache-Control:max-age=3600 ,那么的確是3600秒才請求backend一次,但是如果我設置為 Cache-Control:max-age=3600, s-maxage=60 ,對于 backend 每60秒會請求一次與每個小時請求一次對其性能壓力并沒有什么區別。那么后一種配置有什么好處呢?首先避免占用過高的內存使用(如果該接口并非頻繁請求),其次我自己在使用過程中的確出現過由于人為原因配置了錯誤的數據,導致接口緩存數據錯誤,需要手工清除緩存的情況,找出所有可能影響的url(影響的url有可能很多,無法保證是否遺漏)一一清除,這是很浪費時間而且容易出錯的事情,而使用短時間的緩存則很快會刷新,不用手工清理。(我自己的使用實踐是基本不會設置s-maxage超過300s)
合理使用Hit-For-Pass
當有并發的請求到 varnish 時,如果在 vcl_recv 階段無法判斷該請求是否可以緩存,那么只會有一個請求發送到 backend ,其它的請求進入隊列等待。
-
請求后端響應的結果是該請求可以緩存
那么將隊列中等待的請求將會將響應的數據復制,并一一響應給其對應的客戶端。
-
請求的后端響應是不可緩存的(Set-Cookie、max-age=0 等由服務器端返回的數據設置不能緩存的)
那么將隊列中等待的所有請求都會發送到 backend ,各自根據響應返回給其對應的客戶端。這里就存在了一個問題,如果第一次請求響應很慢,那么有可能會堆積較多的請求,到時會一并請求到后端,導致后端的壓力增大,這也是上面所說,如果不能緩存的請求,盡量添加HTTP請求頭 Cache-Control:no-cache 的其中一個原因。假如在現有系統,無法做大的改造,那么如果不能緩存的請求,只能使用后端設置的方式,那么有沒辦法能優化性能呢?是有的,這就是 Hit-For-Pass ,看如下配置:
sub vcl_backend_response {
...
The following scenarios set uncacheable
if (beresp.ttl <= 0s ||
beresp.http.Set-Cookie ||
beresp.http.Surrogate-Control ~ "no-store" ||
(!beresp.http.Surrogate-Control &&
beresp.http.Cache-Control ~ "no-cache|no-store|private") ||
beresp.http.Vary == "*"){
# Hit-For-Pass
set beresp.uncacheable = true;
set beresp.ttl = 120s;
set beresp.grace = 0s;
return (deliver);
}
...
}</code></pre>
Hit-For-Pass 的請求是會緩存在 varnish 中,但是當命中緩存的時候,不是直接將數據返回,而是使用 pass 的方式,把請求轉向 backend ,那么在后續相同的請求進來的時候,就可以快速的判斷該請求是 pass 的。下面我們再來考慮一下問題,請求本應該是可以緩存的,但是因為后端出錯(數據庫掛了,或者其它原因),導致接口的響應狀態碼為 500 ,那么所有相同的請求都會轉向 backend ,那么壓力就會增大,有可能導致后端程序直接掛了(因為請求有可能因為出錯的原因突然并發量很大),因此還需要后端程序做好流控之類的限制。
出錯的請求也做緩存
可能會有一些這樣的場景,如果接口出錯了,也希望直接把出錯的響應直接緩存,后續使用出錯的數據響應給客戶端,那么是否也可以做這樣的調整呢?是的,可以做這樣的調整(但我不建議),看如下配置:
sub vcl_backend_response {
if (beresp.status == 500 && beresp.http.Force-Caching) {
set beresp.uncacheable = false;
set beresp.ttl = 120s;
return (deliver);
}
...
}</code></pre>
在 vcl_backend_response 開始位置增加,如果 status == 500 ,并且響應頭設置了 Force-Caching ,那么將該請求緩存設置為可以緩存 120s ,后續相同的請求在 120s 時間內,將使用出錯的數據響應,可以減緩出錯時后端應用程序的壓力。
下面列舉使用上述配置之后,不同類型的HTTP請求響應流程
不可緩存的請求
-
POST/PUT等請求 vcl_recv --> vcl_hash --> vcl_pass --> vcl_backend_fetch --> vcl_backend_response --> vcl_deliver
-
請求頭中Cache-Control:no-cache或者url中query參數帶有cache=false vcl_recv --> vcl_hash --> vcl_pass --> vcl_backend_fetch --> vcl_backend_response --> vcl_deliver
-
HTTP Status Code 不屬于 202、203、204、300、301、302、304、307、404、410、414,響應頭設置Cache-Control也無用 vcl_recv --> vcl_hash --> vcl_miss --> vcl_backend_fetch --> vcl_backend_response (在此會設置hit-for-pass的ttl)--> vcl_deliver
-
Set-Cookie、max-age=0 等由服務器端返回的數據設置不能緩存的, vcl_recv --> vcl_hash --> vcl_miss --> vcl_backend_fetch --> vcl_backend_response (在此會設置hit-for-pass的ttl)--> vcl_deliver
可緩存的GET/HEAD請求
GET /cache/max-age/60 返回數據設置Cache-Control:public, max-age=60
-
無緩存,數據從backend中拉取 vcl_recv --> vcl_hash --> vcl_miss --> vcl_backend_fetch --> vcl_backend_response --> vcl_deliver
-
有緩存且未過期,從緩存中返回,X-Hits + 1 vcl_recv --> vcl_hash --> vcl_hit --> vcl_deliver
-
有緩存且已過期,backend正常,過期時間未超過stale(3s),從緩存中返回,且從backend中拉取數據更新緩存 vcl_recv --> vcl_hash --> vcl_hit --> vcl_deliver --> vcl_backend_fetch --> vcl_backend_response
-
有緩存且已過期(也超過stale),backend正常,從backend中拉取數據更新緩存 vcl_recv --> vcl_hash --> vcl_miss --> vcl_backend_fetch --> vcl_backend_response --> vcl_deliver
-
有緩存且已過期,backend掛起,過期時間未超過grace(60s),從緩存中返回 vcl_recv --> vcl_hash --> vcl_hit --> vcl_deliver --> vcl_backend_fetch
-
有緩存且已過期,backend掛起,過期時間超過grace(60s),Backend fetch failed vcl_recv --> vcl_hash --> vcl_miss --> vcl_backend_fetch --> vcl_deliver
varnish-generator
遵循上面所說的可緩存與不可緩存的請求配置處理,我定義好了一套 varnish 的模板配置,每次新的項目啟動之前,都要求實現上面所說的規則,在正式上線的時候,只需要增加 backend 的配置就可以了,擺脫了每個項目都要各自配置自己的 varnish ,而且后期維護不方便的局面。對此我寫了一個 node.js 的模塊 varnish-generator ,根據 backend 配置來生成對應的 vcl ,以后只需要關注如下的服務器配置:
{
"name": "varnish-test",
"stale": 2,
"directors": [
{
"name": "timtam",
"prefix": "/timtam",
"director": "fallback",
"backends": [
{
"ip": "127.0.0.1",
"port": 3000
},
{
"ip": "127.0.0.1",
"port": 3010
}
]
},
{
"name": "dcharts",
"prefix": "/dcharts",
"host": "dcharts.com",
"director": "hash",
"hashKey": "req.http.cookie",
"backends": [
{
"ip": "127.0.0.1",
"port": 3020,
"weight": 5
},
{
"ip": "127.0.0.1",
"port": 3030,
"weight": 3
}
]
},
{
"name": "vicanso",
"host": "vicanso.com",
"director": "random",
"backends": [
{
"ip": "127.0.0.1",
"port": 3040,
"weight": 10
},
{
"ip": "127.0.0.1",
"port": 3050,
"weight": 5
}
]
},
{
"name": "aslant",
"backends": [
{
"ip": "127.0.0.1",
"port": 8000
}
]
}
]
}
建議
對于緩存的使用,一開始不要過早的加入,特別注意是使用緩存要先考慮清楚是會有可能緩存了不可緩存的內容。緩存能提升系統性能,但也有可能導致數據錯誤了,請謹慎。最后,如果大家有什么疑問可以在 issue 中提出。
來自:https://github.com/vicanso/articles/blob/master/varnish-suggestion.md