快取

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的快取分成L1L2L3,然后是内存、最后是硬盘空间,这之间的存取速度和可用空间差了好几个数量级,前者对后者来说,就是一种快取层。而资料一旦被放到快取,就要去处理资料的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

  1. $ brew install memcached

在 Ubuntu Linux 服务器上,用 apt-get 就可以安装了:

  1. $ sudo apt-get install memcached

接着编辑Gemfile加上memcached的函式库

  1. gem "dalli"

编辑config/environments/development.rbproduction.rb加上

  1. 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

  1. <% cache [@events] do %>
  2. All events:
  3. <% @events.each do |event| %>
  4. <%= event.name %>
  5. <% end %>
  6. <% end %>

cache的参数是拿来当作快取Key的物件或名称,我们也可以多加一些名称来识别。Rails会自动将ActiveRecord物件的最后更新时间、你给的客制名称,加上Template的内容杂凑自动产生出一个快取Key

  1. <% cache [:popular, @events] do %>
  2. All popular events:
  3. <% end %>

更新快取的策略

用了快取,就还要学会怎么处理过期资料,也就是在资料过期之后,将对应的快取资料清除。Rails采用的策略非常聪明,就是利用LRU快取算法的特性,根据当时情境来动态命名快取Key,从而避免手动清除快取的动作,反正快取内存一满,没用到的快取资料就会自动被清除掉。

实际看看Rails产生出来的快取Key吧,例如cache [@event]会产生出以下的快取Key

  1. views/events/3-20141130131120000000000/366bcee2ae9bd3aa0738785aea6ec97d

其中3Event ID20141130131120000000000是这个Event的最后更新时间、366bcee2ae9bd3aa0738785aea6ec97d是这个Template内容的杂凑。也就是如果资料有更新,或是Template有改动,那么产生出来的快取Key就会不一样,产生出新的快取资料。至于旧的快取资料就不管了,反正满了就会被LRU自动清掉。

如果放一个ActiveRecord阵列呢,例如cache [:list, @events],会产生出以下的快取Key

  1. views/list/events/3-20141130131120000000000/events/4-20141111035115000000000/events/7-20141130131005000000000/events/8-20141111035115000000000/events/9-20141111035115000000000/bbce07d6df6dd28670ad114790c47484

Rails会将所有的最后更新时间都串在一起,只要其中一个最后更新有改,整个快取资料就会重新产生。

这一招当然也不是万能,例如如果你的资料跟当时语系又有关系,那你就得把语系这个变量也设定到快取Key,例如

  1. <% cache [:list, @events, I18n.locale] %>

当然,我们也可以找地方手动清除快取,例如放到update action之中:

  1. expire_fragment(:popular_events)

rake tmp:clear指令可以清空全部快取

另一种快取更新的策略是设定Time-based expired,例如设定两小时后自动过期:

  1. <% cache :popular_events, :expires_in => 2.hours do %>

调校快取Key

View快取的一个目的就是节省SQL的查询量,所以实测的一个重点,就是要观察实际到底发出哪些SQL查询。在上述的范例中,Rails用了ActiveRecord的最后更新时间来产生快取Key,因此实际上它还是发出SQL查询来抓到最后更新时间。这部份我们可以做进一步的改进,特别是cache(@events)群集的部分,我们可以用自订快取Key的方式来改善SQL的效率,例如:

  1. # helper
  2. def cache_key_for_events(page)
  3. count = Event.count
  4. max_updated_at = Event.maximum(:updated_at).try(:utc).try(:to_s, :number)
  5. "events/all-#{count}-#{max_updated_at}-#{page}"
  6. end
  7. <% cache cache_key_for_events(params[:page]) do %>

这样就实际的SQL查询就会从:

  1. SELECT `events`.* FROM `events` LIMIT 10 OFFSET 0

变成比较有效率的:

  1. SELECT COUNT(*) FROM `events`
  2. SELECT MAX(`events`.`updated_at`) AS max_id FROM `events`

另外要注意是因为有ActiveRecordLazy Load特性,所以写在Controller Action里的ActiveRecord Query才不会立即送出,而是到真正使用的时候(也就是在Fragment cache范围里)才会实际发出SQL查询。如果真没有办法利用到Lazy Load的特性,例如不是ActiveRecord的情况,则可以手动使用fragmentexist?方法在_Action里面检查是不是已经有快取,有的话就不要执行,例如:

  1. def show
  2. @event = Event.find(params[:id])
  3. unless fragment_exist?(@event)
  4. @result = SomeExpenseQuery.execute(@event)
  5. end
  6. end
  7. # show.html.erb
  8. <% cache @event do %>
  9. <%= @event.name %>
  10. <%= @result %>
  11. <% end %>

Russian Doll快取策略

上述cache [:list, @events]的范例中,如果其中一笔资料有更新,会造成整组@events快取资料都要重新计算,这一点很没效率。Rails支援nested的叠套方式让我们可以重用(reuse)其中的快取资料,例如:

  1. <% cache [:list, @events] %>
  2. All events:
  3. <% @events.each do |event| %>
  4. <% cache event do %>
  5. <%= event.name %>
  6. <% end %>
  7. <% end %>
  8. <% end %>

如果其中一笔event有更新,最外围的快取也会一起更新,但是它不会笨笨的重算每一个小event的快取,只会重算有更新的event而已,其他event则会沿用已经有的快取资料。

ActiveRecord Touch 属性

被当作快取KeyActiveRecord物件的最后更新时间updatedat,在一对一或一对多的关系中,默认并不会根据底下的物件而自动更新。例如以下的例子中,如果有新的_attendee进来,并不会自动更新该event的最后更新时间,会导致这整个快取不会被更新到。

  1. <% cache event do %>
  2. <%= event.name %>
  3. <%= event.attendees.last.try(:name) %>
  4. <% end %>

解决的办法是使用Touch属性:

  1. class Attendee < ApplicationRecord
  2. belongs_to :event, :touch => true
  3. # ...
  4. end

这样的话,在新增或编辑attendee后,Rails就会知道要去更新event的最后更新时间,进而重新更新的这份快取了。

快取资料

上述的作法都是将最后的HTML结果快取起来,但是有时候如果形式有很多种,例如同时提供HTMLJSONXML等,或是有其他程式也想利用同一份快取,这时候我们可以考虑快取资料(字串、阵列或杂凑的基本形式),而不是最后的HTML

  1. Rails.cache.read("city") # => nil
  2. Rails.cache.write("city", "Duckburgh")
  3. Rails.cache.read("city") # => "Duckburgh"
  4. Rails.cache.fetch("#{id}-data") do
  5. Book.sum(:amount, :conditions => { :category_id => self.category_ids } )
  6. end

writefetch支援expires_in参数可以设定时效。

使用HTTP快取

HTTP 1.1规格中定义了Cache-ControlETagLast-ModifiedHeaders可以更细微的设定用户端和服务器之间要如何快取,Rails也有语法可以很方便的支援。这在大型网站的架构中,会搭配HTTP快取服务器,来获得最大的效益。例如VarnishSquid

HTTP Cache-Control

使用expires_inexpires_now方法。

HTTP ETag 和 Last-Modified

使用freshwhenstale?方法,当判断_response内容没有更新的时候,只回传HTTP 304 Not Modified

其他线上资源