web 應(yīng)用中常用的各種 cache詳解
本文以Nginx,Rails,Mysql,Redis作為例子,換成其他web服務(wù)器,語(yǔ)言,數(shù)據(jù)庫(kù),緩存服務(wù)都是類(lèi)似的。
以下是3層的示意圖,方便后續(xù)引用:
1. 客戶(hù)端緩存
一個(gè)客戶(hù)端經(jīng)常會(huì)訪(fǎng)問(wèn)同一個(gè)資源,比如用瀏覽器訪(fǎng)問(wèn)網(wǎng)站首頁(yè)或查看同一篇文章,或用app訪(fǎng)問(wèn)同一個(gè)api,如果該資源和他之前訪(fǎng)問(wèn)過(guò)的沒(méi)有任何改變,就可以利用http規(guī)范中的304 Not Modified 響應(yīng)頭(http://www.w3.org/Protocols/rfc2616/rfc2616-sec10.html#sec10.3.5),直接用客戶(hù)端的緩存,而無(wú)需在服務(wù)器端再生成一次內(nèi)容。
在Rails里面內(nèi)置了fresh_when這個(gè)方法,一行代碼就可以完成:
class ArticlesController def show @article = Article.find(params[:id]) fresh_when :last_modified => @article.updated_at.utc, :etag => @article end end
下次用戶(hù)再訪(fǎng)問(wèn)的時(shí)候,會(huì)對(duì)比request header里面的If-Modified-Since和If-None-Match,如果相符合,就直接返回304,而不再生成response body。
但是這樣會(huì)遇到一個(gè)問(wèn)題,假設(shè)我們的網(wǎng)站導(dǎo)航有用戶(hù)信息,一個(gè)用戶(hù)在未登陸專(zhuān)題訪(fǎng)問(wèn)了一下,然后登陸以后再訪(fǎng)問(wèn),會(huì)發(fā)現(xiàn)頁(yè)面上顯示的還是未登陸狀態(tài)?;蛘咴赼pp訪(fǎng)問(wèn)一篇文章,做了一下收藏,下次再進(jìn)入這篇文章,還是顯示未收藏狀態(tài)。解決這個(gè)問(wèn)題的方法很簡(jiǎn)單,將用戶(hù)相關(guān)的變量也加入到etag的計(jì)算里面:
fresh_when :etag => [@article.cache_key, current_user.id] fresh_when :etag => [@article.cache_key, current_user_favorited]
另外提一個(gè)坑,如果nginx開(kāi)啟了gzip,對(duì)rails執(zhí)行的結(jié)果進(jìn)行壓縮,會(huì)將rails輸出的etag header干掉,nginx的開(kāi)發(fā)人員說(shuō)根據(jù)rfc規(guī)范,對(duì)proxy_pass方式處理必須這樣(因?yàn)閮?nèi)容改變了),但是我個(gè)人認(rèn)為沒(méi)這個(gè)必要,于是用了粗暴的方法,直接將src/http/modules/ngx_http_gzip_filter_module.c這個(gè)文件里面的這行代碼注釋掉,然后重新編譯nginx:
//ngx_http_clear_etag(r);
或者你可以選擇不改變nginx源代碼,將gzip off掉,將壓縮用Rack中間件來(lái)處理:
config.middleware.use Rack::Deflater
除了在controller里面指定fresh_when以外,rails框架默認(rèn)使用Rack::ETag middleware,它會(huì)自動(dòng)給無(wú)etag的response加上etag,但是和fresh_when相比,自動(dòng)etag能夠節(jié)省的只是客戶(hù)端時(shí)間,服務(wù)器端還是一樣會(huì)執(zhí)行所有的代碼,用curl來(lái)對(duì)比一下。
Rack::ETag自動(dòng)加入etag:
curl -v http://localhost:3000/articles/1 < Etag: "bf328447bcb2b8706193a50962035619" < X-Runtime: 0.286958 curl -v http://localhost:3000/articles/1 --header 'If-None-Match: "bf328447bcb2b8706193a50962035619"' < X-Runtime: 0.293798 用fresh_when: curl -v http://localhost:3000/articles/1 --header 'If-None-Match: "bf328447bcb2b8706193a50962035619"' < X-Runtime: 0.033884
2. Nginx緩存
有一些資源可能會(huì)被調(diào)用很多,又無(wú)關(guān)用戶(hù)狀態(tài),并且很少改變,比如新聞app上的列表api,購(gòu)物網(wǎng)站上ajax請(qǐng)求分類(lèi)菜單,可以考慮用Nginx來(lái)做緩存。
主要有2種實(shí)現(xiàn)方法:
A. 動(dòng)態(tài)請(qǐng)求靜態(tài)文件化
在rails請(qǐng)求完成以后,將結(jié)果保存成靜態(tài)文件,后續(xù)請(qǐng)求就會(huì)直接由nginx提供靜態(tài)文件內(nèi)容,用after_filter來(lái)實(shí)現(xiàn)一下:
class CategoriesController < ActionController::Base after_filter :generate_static_file, :only => [:index] def index @categories = Category.all end def generate_static_file File.open(Rails.root.join('public', 'categories'), 'w') do |f| f.write response.body end end end
另外我們需要在任何分類(lèi)更新的時(shí)候,刪除掉這個(gè)文件,避免緩存不刷新的問(wèn)題:
class Category < ActiveRecord::Base after_save :delete_static_file after_destroy :delete_static_file def delete_static_file File.delete Rails.root.join('public', 'categories') end end
Rails 4之前,處理這種生成靜態(tài)文件緩存可以用內(nèi)置的caches_page, rails 4之后變成了一個(gè)獨(dú)立gem actionpack-page_caching,和手工代碼對(duì)比一下,
class CategoriesController < ActionController::Base caches_page :index def update #... expire_page action: 'index' end end
如果只有一臺(tái)服務(wù)器,這個(gè)方法簡(jiǎn)單又實(shí)用,但是如果有多臺(tái)服務(wù)器,就會(huì)出現(xiàn)更新分類(lèi)只能刷新自己本身這臺(tái)服務(wù)器緩存的問(wèn)題,可以用nfs來(lái)共享靜態(tài)資源目錄解決,或者用第2種:
B. 靜態(tài)化到集中緩存服務(wù)
首先我們得讓Nginx有直接訪(fǎng)問(wèn)緩存的能力:
upstream redis { server redis_server_ip:6379; } upstream ruby_backend { server unicorn_server_ip1 fail_timeout=0; server unicorn_server_ip2 fail_timeout=0; } location /categories { set $redis_key $uri; default_type text/html; redis_pass redis; error_page 404 = @httpapp; } location @httpapp { proxy_pass http://ruby_backend; }
Nginx首先會(huì)用請(qǐng)求的uri作為key去redis里面獲取,如果獲取不到(404)就轉(zhuǎn)發(fā)給unicorn進(jìn)行處理,然后改寫(xiě)generate_static_file和delete_static_file方法:
redis_cache.set('categories', response.body) redis_cache.del('categories')
這樣除了集中管理以外,還能夠設(shè)置緩存的失效時(shí)間,對(duì)于一些更新無(wú)時(shí)效性要求的數(shù)據(jù),就可以不用處理刷新機(jī)制,簡(jiǎn)單地固定時(shí)間刷新一次:
redis_cache.setex('categories', 3.hours.to_i, response.body)
3. 整頁(yè)緩存
Nginx緩存在處理帶參數(shù)資源或者有用戶(hù)狀態(tài)的請(qǐng)求時(shí)候,就非常難以處理,這個(gè)時(shí)候可以用到整頁(yè)緩存。
比如說(shuō)分頁(yè)請(qǐng)求列表,我們可以將page參數(shù)加入到cache_path:
class CategoriesController caches_action :index, :expires_in => 1.day, :cache_path => proc {"categories/index/#{params[:page].to_i}"} end
比如說(shuō)我們只需要針對(duì)rss輸出進(jìn)行緩存8小時(shí):
class ArticlesController caches_action :index, :expires_in => 8.hours, :if => proc {request.format.rss?} end
再比如說(shuō)對(duì)于非登陸用戶(hù),我們可以緩存首頁(yè):
class HomeController caches_action :index, :expires_in => 3.hours, :if => proc {!user_signed_in?} end
4. 片段緩存
如果說(shuō)前面2種緩存能夠用到的場(chǎng)景有限,那么片段緩存是適用性最廣的。
場(chǎng)景1:我們需要在每個(gè)頁(yè)面一段廣告代碼,用來(lái)顯示不同廣告,如果沒(méi)有使用片段緩存,那么每個(gè)頁(yè)面都會(huì)要去查詢(xún)廣告的代碼,并且花費(fèi)一定時(shí)間去生成html代碼:
- if advert = Advert.where(:name => request.controller_name + request.action_name, :enable => true).first div.ad = advert.content
加了片段緩存以后,就可以少去這個(gè)查詢(xún):
- cache "adverts/#{request.controller_name}/#{request.action_name}", :expires_in => 1.day do - if advert = Advert.where(:name => request.controller_name + request.action_name, :enable => true).first div.ad = advert.content
場(chǎng)景2:閱讀文章,文章的內(nèi)容可能比較長(zhǎng)時(shí)間都不會(huì)改變,經(jīng)常變化可能是文章評(píng)論,就可以對(duì)文章主體部分加上片段緩存:
- cache "articles/#{@article.id}/#{@article.updated_at.to_i}" do div.article = @article.content.markdown2html
節(jié)約了生成markdown語(yǔ)法轉(zhuǎn)換到html時(shí)間,這里用文章最后更新時(shí)間作為cache key的一部分,文章內(nèi)容如果有改變,緩存自動(dòng)失效,默認(rèn)activerecord的cache_key方法也是用updated_at,你也可以加入更多的參數(shù),比如article上有評(píng)論數(shù)的counter cache,更新評(píng)論數(shù)的時(shí)候不會(huì)更新文章時(shí)間,可以將這個(gè)counter也加入到key的一部分
場(chǎng)景3:復(fù)雜頁(yè)面結(jié)構(gòu)的生成
數(shù)據(jù)結(jié)構(gòu)比較復(fù)雜的頁(yè)面,在生成的時(shí)候避免不了大量的查詢(xún)和html渲染,用片段緩存,可以將這部分時(shí)間大大地節(jié)約,以我們網(wǎng)站游記頁(yè)面http://chanyouji.com/trips/109123(請(qǐng)?jiān)试S小小地打個(gè)廣告,帶點(diǎn)流量)來(lái)說(shuō):
需要獲取天氣數(shù)據(jù),照片數(shù)據(jù),文本數(shù)據(jù)等,同時(shí)還要生成meta,keyword等seo數(shù)據(jù),而這些內(nèi)容又是和其他動(dòng)態(tài)內(nèi)容交叉,片段緩存就可以分開(kāi)多個(gè):
- cache "trips/show/seo/#{@trip.fragment_cache_key}", :expires_in => 1.day do title #{trip_name @trip} meta name="description" content="..." meta name="keywords" content="..." body div ... - cache "trips/show/viewer/#{@trip.fragment_cache_key}", :expires_in => 1.day do - @trip.eager_load_all
小貼士,我在trip對(duì)象里面加了一個(gè)eager_load_all方法,緩存沒(méi)有命中的時(shí)候,查詢(xún)的時(shí)候避免出現(xiàn)n+1問(wèn)題:
def eager_load_all ActiveRecord::Associations::Preloader.new([self], {:trip_days => [:weather_station_data, :nodes => [:entry, :notes => [:photo, :video, :audio]]]}).run end
小技巧1:帶條件的片段緩存
和caches_action不同,rails自帶的片段緩存是不支持條件的,比如說(shuō)我們想未登陸用戶(hù)給他用片段緩存,而登陸用戶(hù)不使用,寫(xiě)起來(lái)就很麻煩,我們可以改寫(xiě)一下helper就可以了:
def cache_if (condition, name = {}, cache_options = {}, &block) if condition cache(name, cache_options, &block) else yield end end - cache_if !user_signed_in?, "xxx", :expires_in => 1.day do
小技巧2:關(guān)聯(lián)對(duì)象的自動(dòng)更新
常使用對(duì)象update_at時(shí)間戳來(lái)作為cache key,可以在關(guān)聯(lián)對(duì)象上加上touch選項(xiàng),自動(dòng)更新關(guān)聯(lián)對(duì)象時(shí)間戳,比如我們可以在更新或者刪除文章評(píng)論的時(shí)候,自動(dòng)個(gè)更新:
class Article has_many :comments end class Comment belongs_to :article, :touch => true end
5. 數(shù)據(jù)查詢(xún)緩存
通常來(lái)說(shuō)web應(yīng)用性能瓶頸都出現(xiàn)在DB IO上,做好數(shù)據(jù)查詢(xún)緩存,減少數(shù)據(jù)庫(kù)的查詢(xún)次數(shù),可以極大提高整體響應(yīng)時(shí)間。
數(shù)據(jù)查詢(xún)緩存分2種:
A. 同一個(gè)請(qǐng)求周期內(nèi)的緩存
舉一個(gè)顯示文章列表的例子,輸出文章標(biāo)題和文章類(lèi)別,對(duì)應(yīng)代碼如下
# controller def index @articles = Article.first(10) end # view - @articles.each do |article| h1 = article.name span = article.category.name
會(huì)發(fā)生10條類(lèi)似的sql查詢(xún):
SELECT `categories`.* FROM `categories` WHERE `categories`.`id` = ?
rails內(nèi)置了query cache(https://github.com/rails/rails/blob/master/activerecord/lib/active_record/connection_adapters/abstract/query_cache.rb),在同一個(gè)請(qǐng)求周期內(nèi),如果沒(méi)有update/delete/insert的操作,會(huì)對(duì)相同的sql查詢(xún)進(jìn)行緩存,如果文章類(lèi)別都是相同的話(huà),真正去查詢(xún)數(shù)據(jù)庫(kù)只會(huì)有1次。
如果文章類(lèi)別都不一樣,就會(huì)出現(xiàn)N+1查詢(xún)問(wèn)題(常見(jiàn)的性能瓶頸),rails推薦的解決方法是用Eager Loading Associations (http://guides.rubyonrails.org/active_record_querying.html#eager-loading-associations)
def index @articles = Article.includes(:category).first(10) end
查詢(xún)語(yǔ)句會(huì)變成
SELECT `categories`.* FROM `categories` WHERE `categories`.`id` in (?,?,?...)
B. 跨請(qǐng)求周期的緩存
同請(qǐng)求周期緩存所帶來(lái)性能優(yōu)化是很有限的,很多時(shí)候我們需要用跨請(qǐng)求周期的緩存,將一些常用的數(shù)據(jù)(比如User model)緩存,對(duì)于active record來(lái)說(shuō),利用統(tǒng)一的查詢(xún)接口來(lái)fetch cache,利用callback來(lái)expire cache,就很容易實(shí)現(xiàn),而且有一些現(xiàn)成的gem可以來(lái)用。
比如說(shuō) identity_cache (https://github.com/Shopify/identity_cache)
class User < ActiveRecord::Base include IdentityCache end class Article < ActiveRecord::Base include IdentityCache cached_belongs_to :user end # 都會(huì)命中緩存 User.fetch(1) Article.find(2).user
這個(gè)gem的優(yōu)點(diǎn)是代碼實(shí)現(xiàn)簡(jiǎn)單,cache設(shè)置靈活,也方便擴(kuò)展,缺點(diǎn)是需要用不同的查詢(xún)方法名(fetch),以及額外的關(guān)系定義。
如果想在無(wú)數(shù)據(jù)緩存的應(yīng)用無(wú)縫加入緩存功能,推薦@hooopo做的second_level_cache (https://github.com/hooopo/second_level_cache) 。
class User < ActiveRecord::Base acts_as_cached(:version => 1, :expires_in => 1.week) end
#還是使用find方法,就會(huì)命中緩存
User.find(1)
#無(wú)需額外用不一樣的belongs_to定義
Article.find(2).user
實(shí)現(xiàn)原理是擴(kuò)展了active record底層arel sql ast處理 (https://github.com/hooopo/second_level_cache/blob/master/lib/second_level_cache/arel/wheres.rb)
它的優(yōu)點(diǎn)是無(wú)縫接入,缺點(diǎn)是擴(kuò)展比較困難,對(duì)于只獲取少量字段的查詢(xún)無(wú)法緩存。
6. 數(shù)據(jù)庫(kù)緩存
編輯中
這6種緩存,分布在客戶(hù)端到服務(wù)器端不同的位置,所能夠節(jié)約的時(shí)間也正好從多到少依次排列。
相關(guān)文章
Ruby on Rails所構(gòu)建的應(yīng)用程序基本目錄結(jié)構(gòu)總結(jié)
Ruby on Rails是Ruby世界中一家獨(dú)大的Web開(kāi)發(fā)框架,要掌握Rails程序的構(gòu)建,對(duì)其目錄結(jié)構(gòu)的了解十分必要,下面就來(lái)看一下Ruby on Rails所構(gòu)建的應(yīng)用程序基本目錄結(jié)構(gòu)總結(jié)2016-05-05分析Cache 在 Ruby China 里面的應(yīng)用情況
之前文章介紹《web 應(yīng)用中常用的各種 cache詳解》,今天來(lái)分享一下在 Ruby China 里面,我們是如何做 Cache 的。2014-06-06Ruby中一些基本語(yǔ)法知識(shí)點(diǎn)的羅列匯總
這篇文章主要介紹了Ruby中一些基本語(yǔ)法知識(shí)點(diǎn)的羅列匯總,包括Ruby中的注釋和標(biāo)識(shí)符等基礎(chǔ)內(nèi)容,需要的朋友可以參考下2015-05-05Ruby實(shí)現(xiàn)發(fā)送郵件的兩個(gè)方法
這篇文章主要介紹了Ruby實(shí)現(xiàn)發(fā)送郵件的兩個(gè)方法,本文講解了使用Net::SMTP、使用ruby gem:mail兩種方法實(shí)現(xiàn)郵件發(fā)送,需要的朋友可以參考下2015-01-01rails "No route matches" 錯(cuò)誤的解決方法
有時(shí)候 rails 會(huì)出現(xiàn)2008-12-12Ruby on Rails中的ActiveRecord編程指南
這篇文章主要介紹了Ruby on Rails中的ActiveRecord編程指南,作者對(duì)編寫(xiě)ActiveRecord的通用習(xí)慣給出了建議,需要的朋友可以參考下2015-08-08