快取

    关于快取,有句话是这样说的:“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中,这样下一次就不需要重新运算,大幅提高效能。

    Rails提供了几种不同的Cache Store可以选择,默认的memory_store只适合单机开发,而且重启Rails快取资料就不见了。因此正式上线的网站会推荐使用。它是一套Name-Value Pair(NVP)分布式内存快取系统,当你有多个Rails服务器的时候,也可以很方便的共享快取资料。

    使用Mac的话,可以用Homebrew安装Memcached

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

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

    1. gem "dalli"

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

    1. config.cache_store = :mem_cache_store

    使用memcached做快取的基本模式就是,先查看有没有key-value,有就把快取资料读出来,没有就运算结果后存到memcached快取数据库中(你应该假设就算快取系统关闭,你的系统也可以正常执行)。注意到它并不是persistent data store,只要一关掉memcahed重开,里面的资料就会通通不见。另一个特性是它使用LRU快取算法(默认是64MB),当快取的资料超过设定的内存容量时,就是自动清除太久没有使用的资料,这个特性等会我们会看到非常实用。

    Fragment caching可以只快取HTML中的一小段元素,我们可以自由选择要快取的区块,例如侧栏或是选单等等,让我们有最大的弹性。也因为这种快取发生在View中,所以我们必须把快取程式放进View中,用cache包起来要快取的Template

    1. <% cache [@events] do %>
    2. All events:
    3. <% @events.each do |event| %>
    4. <%= event.name %>
    5. <% 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

    其中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)

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

    调校快取Key

    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 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. <% cache @event do %>
    8. <%= @event.name %>
    9. <%= @result %>

    上述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

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

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

    HTTP ETag 和 Last-Modified

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