快取

最后更新于:2022-04-01 02:31:41

> 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](http://memcached.org/)。它是一套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](http://en.wikipedia.org/wiki/Cache_algorithms)快取演算法(預設是64MB),當快取的資料超過設定的記憶體容量時,就是自動清除太久沒有使用的資料,這個特性等會我們會看到非常實用。 更深入的memcached用法可以參考筆者[如何使用 memcached 做快取](http://ihower.tw/blog/archives/1768)一文。 ## 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 ~~~ `write`和`fetch`支援`expires_in`參數可以設定時效。 ## 使用HTTP快取 在HTTP 1.1規格中定義了Cache-Control、ETag和Last-Modified等Headers可以更細微的設定用戶端和伺服器之間要如何快取,Rails也有語法可以很方便的支援。這在大型網站的架構中,會搭配HTTP快取伺服器,來獲得最大的效益。例如[Varnish](https://www.varnish-cache.org/)或[Squid](http://www.squid-cache.org/)。 ### HTTP Cache-Control 使用`expires_in`和`expires_now`方法。 ### HTTP ETag 和 Last-Modified 使用`fresh_when`和`stale?`方法,當判斷response內容沒有更新的時候,只回傳HTTP 304 Not Modified。 ## 其他線上資源 * [Caching with Rails: An overview](http://guides.rubyonrails.org/caching_with_rails.html) * [Google Developers: HTTP 快取](https://developers.google.com/web/fundamentals/performance/optimizing-content-efficiency/http-caching)
';