快取
快取
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 的情况,则可以手动使用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 < 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
使用fresh _when_ 和stale?方法,当判断_response_内容没有更新的时候,只回传 _HTTP 304 Not Modified 。
