快取
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的情况,则可以手动使用fragmentexist?
方法在_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物件的最后更新时间updatedat
,在一对一或一对多的关系中,默认并不会根据底下的物件而自动更新。例如以下的例子中,如果有新的_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
使用freshwhen
和stale?
方法,当判断_response内容没有更新的时候,只回传HTTP 304 Not Modified。