ORM緩存引言html
從10年前的2003年開始,在Web應用領域,ORM(對象-關係映射)框架就開始逐漸普及,而且流行開來,其中最廣爲人知的就是Java的開源ORM框架Hibernate,後來Hibernate也成爲了EJB3的實現框架;2005年之後,ORM開始普及到其餘編程語言領域,其中最有名氣的是Ruby on rails框架的ORM - ActiveRecord。現在各類開源框架的ORM,乃至ODM(對象-文檔關係映射,用在訪問NoSQLDB)層出不窮,功能都十分強大,也很普及。java
然而圍繞ORM的性能問題,也一直有不少批評的聲音。其實ORM的架構對插入緩存技術是很是容易的,我作的不少項目和產品,但凡使用ORM,緩存都是標配,性能都很是好。並且我發現業界使用ORM的案例都忽視了緩存的運用,或者說沒有意識到ORM緩存能夠帶來巨大的性能提高。git
ORM緩存應用案例github
咱們去年有一個老產品重寫的項目,這個產品有超過10年曆史了,數據庫的數據量很大,多個表都是上千萬條記錄,最大的表記錄達到了9000萬條,Web訪問的請求數天天有300萬左右。算法
老產品採用了傳統的解決性能問題的方案:Web層採用了動態頁面靜態化技術,超過必定時間的文章生成靜態HTML文件;對數據庫進行分庫分表,按年拆表。動態頁面靜態化和分庫分表是應對大訪問量和大數據量的常規手段,自己也有效。但它的缺點也不少,比方說增長了代碼複雜度和維護難度,跨庫運算的困難等等,這個產品的代碼維護從來很是困難,致使bug不少。數據庫
進行產品重寫的時候,咱們放棄了動態頁面靜態化,採用了純動態網頁;放棄了分庫分表,直接操做千萬級,乃至近億條記錄的大表進行SQL查詢;也沒有采起讀寫分離技術,所有查詢都是在單臺主數據庫上進行;數據庫訪問所有使用ActiveRecord,進行了大量的ORM緩存。上線之後的效果很是好:單臺MySQL數據庫服務器CPU的IO Wait低於5%;用單臺1U服務器2顆4核至強CPU已經能夠輕鬆支持天天350萬動態請求量;最重要的是,插入緩存並不須要代碼增長多少複雜度,可維護性很是好。編程
總之,採用ORM緩存是Web應用提高性能一種有效的思路,這種思路和傳統的提高性能的解決方案有很大的不一樣,但它在不少應用場景(包括高度動態化的SNS類型應用)很是有效,並且不會顯著增長代碼複雜度,因此這也是我本身一直偏心的方式。所以我一直很想寫篇文章,結合示例代碼介紹ORM緩存的編程技巧。設計模式
今年春節先後,我開發本身的我的網站項目,有意識的大量使用了ORM緩存技巧。對一個沒多少訪問量的我的站點來講,有些過分設計了,但我也想借這個機會把經常使用的ORM緩存設計模式寫成示例代碼,提供給你們參考。個人我的網站源代碼是開源的,託管在github上:robbin_site緩存
ORM緩存的基本理念ruby
我在2007年的時候寫過一篇文章,分析ORM緩存的理念:ORM對象緩存探討,因此這篇文章不展開詳談了,總結來講,ORM緩存的基本理念是:
·以減小數據庫服務器磁盤IO爲最終目的,而不是減小發送到數據庫的SQL條數。實際上使用ORM,會顯著增長SQL條數,有時候會成倍增長SQL。
·數據庫schema設計的取向是儘可能設計細顆粒度的表,表和表之間用外鍵關聯,顆粒度越細,緩存對象的單位越小,緩存的應用場景越普遍
·儘可能避免多表關聯查詢,儘可能拆成多個表單獨的主鍵查詢,儘可能多製造n + 1條查詢,不要懼怕「臭名昭著」的n + 1問題,實際上n + 1纔能有效利用ORM緩存
利用表關聯實現透明的對象緩存
在設計數據庫的schema的時候,設計多個細顆粒度的表,用外鍵關聯起來。當經過ORM訪問關聯對象的時候,ORM框架會將關聯對象的訪問轉化成用主鍵查詢關聯表,發送n + 1條SQL。而基於主鍵的查詢能夠直接利用對象緩存。
咱們本身開發了一個基於ActiveRecord封裝的對象緩存框架:second_level_cache,從這個ruby插件的名稱就能夠看出,實現借鑑了Hibernate的二級緩存實現。這個對象緩存的配置和使用,能夠看我寫的ActiveRecord對象緩存配置。
下面用一個實際例子來演示一下對象緩存起到的做用:訪問我我的站點的首頁。這個頁面的數據須要讀取三張表:blogs表獲取文章信息,blog_contents表獲取文章內容,accounts表獲取做者信息。三張表的model定義片斷以下,完整代碼請看models:
class Account <ActiveRecord::Base acts_as_cached has_many :blogs end class Blog <ActiveRecord::Base acts_as_cached belongs_to :blog_content, :dependent => :destroy belongs_to :account, :counter_cache => true end class BlogContent< ActiveRecord::Base acts_as_cached end
傳統的作法是發送一條三表關聯的查詢語句,相似這樣的:
SELECT blogs.*,blog_contents.content, account.name FROM blogs LEFTJOINblog_contents ON blogs.blog_content_id = blog_contents.id LEFTJOIN accountsON blogs.account_id = account.id
每每單條SQL語句就搞定了,可是複雜SQL的帶來的表掃描範圍可能比較大,形成的數據庫服務器磁盤IO會高不少,數據庫實際IO負載每每沒法獲得有效緩解。
個人作法以下,完整代碼請看home.rb:
@blogs = Blog.order('id DESC').page(params[:page])
這是一條分頁查詢,實際發送的SQL以下:
SELECT * FROMblogs ORDERBY id DESC LIMIT 20
轉成了單表查詢,磁盤IO會小不少。至於文章內容,則是經過blog.content的對象訪問得到的,因爲首頁抓取20篇文章,因此實際上會多出來20條主鍵查詢SQL訪問blog_contents表。就像下面這樣:
DEBUG - BlogContent Load (0.3ms) SELECT `blog_contents`.* FROM `blog_contents` WHERE `blog_contents`.`id` = 29 LIMIT 1 DEBUG - BlogContent Load (0.2ms) SELECT `blog_contents`.* FROM `blog_contents` WHERE `blog_contents`.`id` = 28 LIMIT 1 DEBUG - BlogContent Load (1.3ms) SELECT `blog_contents`.* FROM `blog_contents` WHERE `blog_contents`.`id` = 27 LIMIT 1 ...... DEBUG - BlogContent Load (0.9ms) SELECT `blog_contents`.* FROM `blog_contents` WHERE `blog_contents`.`id` = 10 LIMIT 1
可是主鍵查詢SQL不會形成表的掃描,並且每每已經被數據庫buffer緩存,因此基本不會發生數據庫服務器的磁盤IO,於是整體的數據庫IO負載會遠遠小於前者的多表聯合查詢。特別是當使用對象緩存以後,會緩存全部主鍵查詢語句,這20條SQL語句每每並不會所有發生,特別是熱點數據,緩存命中率很高:
DEBUG - Cache read: robbin/blog/29/1 DEBUG - Cache read: robbin/account/1/0 DEBUG - Cache read:robbin/blogcontent/29/0 DEBUG - Cache read: robbin/account/1/0 DEBUG - Cache read: robbin/blog/28/1 ...... DEBUG - Cache read: robbin/blogcontent/11/0 DEBUG - Cache read: robbin/account/1/0 DEBUG - Cache read: robbin/blog/10/1 DEBUG - Cache read:robbin/blogcontent/10/0 DEBUG - Cache read: robbin/account/1/0
拆分n+1條查詢的方式,看起來彷佛很是違反你們的直覺,但實際上這是真理,我實踐經驗證實:數據庫服務器的瓶頸每每是磁盤IO,而不是SQL併發數量。所以拆分n+1條查詢本質上是以增長n條SQL語句爲代價,簡化複雜SQL,換取數據庫服務器磁盤IO的下降固然這樣作之後,對於ORM來講,有額外的好處,就是能夠高效的使用緩存了。
按照column拆表實現細粒度對象緩存
數據庫的瓶頸每每在磁盤IO上,因此應該儘可能避免對大表的掃描。傳統的拆表是按照row去拆分,保持表的體積不會過大,可是缺點是形成應用代碼複雜度很高;使用ORM緩存的辦法,則是按照column進行拆表,原則通常是:
·將大字段拆分出來,放在一個單獨的表裏面,表只有主鍵和大字段,外鍵放在主表當中
·將不參與where條件和統計查詢的字段拆分出來,放在獨立的表中,外鍵放在主表當中
按照column拆表本質上是一個去關係化的過程。主表只保留參與關係運算的字段,將非關係型的字段剝離到關聯表當中,關聯表僅容許主鍵查詢,以Key-Value DB的方式來訪問。所以這種緩存設計模式本質上是一種SQLDB和NoSQLDB的混合架構設計
下面看一個實際的例子:文章的內容content字段是一個大字段,該字段不能放在blogs表中,不然會形成blogs表過大,表掃描形成較多的磁盤IO。我實際作法是建立blog_contents表,保存content字段,schema簡化定義以下:
CREATETABLE`blogs` ( `id`int(11) NOTNULL AUTO_INCREMENT, `title`varchar(255) NOTNULL, `blog_content_id`int(11) NOTNULL, `content_updated_at` datetime DEFAULTNULL, PRIMARYKEY (`id`), ); CREATETABLE`blog_contents` ( `id`int(11) NOTNULL AUTO_INCREMENT, `content`mediumtext NOTNULL, PRIMARYKEY (`id`) );
blog_contents表只有content大字段,其外鍵保存到主表blogs的blog_content_id字段裏面。
model定義和相關的封裝以下:
class Blog <ActiveRecord::Base acts_as_cached delegate:content, :to => :blog_content, :allow_nil => true defcontent=(value) self.blog_content ||= BlogContent.new self.blog_content.content = value self.content_updated_at = Time.now end end class BlogContent< ActiveRecord::Base acts_as_cached validates:content, :presence => true end
在Blog類上定義了虛擬屬性content,當訪問blog.content的時候,實際上會發生一條主鍵查詢的SQL語句,獲取blog_content.content內容。因爲BlogContent上面定義了對象緩存acts_as_cached,只要被訪問過一次,content內容就會被緩存到memcached裏面。
這種緩存技術實際會很是有效,由於:只要緩存足夠大,全部文章內容能夠所有被加載到緩存當中,不管文章內容表有多麼大,你都不須要再訪問數據庫了更進一步的是:這張大表你永遠都只須要經過主鍵進行訪問,絕無可能出現表掃描的情況爲什麼當數據量大到9000萬條記錄之後,咱們的系統仍然可以保持良好的性能,祕密就在於此。
還有一點很是重要:使用以上兩種對象緩存的設計模式,你除了須要添加一條緩存聲明語句actsas_cached之外,不須要顯式編寫一行代碼_ 有效利用緩存的代價如此之低,何樂而不爲呢?
以上兩種緩存設計模式都不須要顯式編寫緩存代碼,如下的緩存設計模式則須要編寫少許的緩存代碼,不過代碼的增長量很是少。
寫一致性緩存
寫一致性緩存,叫作write-throughcache,是一個CPU Cache借鑑過來的概念,意思是說,當數據庫記錄被修改之後,同時更新緩存,沒必要進行額外的緩存過時處理操做。但在應用系統中,咱們須要一點技巧來實現寫一致性緩存。來看一個例子:
個人網站文章原文是markdown格式的,當頁面顯示的時候,須要轉換成html的頁面,這個轉換過程自己是很是消耗CPU的,我使用的是Github的markdown的庫。Github爲了提升性能,用C寫了轉換庫,但若是是很是大的文章,仍然是一個耗時的過程,Ruby應用服務器的負載就會比較高。
個人解決辦法是緩存markdown原文轉換好的html頁面的內容,這樣當再次訪問該頁面的時候,就沒必要再次轉換了,直接從緩存當中取出已經緩存好的頁面內容便可,極大提高了系統性能。個人網站文章最終頁的代碼執行時間開銷每每小於10ms,就是這個緣由。代碼以下:
defmd_content# cached markdown format blog content APP_CACHE.fetch(content_cache_key) { GitHub::Markdown.to_html(content, :gfm) } end
這裏存在一個如何進行緩存過時的問題,當文章內容被修改之後,應該更新緩存內容,讓老的緩存過時,不然就會出現數據不一致的現象。進行緩存過時處理是比較麻煩的,咱們能夠利用一個技巧來實現自動緩存過時:
defcontent_cache_key "#{CACHE_PREFIX}/blog_content/#{self.id}/#{content_updated_at.to_i}" end
當構造緩存對象的key的時候,我用文章內容被更新的時間來構造key值,這個文章內容更新時間用的是blogs表的content_updated_at字段,當文章被更新的時候,blogs表會進行update,更新該字段。所以每當文章內容被更新,緩存的頁面內容的key就會改變,應用程序下次訪問文章頁面的時候,緩存就會失效,因而從新調用GitHub::Markdown.to_html(content,:gfm)生成新的頁面內容。而老的頁面緩存內容不再會被應用程序存取,根據memcached的LRU算法,當緩存填滿以後,將被優先剔除。
除了文章內容緩存以外,文章的評論內容轉換成html之後也使用了這種緩存設計模式。具體能夠看相應的源代碼:blog_comment.rb
片斷緩存和過時處理
Web應用當中有大量的並不是實時更新的數據,這些數據均可以使用緩存,避免每次存取的時候都進行數據庫查詢和運算。這種片斷緩存的應用場景不少,例如:
·展現網站的Tag分類統計(只要沒有更新文章分類,或者發佈新文章,緩存一直有效)
·輸出網站RSS(只要沒有發新文章,緩存一直有效)
·網站右側欄(若是沒有新的評論或者發佈新文章,則在一段時間例如一天內基本不須要更新)
以上應用場景均可以使用緩存,代碼示例:
defself.cached_tag_cloud APP_CACHE.fetch("#{CACHE_PREFIX}/blog_tags/tag_cloud") do self.tag_counts.sort_by(&:count).reverse end end
對全站文章的Tag雲進行查詢,對查詢結果進行緩存
<% cache("#{CACHE_PREFIX}/layout/right", :expires_in =>1.day) do %> <divclass="tag"> <% Blog.cached_tag_cloud.select {|t|t.count > 2}.each do |tag| %> <%= link_to"#{tag.name}<span>#{tag.count}</span>".html_safe,url(:blog, :tag, :name => tag.name) %> <% end %> </div> ...... <% end %>
對全站右側欄頁面進行緩存,過時時間是1天。
緩存的過時處理每每是比較麻煩的事情,但在ORM框架當中,咱們能夠利用model對象的回調,很容易實現緩存過時處理。咱們的緩存都是和文章,以及評論相關的,因此能夠直接註冊Blog類和BlogComment類的回調接口,聲明當對象被保存或者刪除的時候調用刪除方法:
class Blog <ActiveRecord::Base acts_as_cached after_save:clean_cache before_destroy:clean_cache defclean_cache APP_CACHE.delete("#{CACHE_PREFIX}/blog_tags/tag_cloud") # clean tag_cloud APP_CACHE.delete("#{CACHE_PREFIX}/rss/all") # clean rss cache APP_CACHE.delete("#{CACHE_PREFIX}/layout/right") # clean layout right column cache in _right.erb end end class BlogComment< ActiveRecord::Base acts_as_cached after_save:clean_cache before_destroy:clean_cache defclean_cache APP_CACHE.delete("#{CACHE_PREFIX}/layout/right") # clean layoutright column cache in _right.erb end end
在Blog對象的after_save和before_destroy上註冊clean_cache方法,當文章被修改或者刪除的時候,刪除以上緩存內容。總之,能夠利用ORM對象的回調接口進行緩存過時處理,而不須要處處寫緩存清理代碼。
對象寫入緩存
咱們一般說到緩存,老是認爲緩存是提高應用讀取性能的,其實緩存也能夠有效的提高應用的寫入性能。咱們看一個常見的應用場景:記錄文章點擊次數這個功能。
文章點擊次數須要每次訪問文章頁面的時候,都要更新文章的點擊次數字段view_count,而後文章必須實時顯示文章的點擊次數,所以常見的讀緩存模式徹底無效了。每次訪問都必須更新數據庫,當訪問量很大之後數據庫是吃不消的,所以咱們必須同時作到兩點:
·每次文章頁面被訪問,都要實時更新文章的點擊次數,而且顯示出來
·不能每次文章頁面被訪問,都更新數據庫,不然數據庫吃不消
對付這種應用場景,咱們能夠利用對象緩存的不一致,來實現對象寫入緩存。原理就是每次頁面展現的時候,只更新緩存中的對象,頁面顯示的時候優先讀取緩存,可是不更新數據庫,讓緩存保持不一致,積累到n次,直接更新一次數據庫,但繞過緩存過時操做。具體的作法能夠參考blog.rb:
# blog viewer hit counter defincrement_view_count increment(:view_count) # add view_count += 1 write_second_level_cache# update cache per hit, but do not touch db # update db per 10 hits self.class.update_all({:view_count => view_count}, :id => id) if view_count %10 == 0 end
increment(:view_count)增長view_count計數,關鍵代碼是第2行write_second_level_cache,更新view_count以後直接寫入緩存,但不更新數據庫。累計10次點擊,再更新一次數據庫相應的字段。另外還要注意,若是blog對象不是經過主鍵查詢,而是經過查詢語句構造的,要優先讀取一次緩存,保證頁面點擊次數的顯示一致性,所以_blog.erb這個頁面模版文件開頭有這樣一段代碼:
<% # read view_count from model cache if modelhas been cached. view_count = blog.view_count ifb = Blog.read_second_level_cache(blog.id) view_count = b.view_count end %>
採用對象寫入緩存的設計模式,就能夠很是容易的實現寫入操做的緩存,在這個例子當中,咱們僅僅增長了一行緩存寫入代碼,而這個時間開銷大約是1ms,就能夠實現文章實時點擊計數功能,是否是很是簡單和巧妙?實際上咱們也可使用這種設計模式實現不少數據庫寫入的緩存功能。
經常使用的ORM緩存設計模式就是以上的幾種,本質上都是很是簡單的編程技巧,代碼的增長量和複雜度也很是低,只須要不多的代碼就能夠實現,可是在實際應用當中,特別是當數據量很龐大,訪問量很高的時候,能夠發揮驚人的效果。咱們實際的系統當中,緩存命中次數:SQL查詢語句,通常都是5:1左右,即每次向數據庫查詢一條SQL,都會在緩存當中命中5次,數據主要都是從緩存當中獲得,而非來自於數據庫了。
其餘緩存的使用技巧
還有一些並不是ORM特有的緩存設計模式,可是在Web應用當中也比較常見,簡單說起一下:
用數據庫來實現的緩存
在我這個網站當中,每篇文章都標記了若干tag,而tag關聯關係都是保存到數據庫裏面的,若是每次顯示文章,都須要額外查詢關聯表獲取tag,顯然會很是消耗數據庫。在我使用的acts-as-taggable-on插件中,它在blogs表當中添加了一個cached_tag_list字段,保存了該文章標記的tag。當文章被修改的時候,會自動相應更新該字段,避免了每次顯示文章的時候都須要去查詢關聯表的開銷。
HTTP客戶端緩存
基於資源協議實現的HTTP客戶端緩存也是一種很是有效的緩存設計模式,我在2009年寫過一篇文章詳細的講解了:基於資源的HTTP Cache的實現介紹,因此這裏就再也不復述了。
用緩存實現計數器功能
這種設計模式有點相似於對象寫入緩存,利用緩存寫入的低開銷來實現高性能計數器。舉一個例子:用戶登陸爲了不遭遇密碼暴力破解,我限定了每小時每IP只能嘗試登陸5次,若是超過5次,拒絕該IP再次嘗試登陸。代碼實現很簡單,以下:
post:login, :map => '/login'do login_tries = APP_CACHE.read("#{CACHE_PREFIX}/login_counter/#{request.ip}") halt403iflogin_tries && login_tries.to_i > 5# reject ip if login tries is over 5 times @account = Account.new(params[:account]) iflogin_account = Account.authenticate(@account.email, @account.password) session[:account_id] = login_account.id redirecturl(:index) else # retry 5 times per one hour APP_CACHE.increment("#{CACHE_PREFIX}/login_counter/#{request.ip}", 1, :expires_in => 1.hour) render'home/login' end end
等用戶POST提交登陸信息以後,先從緩存當中取該IP嘗試登陸次數,若是大於5次,直接拒絕掉;若是不足5次,並且登陸失敗,計數加1,顯示再次嘗試登陸頁面。