牛骨文教育服务平台(让学习变的简单)

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

編輯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, :expire_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 < ActiveRecord::Base
	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

writefetch支援expires_in參數可以設定時效。

使用HTTP快取

在HTTP 1.1規格中定義了Cache-Control、ETag和Last-Modified等Headers可以更細微的設定用戶端和伺服器之間要如何快取,Rails也有語法可以很方便的支援。這在大型網站的架構中,會搭配HTTP快取伺服器,來獲得最大的效益。例如VarnishSquid

HTTP Cache-Control

使用expires_inexpires_now方法。

HTTP ETag 和 Last-Modified

使用fresh_whenstale?方法,當判斷response內容沒有更新的時候,只回傳HTTP 304 Not Modified。

其他線上資源