深入理解Ruby on Rails中的缓存机制
几个场景
首先,让我先来带您浏览几个 ChangingThePresent.org 中的页面吧。我将显示站点中几个需要缓存的地方。然后,再指出我们为其中每个地方所做出的选择以及为实现这些页面所使用的代码或策略。尤其会重点讨论如下内容:
- 全静态页面
- 几乎无变化的全动态的页面
- 动态页面片段
- 应用程序数据
先来看看静态页面。几乎每个站点都会有静态页面,如图 1 所示,其中还有我们的条款和条件。可以通过单击 register 然后再选择是否接受用户协议来浏览相应页面。对于 ChangingThePresent 而言,我们从此页中删除了所有动态内容以便 Apache 能够对它进行缓存。按照我们 Apache 中配置的规则,这些内容永远都不会由 Rails 服务器生成。因此,我根本无需对其考虑 Rails 缓存。
图 1. 用户协议
接下来,再来看看全动态页面。理论上讲,ChangingThePresent 可以有一些动态构建的页面,但是这些页面一般很少变化。由于几乎所有页面都会显示用户是否登录,因此我们并不怎么关注这种缓存。
再下来,看看页面分段缓存。图 2 中所示的主页原来是完全静态的,现在,有一些元素变成了动态的。每天,页面都会显示一系列礼物,这些礼物有的是随机选的,有的则由我们的管理员选定。请注意在标题为 “A Few of our Special Gifts for Mother's Day” 节下的那些礼物,同时也请注意在最右边显示为 “login.” 的那个链接。此链接取决于用户是否登录。我们不能缓存整个页。页面每天只能改变一次。
图 2. 主页
最后再考虑应用程序。除非是在 15 年之前进行网络冲浪,否则您现在遇到的有趣站点全部都是动态的。现代的应用程序大都分层,而且可以通过在层间添加缓存来使这些分层更加有效。ChangingThePresent 在数据库层采用了一些缓存。接下来,我将深入讨论不同类型的缓存,还会介绍我们为 ChangingThePresent 都采用了何种缓存。
缓存静态内容
Mongrel 是一种 Web 服务器,由 Zed Shaw 利用 2500 行 Ruby 和 C 编写而成。这个小型的服务器占用内存极少,非常适合 Ruby Web 应用程序,例如 Rails、Nitro、Iowa 等等。Mongrel 可运行于 UNIX? 和 Linux? 上,也可运行在 Win32 上。Mongrel 也经常可以作为代理运行在另一个 Web 服务器(例如 Apache 或 Litespeed)的后端,但这不是必需的 ―― 因为 Mongrel 是一种 HTTP 服务器,可以与所有您偏好的 HTTP 工具结合使用。
除了图像之外,有关缓存静态数据的内容,可讲的内容不多。由于我们的网站是一个慈善性质的门户网站,这意味着我们需要更多地关注用户的感受,比如多加入一些图像或视频。但我们的 Web 服务器 Mongrel 并不能很好地服务静态数据,因此我们使用 Apache 来服务图像内容。
我们现在正在着手转向采用图形加速器 Panther Express 来缓存最经常被使用的图像以使其能够更快地被我们的客户访问到。要采取这种策略,我们将需要一个子域 images.changingThePresent.org。Panther Express 直接在图像的本地缓存中提供图像服务,然后再向我们发送请求。由于 Panther 服务并不知道我们何时会更改图像,所以我们使用 HTTP 报头来使其到期失效,如下所示:
HTTP 缓存失效报头
HTTP/1.1 200 OK Cache-Control: max-age=86400, must-revalidate Expires: Tues, 17 Apr 2007 11:43:51 GMT Last-Modified: Mon, 16 Apr 2007 11:43:51 GMT
注意这些不是 HTML 报头。它们与 Web 页面内容独立构建。Web 服务器将负责构建这些 HTTP 报头。像这样一篇有关 Rails 的文章系列若详细介绍 Web 服务器配置,未免有点偏题,所以我将直接切入可用 Rails 框架进行控制的缓存内容这一主题(有关 Web 服务器配置的更多内容,请参见 参考资料 中的相关链接)。
页面缓存
如果动态页面不经常更改,可以使用页面级的缓存。比如,Blog 和公告牌使用的就是这种缓存。通过页面缓存,Rails 就可以用来构建动态 HTML 页,并将此页存储在公共目录,这样,应用程序服务器就可以像服务其他静态页面一样来服务这个动态页。
如果页面已经被缓存,那么就不需要引入 Rails,页面缓存是 Rails 内速度最快的一种缓存。在最底层,页面缓存实际上在 Rails 中非常容易实现。页面和分段缓存二者均在控制器级别发生。您需要告知 Rails 如下内容:
- 想要缓存哪些页面?
- 当页面内容更改时,您如何能在缓存中让该页面到期失效?
可以通过在控制器类中使用 caches_page 指令来启用页面缓存。例如,若要在 about_us_controller 缓存 privacy_policy 和 user_agreement 页面,可以输入如下代码:
清单 2. 启用页面缓存
class AboutController < ApplicationController caches_page :privacy_policy, :user_agreement end
让页面到期失效则可以通过 expire_page 指令来实现。若要在 Rails 调用 new_pages 动作时使上述页面到期失效,可以使用如下代码:
清单 3. 使页面失效
class AboutController < ApplicationController caches_page :privacy_policy, :user_agreement def new_pages expire_page :action => :privacy_policy expire_page :action => :user_agreement end end
另外,有几个小问题需要注意,比如 URL。URL 不能依赖于 URL 参数。例如,应该使用 gifts/water/1 而非 gifts/water?page=1。在 routes.rb 中使用这类 URL 将非常容易。比如,我们的页面中总是有一个选项卡参数用来显示哪个选项卡被当前选中。若要将此选项卡作为 URL 的一部分,我们会有如下的路由规则:
清单 4. 选项卡的路由规则
代码如下:
map.connect 'member/:id/:tab', :controller => 'profiles', :action => 'show'
对于具有页面参数的那些列表以及依赖于 URL 参数的其他页面,也需要采用相同的做法。此外,还需要考虑安全性问题。
如果页面已经在缓存内,那么就不会用到 Rails 框架,服务器并不能为您管理安全性。Web 服务器将更乐于在缓存内呈现任何页面,而不管用户是否对其拥有查看的权限。所以,如果您很关心页面可由谁查看,那么就不要使用页面缓存。
如果只是想缓存简单的静态页面,那么了解上述内容就应该足够了。只要内容简单,实现起来就不难。
当想要缓存更为复杂的内容时,就需要进行一些权衡取舍了。由于想要缓存的页面高度动态,所以到期失效逻辑就会变得更加复杂。要处理复杂的到期失效逻辑,将需要编写和配置定制清理器(sweeper)。在某些控制器击发时,这些类会从缓存内删除选定的元素。
多数定制清理器都会观察某些模型对象,并根据更改击发逻辑来使一个或多个缓存页面到期失效。清单 5 显示了一种典型的缓存清理器。在此清理器中,开发人员可以定义一个活动记录事件,比如 after_save。当此事件击发时,清理器也会击发,并可让缓存内的特定页面到期失效。这个事例所显示的到期失效基于 expire_page 方法。而很多严格的应用程序大都直接使用 Ruby 优秀的文件系统实用工具来显式地删除所缓存的页面。
清单 5. 一个典型的观察器
class CauseController < ApplicationController cache_sweeper :cause_sweeper ... class CauseSweeper < ActionController::Caching::Sweeper observe Cause def after_save(record) expire_page(:controller => 'causes', :action => 'show', :id => record.id) cause.nonprofits.each do |nonprofit| expire_page(:controller => 'nonprofits', :action => 'show', :id => nonprofit.id) end end end
现在,您可能会开始感觉到页面缓存的些许缺点了:复杂性。您虽然可以很好地进行页面级的缓存,但固有的复杂性却让应用程序很难测试,进而会增加系统内出现 bug 的可能性。而且,如果页面针对每个用户都会有所不同,或者希望缓存进行过身份验证的页面,那么将需要使用页面缓存之外的方式。对于 ChangingThePresent,我们必须处理两种情况,原因是我们必须基于用户是否登录来更改基本布局上的链接。对于大多数页面,我们甚至都不会考虑使用页面级缓存。为了让您能够深入了解页面级的缓存,在本文的 参考资料 部分,我特意给出了到一系列有关页面级缓存的优秀文章的链接。接下来,将来深入探究另一种形式的整页缓存 ―― 动作缓存。
动作缓存
至此,您已经了解了页面缓存的主要的优势及主要的缺点:对于多数页面检索而言,根本无需考虑使用 Rails。页面缓存的优势是速度快。缺点是缺少灵活性。如果想要基于应用程序内的条件 ― 例如,身份认证 ― 来缓存整个页面,那么可以使用动作缓存。
动作缓存与页面缓存的工作方式大体相同,但在流程上稍有差别。Rails 在呈现动作前会实际调用控制器。如果由该动作呈现的页面已经存在于缓存内,那么 Rails 就会在缓存内呈现页面而不是重新加以呈现。由于现在使用了 Rails,因此动作缓存的速度要比页面缓存慢一些,但其优点还是很明显的。几乎所有的 Rails 认证模式都会在控制器上使用 before 过滤器。动作缓存让您能够利用认证及控制器上的任何过滤器。
从语句构成的角度来看,动作缓存与页面缓存也应该十分类似,只有指令不太一样。清单 6 显示了如何使用 caches_action 指令。
清单 6. 启用动作缓存
class AboutController < ApplicationController caches_action :secret_page, :secret_list end
缓存到期失效以及清理器的工作方式也应该相同。我们不使用动作缓存的原因与我们不使用页面缓存的原因是一样的,但分段缓存对我们来说更重要一些。
缓存页面分段
借助部分缓存,可以缓存页面的一部分,所缓存的内容很多时候都是布局之类的。要使用分段缓存,开发人员需要先确定分段,方法是通过在 Web 页面上直接放上 rhtml 指令来包围一块内容,如清单 7 所示。在 ChangingThePresent.org 上,我们使用分段缓存来缓存首页和其他的几页。所有的这些页均使用了数据库密集访问而且大都是我们最受欢迎的页面。
清单 7. 确定缓存分段
<% cache 'gifts_index' do %> <h3> Here, you can make the world a better place with a single gift. Donation gifts are also a wonderful way to honor friends and family. Just imagine what we can achieve together. </h3> <h2 class="lightBlue"><%= @event_title %></h2> <div id="homefeatureitems"> <% for gift in @event_gifts %> <%= render :partial => 'gifts/listable', :locals => { :gift => gift } %> <% end %> </div> ... <% end %>
在清单 7 中,cache 帮助程序标识所要缓存的分区。第一个参数是标识此缓存分区的惟一名称。第二个参数包含代码块 ― 即第一个 do 和最后一个 end 之间的代码 ― 此代码块准确地确定了要缓存的 RHTML 分区。
我们的网站只有一个主页,所以命名这个页面非常容易。在其他地方,我们使用一种特定的方法来决定此网页的 URL 以便惟一标识缓存分段。例如,当我们为特定的内容(比如世界和平或减少贫困)而进行代码缓存时,我们需要使用清单 8 中的代码。代码会为之寻找永久 url,也称为 permalink。
清单 8. 通过 URL 标识缓存分段
<% cache @cause.permalink(params[:id]) do %>
通常,当缓存单独页面时,需要用清理器使之过期失效。有时,使用简单的基于时间的对象过期更为容易和简洁。默认地,Rails 并不提供这类机制,但有一种插件名为 timed_fragment_cache 可以实现这一目的。借助这个插件,我可以指定超时,可以在缓存了的内容中指定,也可以在为此页提供了动态数据的控制器代码中指定。例如,清单 9 所示的代码就可以为此页面构建动态数据。when_fragment_expired 方法只有在相关的缓存分段过期时才会执行。此方法接受参数,用来指定超时的时长,它还接受一个代码块,用来指定当内容过期时哪些内容需要重建。我也可以选择在 rhtml 页面中指定超时和缓存方法,但我更愿意使用基于控制器的方法。
清单 9. 基于时间的缓存到期
def index when_fragment_expired 'causes_list', 15.minutes.from_now do @causes = Cause.find_all_ordered end end
如果能够容忍数据稍微有些陈旧,那么使用定时的到期机制将可以极大地简化缓存策略。对于每个被缓存的元素,只需指定想要缓存的内容、可生成动态内容的任何控制器动作以及超时。与页面缓存类似,如果需要,也可以使用 expire_fragment :controller => controller, :action => action, :id => id 方法显式让内容到期。此方法的工作方式与缓存动作和缓存页面的到期失效是一样的。接下来,我将介绍如何配置此后端。
Memcached
至此为止,我已经介绍了 Ruby on Rails 的页面和分段缓存模型。看过了 API 之后,现在就可以定义缓存后的数据的去处了。默认地,Rails 将把缓存后的页面放入文件系统。缓存后的页面和动作都会进入公共目录。可以配置缓存后的分段的存储位置。为此,需要用到内存存储、文件系统(在所定义的目录)、数据库或称为 memcached 的服务。对于 ChangingThePresent.org,我们使用 memcached。
可以将 Memcached 想象为一个大型的 hash 图,这个图可通过网络获得。基于内存的缓存速度快,而基于网络的缓存的可伸缩性比较好。有了插件支持,Rails 就可使用 memcached 来缓存分段和 ActiveRecord 模型。要使用它,需要安装 memcached(更多信息,请参看 参考资料)并在 environment.rb(或其他的环境配置文件,比如 production.rb)对它进行配置。
清单 10. 配置缓存
config.action_controller.perform_caching = true memcache_options = { :c_threshold => 10_000, :compression => false, :debug => false, :readonly => false, :urlencode => false, :ttl => 300, :namespace => 'igprod', :disabled => false } CACHE = MemCache.new memcache_options
清单 10 显示了一种典型的配置,其中第一行 config.action_controller.perform_caching = true 将启用缓存。接下来的一行将准备缓存选项。注意,这里的诸多选项是为了让您可以获得更多的调试数据、禁用缓存和定义该缓存的名称空间。在 参考资料 部分给出的 memcached 站点可以找到有关配置选项的更多信息。
模型缓存
我们使用的最后一种缓存是基于模型的缓存。我们使用的是称为 CachedModel 的缓存插件的一种定制版本。模型缓存实际上是一种有限形式的数据库缓存。缓存很容易按模型启用。
要想让模型使用缓存解决方案,只需扩展 CachedModel 类,而非扩展 ActiveRecord,如清单 11 所示。 CachedModel 扩展 ActiveRecord::Base。ActiveRecord 并非全对象关系型映射层。此框架极大地依赖于 SQL 来执行复杂的特性,而且如果需要,用户可以很容易降至 SQL。直接使用 SQL 会使缓存出问题,因为缓存层必须处理完整的结果集,而不是单独一个数据库行。处理完整的结果集常常会问题不断,而且如果没有支持应用程序的深层逻辑,这几乎不太可能。正由于这个原因,CachedModel 的焦点才会放到缓存单个模型对象上,并只加速返回单行结果的查询。
清单 11. 使用 CachedModel
Class Cause < CachedModel
大多数 Rails 应用程序都会重复访问多个条目,例如用户对象。模型缓存在很多情况下都可以明显地使速度加快。对于 ChangingThePresent,我们刚刚开始加速基于模型的缓存。
结束语
Ruby 虽然是一门生产率极高的语言,但若从性能角度考虑,该语言解释性的特性让它并不那么理想。大多数主要的 Rails 应用程序都将会通过有效利用缓存来弥补某些不足。对于 ChangingThePresent.org,我们主要使用分段缓存,并通过控制器使用基于时间的方法来使缓存分段到期失效。这种方式很适合我们的网站,即使其中有一些页面会基于登录进来的用户有所变化。
我们还研究了使用受 memcached 支撑的 CachedModel 类所能带来的影响。虽然我们的研究还仅限于缓存对数据库性能所造成的影响,但早期的结果还是很有希望的。在 下一篇 文章中,我将介绍一些实用技巧,您可以使用这些技巧来为另一个真实世界中的 Rails 示例进行数据库优化。