說說 Rails 的套娃緩存機制

Rails 4.0 之後,開始推廣一種稱爲「俄羅斯套娃」的緩存機制,這是一種使用 Fragment Caching(http://guides.rubyonrails.org/caching_with_rails.html#fragment-caching) 技術的緩存機制,在數據庫作完查詢之後,若是記錄沒有變化,那麼對應的頁面不會被 Rails 從新渲染,而是直接從緩存裏取出,拼裝好之後,返回給客戶。html

Tower 正是借鑑了這套緩存機制,給那些訪問 Tower 的用戶提供流暢的使用體驗,今天跟你們分享一下咱們的經驗,和一些須要注意的坑。數據庫

1. 套娃是怎麼套的緩存

拿 Tower 的項目詳情頁爲例,咱們能夠把這個頁面明顯的分紅一個個的 Section,好比「討論區」、「任務清單區」、「文件區」、「文檔區」、「日曆事件區」,咱們能夠把每個列表區域設置爲一個獨立的緩存,這樣,若是列表的數據沒有更新的話,在渲染項目詳情頁的時候,就能夠直接從緩存裏讀取以前生成好的數據。ruby

那麼,所謂的套娃在哪兒呢?實際上,除了能夠把上面的 Section 放到緩存裏,咱們也能夠把整個項目詳情頁放入緩存,這樣,若是某一個項目裏的數據沒有任何更新,訪問這個詳情頁就能夠直接讀取詳情頁的緩存,連下面的 Section 緩存都不用碰了。ide

一樣,對於某一個列表緩存,好比「討論區」,咱們能夠看到裏面會放三條討論 item,這裏其實每個 item 也能夠對應放入一個獨立的緩存,這樣若是隻有其中一條 item 有更新的話,其它兩條數據是不會被從新渲染,而是直接從緩存區讀取的。這樣分拆下去,咱們大概能夠把整個項目詳情頁的緩存弄成下面這個模樣:post

這樣不就一層一層的套起來了麼?網站

接下來咱們看看,若是這個頁面的某一條數據,好比「任務 A-1」 的內容被改變了,會發生什麼。ui

首先,這條任務本身對應的 L4 Todo Item 緩存失效了,因此在拼裝外面的 L3 級「任務清單A」緩存的時候,會從緩存裏獲取任務 A-二、A-3 的緩存,速度嗖嗖快,快到能夠忽略不計,而後對任務 A-1 從新渲染一次,放入緩存,這樣「任務清單A」經過直接從緩存裏讀取兩條任務(A-2 和 A-3),以及渲染一條新的( A-1 )生成了整個 L3 Todolist Item 的頁面片斷。剩下的「任務清單B」和「任務清單C」,都沒有變化,所以由在生成「任務清單」Section 緩存的時候,直接拼裝便可。spa

其它幾個 Section 片斷由於和任務沒有任何關係,全部緩存都不會過時,所以這幾個 Section 的頁面片斷都是直接從緩存裏撈出來,一樣嗖嗖快。設計

最後,整個項目詳情頁把這幾個 Section 拼裝起來,返回給客戶。從上面的過程能夠看出,只有「任務 A-1」 這個片斷的頁面被從新渲染了。

因此,這種套娃式的緩存,可以保證頁面緩存利用率的最大化,任何數據的更新,只會致使某一個片斷的緩存失效,這樣在組裝完整頁面的時候,因爲大量的頁面片斷都是直接從緩存裏讀取,因此頁面生成的時間開銷就很小。

那麼,套娃是如何在緩存中存取頁面片斷的呢?主要是靠一個叫作 cache_key 的東西來決定的。

2. cache_key

咱們在頁面上可使用一個叫作 cache 的方法,把一坨 HTML 代碼片斷放在一個 Fragment Cache 裏,以項目詳情頁爲例,咱們的代碼多是這個樣子的:

能夠看到,在整個頁面的最外面,有個最大的「套娃」:<% cache @project %>,這個 cache 使用 @project 做爲方法參數,在 cache 方法內部,會把這個對象進行一番處理,最後生成一個字符串,大概是這個樣子:

views/projects/1-20140906112338

這就是所謂的 cache_key,Rails 會使用這串字符串做爲 key,對應的頁面片斷做爲內容,存儲進緩存系統裏。每次渲染頁面的時候,Rails 會根據 cache 裏的元素計算出對應的 cache_key,而後拿着這個 cache_key 到緩存裏去找對應的內容,若是有,則直接從緩存裏取出,若是沒有,則渲染 cache 裏的 HTML 代碼片斷,而且把內容存儲進緩存裏。

對於一個具體的 Model 對象,cache_key 的生成機制簡單來講,就是:對象對應的模型名稱/對象數據庫ID-對象的最後更新時間。

這裏咱們可以很容易分析出,一個緩存判斷最後是否過時,其實很大程度上只和數據最後更新時間有關,由於在系統裏,數據對象對應的模型名稱是不變的,對象在數據庫裏的 ID 通常也是不變的,惟一可能變化的就是最後更新時間。Rails 在建立模型數據表的時候,通常會建立兩個默認的 datetime 類型字段,一個是 created_at,一個是 updated_at,然後者正是用來生成 cache_key 的最後更新時間。並且這個時間通常來講不須要咱們手動更新,咱們都知道若是對一個模型對象調用 save 方法,Rails 會自動幫咱們更新這個 updated_at 字段,這樣,若是我修改了項目名稱,項目的 updated_at 會發生變化,天然的,頁面上項目對應的 cache_key 也就會發生變化,所以咱們的 <%cache @project %> 也就自動過時了。

繼續項目詳情頁裏的示例代碼,接下來咱們看看第二級套娃:各個 Section 緩存。

拿這個 <% cache @top3_topics.max(&:updated_at) %> 爲例,它比 <% cache @project %> 稍微複雜了點。咱們首先應該知道的是,@top3_topics 存儲的是對應項目裏最新建立的三條討論,這裏比較奇怪的是,咱們爲何要用一個 max(&:updated_at) 方法呢?

若是咱們直接把 @top3_topics 對象做爲 cache 的參數 <% cache @top3_topics %>,獲得的 cache_key 實際上會是這樣的形式:

views/topics/3-20140906112338/topics/2-20140906102338/topics/3-20140906092338

看的出來,是每一個對象的 cache_key 的組合,咱們並不太但願 cache_key 變得這麼複雜,特別是當列表元素超過 3 個,好比說有 20 條記錄的時候,因此最簡單的辦法,是取這組數據裏最新一個被更新的數據的 updated_at 時間戳,這樣生成的 cache_key 就是下面的樣子了:

views/20140906112338

可是注意,這裏有一個問題,就是假如 @top3_topics 一條數據都沒有,會出現什麼狀況?好比我新建的項目,裏面理所固然的一條討論都沒有,這個時候,實際上 cache 的是一個空的 relation,對這個空對象調用 max(&:updated) 方法,返回的值永遠都是 nil,因此實際上咱們是對 nil 進行 cache,不幸的是,全部 nil 的 cache_key 都如出一轍,致使這樣的緩存片根本不可用,你不知道到底是對什麼數據進行的緩存。另外,加入任務清單 Section 和討論 Section 最後更新的那條數據的 updated_at 時間戳剛好同樣,也會形成兩個緩存片混淆的問題。

而解決這個問題的方法很簡單,就是給 cache 參數裏增長一個特定的字符串標識,好比把 <% cache @top3_topics.max(&:updated_at) %> 改爲 <% cache [:topics, @top3_topics.max(&:updated_at)] %>,這樣一來,若是 @top3_topics 裏一條數據都沒有,生成的 cache_key 是這樣的:

views/topics/20140906112338

帶上了「topics」本身的標識,這樣就能和其它 nil 類型的緩存區分開了。修改後的項目詳情頁代碼片斷以下:

3. Touch!

咱們回過頭來再看看套娃緩存的讀取機制,訪問項目詳情頁的時候,首先讀取最外層的大套娃 <% cache @project %> ,若是這個緩存片對應的 cache_key 在緩存裏能找到,則直接取出來而且返回,若是緩存過時,則讀取第二級套娃 — 幾個列表 Section 緩存,這些緩存根據列表裏最新一條數據的更新時間生成 cache_key,若是最新一條數據的更新時間沒有變化,則緩存不過時,直接取出來供頁面拼裝用,若是緩存過時,則繼續讀取各自的第三級套娃。

等等,這裏有個問題,若是我改變了一條任務的內容,也就是做廢了任務 partial 本身的緩存,可是包裹任務的任務清單,以及包裹任務清單的項目都沒有變化,這樣當頁面加載的時候,讀取到的第一個大套娃 -- <% cache @project %> 都沒有更新,會直接返回被緩存了的整個項目詳情頁,因此根本不會走到渲染更新的任務 partial 那裏去。對於這個問題的解決方案,是 Rails 模型層的 touch 機制。

簡單的說,咱們須要讓裏面的子套娃在數據更新了之後,touch 一下處在外面的套娃,告訴它,嘿,我更新了,你也得更新才行。咱們直接看看這個代碼片斷:

在這裏,咱們使用 Rails model 的 belongs_to 來聲明模型的從屬關係,好比一個 Todo 屬於一個 Todolist,一個 Todolist 屬於一個 Project,而在 belongs_to 後面,咱們還傳入了一個 touch: true 的參數,這樣,當一條 Todo 更新的時候,會自動更新它對應的 Todolist 對象的 updated_at 字段,而後又由於 Todolist 和 Project 之間也有 touch 機制,因此對應 Project 對象的 updated_at 字段也會被更新。放到咱們的套娃緩存片裏面看的話,就是當一條任務更新之後,「包裹」它的任務清單的緩存片也會被更新,由於對應的 Todolist 對象的 updated_at 時間改變了,而「包裹」這個任務清單的任務清單列表 Section 的緩存片也會失效,由於 @todolists.max(:updated_at) 改變了,接着是「包裹」列表 Section 的項目緩存片過時,由於 @project 對應的 updated_at 也被更新了。

就是經過這麼重重 touch 的機制,咱們能確保子元素在更新之後,它的父容器的緩存也能過時,整個套娃機制才能正常運做。下面是整個 Tower 裏面,各個模型層的 Touch 結構圖:

4. 那些踩過的坑

通過上面的介紹,你們應該已經明白了套娃的實際使用方式,看上去很完美不是麼?但在咱們的實際使用過程當中,套娃緩存仍是有一些坑須要注意的,這裏跟你們分享一下。

咱們在開發過程當中常常遇到的一個問題,是緩存模板裏若是存在「父」元素的狀況。咱們把 Project 定義爲 Todolist 的父元素,把 Todolist 定義爲 Todo 的父元素,由於 touch 機制是自底向上的,從子 touch 到父,可是若是咱們的模板是下面這個樣子:

在任務清單模板裏,咱們須要顯示一下項目的名稱,也就是一個子元素的模板裏,包含了父元素,這個時候若是緩存是 <% cache [:todolists, @todolists.max(&:updated_at) %> 的話,當咱們把項目名稱修改了,這個緩存片是不會過時的,所以任務清單列表裏的項目名也不會改變。

解決這個問題的辦法,一是修改任務清單的緩存的 cache_key,改爲:

這樣修改項目名稱,就能致使緩存片過時,這也是一個廣泛的手段,就是把緩存裏面存在的全部模型對象統一歸入 cache_key 裏面,可是這樣存在一個問題,就是由於項目自己是常常被 touch 的,修改任務也會、建立評論也會,因此致使這個任務清單的緩存片會隨時失效,緩存命中率下降,因此使用這種方法的時候要仔細考慮,引入父元素做爲 cache_key 的一部分,是否會致使這個問題。

另外一個辦法是,使用實際須要的模型字段來作緩存,好比上面的例子,咱們實際上只是須要項目名稱,因此能夠把緩存改成:

這樣只會在項目名稱發生改變的時候,更新緩存片,這個方法可能性價比最高,不過若是一個緩存裏出現多個模型字段的時候,就要寫一串這樣的 cache_key,和咱們「只對一個具體資源緩存」的原則有些差距,因此通常來講,緩存的具體字段最好不要超過一個。

還有一個處理方法是,在 HTML 結構上作調整,基於咱們上面所說的「只對一個具體資源緩存」的原則,這裏咱們若是針對的是 @todolists 作緩存,那麼就應該把其它無關的資源從 HTML 結構裏提取出來,好比放到一個外層的 hidden input 裏面:

這樣能夠經過 JS 讀取這個屬性,再從新注入到模板相應的元素裏面。選擇這種方案,須要提早根據設計作好規劃,把那些須要提取出來的元素放在緩存之外。

最後還有一個方法,就是不理會它。若是你相信任務清單不會長期不變,而項目名稱不會常常變化的話,那麼緩存裏的項目名稱不會隨時都是最新版本,就是一個能夠被接受的事實了,這須要在產品層面上考慮,咱們建議若是遇到這樣的問題,不妨先用這種最簡單的方式處理,看看用戶反饋再決定是否進行調整。

——————————————— 我是分割線 —————————————————

咱們遇到的第二個問題比第一個問題更加讓人頭疼,這個問題發生在咱們爲 Tower 引入一個叫作「訪客鎖」的新功能的時候。在 Tower 裏,用戶被分爲普通成員、管理員和訪客三種,在一個項目裏,有些資源好比一條任務清單,是能夠設置對訪客不可見的,這個在模型層處理起來很簡單,只須要增長一個字段來標識一個資源是不是對訪客不可見便可,可是一旦和 Fragment Caching 結合的時候,就有問題了。在引入訪客鎖功能以前,任務清單列表的 Cache 是這樣的:

這裏 @todolists 是從項目裏取出來的全部未完成的任務清單,而後使用 max(&:updated_at) 時間戳來做爲 cache_key,這樣在一條任務清單更新之後,這個最後更新時間會變化,cache_key 也就變化了。可是在引入訪客鎖之後,這就會有潛在問題了。假如咱們如今有以下圖所示的三條任務清單:

咱們首先將「任務清單B」加鎖,而後再去修改一下「任務清單A」的名稱,這個時候整個清單列表的 max(&:updated_at) 時間就是「任務清單A」的 updated_at 時間,若是一個普通成員先打開項目詳情頁,根據這個更新時間,會緩存一個含有三條任務清單的頁面,接着一個訪客再打開同一個項目詳情頁,會出現什麼狀況呢?這個訪客會看到三條一樣的任務清單,「任務清單B」加鎖是無效的!這是由於對於訪客來講,雖然在控制器裏查詢出來的任務清單隻有 A 和 C 兩條,可是對於這兩條任務清單,最後更新的是 A 的 updated_at 時間戳,這個和能看到三條清單的普通成員以及管理員是同樣的,所以他們的任務清單列表的 cache_key 是同樣的,取出來的緩存片也同樣。

關於這個問題咱們考慮了好久,最後發現只有兩種解決方案,要麼是完全放棄對這種列表類型的片斷作緩存,要麼就是遍歷列表裏的全部子元素,把各自元素的 cache_key 組合起來再求一個 MD5 值,最後咱們選擇了後者,具體的作法是在有列表緩存須要的 Model 裏,引入一個 Concern:

這樣,在須要對列表進行緩存的時候,咱們的寫法就再也不是 <% cache [:todolists, @todolists.max(&:updated_at)] %>,而是這樣:

這種辦法是目前咱們能想到的最佳解決方案,不知道有沒有更好的處理方式。

繞過這個最大的坑之後,還剩下最後一個地方須要修改,就是咱們最外層的那個套娃,咱們使用的是 <% cache @project %> 來對整個項目詳情頁作緩存的,可是由於引入了訪客鎖,因此訪客看到的頁面,和普通成員以及管理員看到的頁面,是不同的,若是都用 @project 做爲 cache_key,會致使和上面列表模式同樣的問題,好在這個地方的解決方法比較簡單,把緩存改爲 <% cache [@project, current_user.visitor?] %> 便可,只是對於同一個項目詳情頁,須要存儲兩份緩存了。

5. 小結

以上就是咱們在 Tower 裏使用套娃緩存的一些經驗,除了 Fragment Caching 以外,咱們也沒有額外再使用 Page Caching 或者 Action Caching 之類的技術,37signals 在這篇 Blog 裏(https://signalvnoise.com/posts/3690-the-performance-impact-of-russian-doll-caching)統計過他們使用套娃後的緩存命中率,這個值是 67%,而 Tower 目前 8G 的 Memcache 的緩存命中率是 45%,相比之下還有差距,不過整站使用體驗上,速度並非一個顯著的短板,若是能把 Fragment Cache 的細粒度繼續作下去,應該會有更好的效果。

綜上,套娃緩存機制仍是蠻適合於小團隊用來加速本身的網站(實際上 Tower 的 Hybird 模式的移動客戶端也是用這種方式來作加速的),只要在模板設計的時候,儘可能按照資源作好規劃,後面逐步增長套娃的數量和層級,因爲只涉及到模板部分的更改,整體來講是一個性價比很高的方案。

相關文章
相關標籤/搜索