快取
No code is faster than no code. - Merb core tenet
關於快取,有句話是這樣說的:“There are only two hard things in Computer Science: cache invalidation and naming things” by Phil Karlton。在電腦硬體和軟體架構中,有非常多的設計都是圍繞在快取系統上,越快的效能代表可用的空間越少,這是成本效益。例如個人電腦上的CPU的快取分成L1、L2、L3,然後是記憶體、最後是硬碟空間,這之間的存取速度和可用空間差了好幾個數量級,前者對後者來說,就是一種快取層。而資料一旦被放到快取,就要去處理資料的Consistent一致性問題。設計網站應用程式也是一樣的道理,將運算過後的結果快取起來,下次要用不計算直接讀取就會比較快。但是什麼時候快取資料過期了需要重新運算呢?這就是令人頭痛的cache invalidation問題。
我們在上一章努力避免緩慢的資料庫SQL查詢,但是如果效能需要再進一步提昇,就需要用到快取機制來減少讀取資料庫,以及利用View快取節省樣板rendering時間。
關於實作快取,有幾點觀念:
- 快取處太多,程式會變複雜,增加維護的難度
- 快取會增加除錯難度,資料不再只有唯一的資料庫版本
- 快取如果沒寫好,可能會產生資料不一致的Bug、時間顯示相關的Bug(例如顯示資料的時間,雖然時間不會變,但是如果是要顯示多少小時以前,就會變動了)等等
- 快取增加了寫程式的難度,像是Expire過期資料、資料的安全性(放在快取層的資料也需要被保護注意安全)
- 會增加撰寫UI的難度,因為快取相關的程式可能會混在樣本中
Rails內建了快取功能,可以讓我們將SQL結果或是HTML結果放到Cache Store中,這樣下一次就不需要重新運算,大幅提高效能。
Cache Store
Rails提供了幾種不同的Cache Store可以選擇,預設的memory_store只適合單機開發,而且重啟Rails快取資料就不見了。因此正式上線的網站會推薦使用Memcached。它是一套Name-Value Pair(NVP)分散式記憶體快取系統,當你有多個Rails伺服器的時候,也可以很方便的共用快取資料。
使用Mac的話,可以用Homebrew安裝Memcached:
$ brew install memcached
在 Ubuntu Linux 伺服器上,用 apt-get 就可以安裝了:
$ sudo apt-get install memcached
接著編輯Gemfile加上memcached的函式庫
gem "dalli"
編輯config/environments/development.rb和production.rb加上
config.cache_store = :mem_cache_store
快取在開發模式下是關閉的,為了測快取功能可以暫時將confog/environments/development.rb裡面的
config.action_controller.perform_caching
暫時改成true
,記得測完改回false
即可。
使用memcached做快取的基本模式就是,先查看有沒有key-value,有就把快取資料讀出來,沒有就運算結果後存到memcached快取資料庫中(你應該假設就算快取系統關閉,你的系統也可以正常執行)。注意到它並不是persistent data store,只要一關掉memcahed重開,裡面的資料就會通通不見。另一個特性是它使用LRU快取演算法(預設是64MB),當快取的資料超過設定的記憶體容量時,就是自動清除太久沒有使用的資料,這個特性等會我們會看到非常實用。
更深入的memcached用法可以參考筆者如何使用 memcached 做快取一文。
View 快取
Fragment caching可以只快取HTML中的一小段元素,我們可以自由選擇要快取的區塊,例如側欄或是選單等等,讓我們有最大的彈性。也因為這種快取發生在View中,所以我們必須把快取程式放進View中,用cache
包起來要快取的Template:
<% cache [@events] do %>
All events:
<% @events.each do |event| %>
<%= event.name %>
<% end %>
<% end %>
cache
的參數是拿來當作快取Key的物件或名稱,我們也可以多加一些名稱來識別。Rails會自動將ActiveRecord物件的最後更新時間、你給的客製名稱,加上Template的內容雜湊自動產生出一個快取Key。
<% cache [:popular, @events] do %>
All popular events:
<% end %>
更新快取的策略
用了快取,就還要學會怎麼處理過期資料,也就是在資料過期之後,將對應的快取資料清除。Rails採用的策略非常聰明,就是利用LRU快取演算法的特性,根據當時情境來動態命名快取Key,從而避免手動清除快取的動作,反正快取記憶體一滿,沒用到的快取資料就會自動被清除掉。
實際看看Rails產生出來的快取Key吧,例如cache [@event]
會產生出以下的快取Key
views/events/3-20141130131120000000000/366bcee2ae9bd3aa0738785aea6ec97d
其中3
是Event ID、20141130131120000000000
是這個Event的最後更新時間、366bcee2ae9bd3aa0738785aea6ec97d
是這個Template內容的雜湊。也就是如果資料有更新,或是Template有改動,那麼產生出來的快取Key就會不一樣,產生出新的快取資料。至於舊的快取資料就不管了,反正滿了就會被LRU自動清掉。
如果放一個ActiveRecord陣列呢,例如cache [:list, @events]
,會產生出以下的快取Key:
views/list/events/3-20141130131120000000000/events/4-20141111035115000000000/events/7-20141130131005000000000/events/8-20141111035115000000000/events/9-20141111035115000000000/bbce07d6df6dd28670ad114790c47484
Rails會將所有的最後更新時間都串在一起,只要其中一個最後更新有改,整個快取資料就會重新產生。
這一招當然也不是萬能,例如如果你的資料跟當時語系又有關係,那你就得把語系這個變數也設定到快取Key,例如
<% cache [:list, @events, I18n.locale] %>
當然,我們也可以找地方手動清除快取,例如放到update action之中:
expire_fragment(:popular_events)
用rake tmp:clear指令可以清空全部快取
另一種快取更新的策略是設定Time-based expired,例如設定兩小時後自動過期:
<% cache :popular_events, :expires_in => 2.hours do %>
調校快取Key
做View快取的一個目的就是節省SQL的查詢量,所以實測的一個重點,就是要觀察實際到底發出哪些SQL查詢。在上述的範例中,Rails用了ActiveRecord的最後更新時間來產生快取Key,因此實際上它還是發出SQL查詢來抓到最後更新時間。這部份我們可以做進一步的改進,特別是cache(@events)
群集的部分,我們可以用自訂快取Key的方式來改善SQL的效率,例如:
# helper
def cache_key_for_events(page)
count = Event.count
max_updated_at = Event.maximum(:updated_at).try(:utc).try(:to_s, :number)
"events/all-#{count}-#{max_updated_at}-#{page}"
end
<% cache cache_key_for_events(params[:page]) do %>
這樣就實際的SQL查詢就會從:
SELECT `events`.* FROM `events` LIMIT 10 OFFSET 0
變成比較有效率的:
SELECT COUNT(*) FROM `events`
SELECT MAX(`events`.`updated_at`) AS max_id FROM `events`
另外要注意是因為有ActiveRecord的Lazy Load特性,所以寫在Controller Action裡的ActiveRecord Query才不會立即送出,而是到真正使用的時候(也就是在Fragment cache範圍裡)才會實際發出SQL查詢。如果真沒有辦法利用到Lazy Load的特性,例如不是ActiveRecord的情況,則可以手動使用fragment_exist?
方法在Action裡面檢查是不是已經有快取,有的話就不要執行,例如:
def show
@event = Event.find(params[:id])
unless fragment_exist?(@event)
@result = SomeExpenseQuery.execute(@event)
end
end
# show.html.erb
<% cache @event do %>
<%= @event.name %>
<%= @result %>
<% end %>
Russian Doll快取策略
上述cache [:list, @events]
的範例中,如果其中一筆資料有更新,會造成整組@events
快取資料都要重新計算,這一點很沒效率。Rails支援nested的疊套方式讓我們可以重用(reuse)其中的快取資料,例如:
<% cache [:list, @events] %>
All events:
<% @events.each do |event| %>
<% cache event do %>
<%= event.name %>
<% end %>
<% end %>
<% end %>
如果其中一筆event有更新,最外圍的快取也會一起更新,但是它不會笨笨的重算每一個小event的快取,只會重算有更新的event而已,其他event則會沿用已經有的快取資料。
ActiveRecord Touch 屬性
被當作快取Key的ActiveRecord物件的最後更新時間updated_at
,在一對一或一對多的關係中,預設並不會根據底下的物件而自動更新。例如以下的例子中,如果有新的attendee進來,並不會自動更新該event的最後更新時間,會導致這整個快取不會被更新到。
<% cache event do %>
<%= event.name %>
<%= event.attendees.last.try(:name) %>
<% end %>
解決的辦法是使用Touch屬性:
class Attendee < ApplicationRecord
belongs_to :event, :touch => true
# ...
end
這樣的話,在新增或編輯attendee後,Rails就會知道要去更新event的最後更新時間,進而重新更新的這份快取了。
快取資料
上述的作法都是將最後的HTML結果快取起來,但是有時候如果形式有很多種,例如同時提供HTML、JSON、XML等,或是有其他程式也想利用同一份快取,這時候我們可以考慮快取資料(字串、陣列或雜湊的基本形式),而不是最後的HTML:
Rails.cache.read("city") # => nil
Rails.cache.write("city", "Duckburgh")
Rails.cache.read("city") # => "Duckburgh"
Rails.cache.fetch("#{id}-data") do
Book.sum(:amount, :conditions => { :category_id => self.category_ids } )
end
write
和fetch
支援expires_in
參數可以設定時效。
使用HTTP快取
在HTTP 1.1規格中定義了Cache-Control、ETag和Last-Modified等Headers可以更細微的設定用戶端和伺服器之間要如何快取,Rails也有語法可以很方便的支援。這在大型網站的架構中,會搭配HTTP快取伺服器,來獲得最大的效益。例如Varnish或Squid。
HTTP Cache-Control
使用expires_in
和expires_now
方法。
HTTP ETag 和 Last-Modified
使用fresh_when
和stale?
方法,當判斷response內容沒有更新的時候,只回傳HTTP 304 Not Modified。
其他線上資源
- Caching with Rails: An overview
- Google Developers: HTTP 快取
- othree: Cache Control 與 ETag
- Introduction to Conditional HTTP Caching with Rails