在最近几个月里,我从性能问题的角度,分析了不少 Rails 应用程序(里面有一些牵涉到我的咨询业务,另外一些则是开源应用)。这些应用程序面向的多个领域之间存在着诸多差异,导致每项性能调优任务都颇具挑战性。然而,它们之间还是存在不少共性,使得我们得以提炼出不少出现问题的地方,正是它们导致了很多应用程序难以达到高性能。它们包括:
- 选用了缓慢的 Session 容器
- 本可在启动时一次性完成的操作被放在每次请求中执行
- 请求处理过程中重复相同运算
- 频繁从数据库加载过多数据(尤其在使用关联进行连接的情况下)
- 过分依赖于低效 helper 方法
除此之外,Rails 框架自身依然存在某些问题区(problem areas),它们的性能是我所希望在今后改善的。里面有一部分可以在应用程序级别解决,另外一部分则无能为力。下面是我比较感兴趣的问题:
- Route 识别和 Route 生成
- ActiveRecord 对象的构造
- SQL 查询的构造
我会在下文中罗列出针对以上问题的部分编码技巧。
一些忠告
遵循本文的建议,有可能改善你的应用程序的性能,但也可能行不通。性能调优是件费脑筋的事情,尤其在实现语言结构的性能特征没有被明确规范的时候,更是如此(在 Ruby 里面就是这种情况)。
我强烈建议,在进行任何变更之前,先评测一下你的应用程序原先的性能,之后每次变更后再次进行评测。我编写了一个包,叫做 railsbench ,这是一个很不错的性能回归测试工具。它简单易用,只需几分钟你就可以得到性能的基准数据。不过比较遗憾,它无法告诉你,在你的应用程序里时间到底花费在哪里 _(译注:此外,你的应用程序所调用的 Rails 框架的代码也是有时间开销的)_。
假如你手头有台运行 Windows 的机器(或者一台带双启动的 Intel Mac),我建议你去试试 Software Verification Ltd.(SVL)的 Ruby Performance Validator (RPVL)。对于我深及 Rails 框架内核的 Rails 性能调优工作,特别是在我所建议的在已有热点视图基础上的调用图功能被 SVL 实现之后,我发现它真是功勋卓著。据我所知,它是目前市面上唯一的 Ruby 应用程序性能分析工具。Railsbench 内建了对 RPVL 的支持,这样也使得在 RPVL 之下运行被 Railsbench 定义的基准变得易如反掌。
选用 Session 容器
Rails 提供了几个内建的 Session 容器。在所有我分析过的应用程序里,要么使用了将 Session 信息储存在你文件系统上独立文件的 PStore,要么用了和数据库打交道的 ActiveRecordStore。这两个方案都不甚理想,特别是拖累了缓存 Action 的页面(action cached pages)。这里提供两个 _ 好用得多 _ 的备选方案供大家参考: SQLSessionStore 和 MemCacheStore 。
SQLSessionStore 通过以下手段避免了 ActiveRecordStore 相关的额外性能开支:
- 避免使用事务(对于 SQLSessionStore 的正确操作,它们并不是必要的)
- 撤销向数据库更新“created_at”和“updated_at”的操作
如果使用 MySQL,你应当保证使用 MyISAM 表来存储 Session 数据,因为它要比 InnoDB 表 _ 快很多 _,并且它不会强制你使用事务处理。前不久我又为 SQLSessionStore 增加了 Postgres 支持,不过,用于 Session 存储 Postgres 看起来要比 MySQL 慢得多。因此,如果你打算使用基于数据库的 Session 存储,我推荐为 Session 表安装 MySQL 数据库(我想不出一个基于 session id 的需要连接的更好用例(use case))。
MemCacheStore 要比 SQLSessionStore 快得更多。我的测评结果显示,对于缓存了 Action 的页面它能够带来了 30% 的速度提升 。你得先安装 Eric Hodel 的 memcache client ,并在 environment.rb 中做相应配置之后才能正常使用。注意: Ruby-Memcache 还是不要去试的好(实在、实在慢得让人难以忍受)。
在我自己的项目中,我更倾向于使用基于数据库的 Session 存储,原因是可以使用 Rails 命令行或者数据库软件包提供的管理工具进行简单得管理。对于 MemCacheStore 你就得自己为它编写脚本了。另一方面,对于高访问量网站来说,内存缓存方式的扩展性更好一些,并且它随支持 Session 超时(session expiry)的 Rails 一起提供。
在请求过程中缓存运算结果
假如你在处理一次请求过程中不止一次需要某些相同的数据,但由于你的数据以某种形式依赖于请求参数,而不能使用类级别缓存的时候,那么请将数据缓存起来,以避免重复计算。
要采取这种模式很简单:
module M def get_data_for(request) @cached_data_for_request ||= begin <span no_style="font-style: italic;">expensive computation depending on request returning data</span> end end end
你的代码可以是简单的 “A”…“Z”.to_a ,或者是一个数据库查询,例如取得一个指定的用户等。
在启动或者首次访问时执行与请求无关的运算
这个性能忠告太简单了,我都有点儿犹豫是不是还该把它放在本文中谈论——然而,我还是发现很多我分析过的应用程序并没有采取这项有效的优化技术。
事实上,这个技术再简单不过了:如果你的应用程序里面存在哪些数据,在整个生命周期里都不会发生改变,或者数据改变之少到发生变更之后进行一次服务器重启就可以解决问题的话,那么,请把这些数据缓存在你的应用程序中某个类适当的类变量中。常见的范例如下:
class C @@cached_data = nil def self.cached_data @@cached_data ||= <span no_style="font-style: italic;">expensive computation returning data</span> end ... end
实际的例子有:
- 应用程序的配置数据(如果你把应用程序设计成其他人可以安装的方式)
- 数据库中恒定不变的(静态的)数据(否则使用缓存)
- 使用 ObjectSpace.each 检测已安装的 Ruby 类 / 模块
优化查询
Rails 提供了功能强大的领域特定语言(Domain Specific Language),用来定义模型类之间反映数据表关系的关联。哎,可惜目前的实现仍然没有对性能进行优化。依赖于内建生成的访问器会严重地影响性能。p>
首当其冲的问题就是常被大家称作“1 + N”查询的问题:如果你从类 Article(数据表“articles”)加载 N 个对象,而类 Article 到类 Author(数据表“authors”)之间存在多对一的关系,使用生成访问器方法访问一个特定的 Article 将会触发 N 个额外的数据库查询。这自然而然就给数据库带来了额外负担,不过对于 Rails 应用服务器性能来说,更重要的是,要提交的 SQL 查询语句会为已经访问过的对象被重新进行构造。
要解决这个额外开销,你可以像下面一样在你的查询参数中添加一个 :include => :author:
Articles.find(:all, :conditions => ..., :include => :author
这样一来,通过提交单独的 SQL 语句以及立即构造 Author 对象的方式,上述额外开销都能得以避免。这项技术常常被称为“贪婪关联查找(find with eager associations)”,并且它对于其它关系类型同样适用(比如一对一、一对多或者多对多关联)。p>
尽管如此,我们还可以使用一项叫做“携带加载(piggy backing)”的技术来进一步优化多对一关系:来自原数据表的属性连同连接表的属性均由带表连接的 ActiveRecord 对象。因此,一条带连接的查询就可以从数据库全数抓取所需的信息。假设你的视图要显示的只是关联到文章信息的作者名称,你可以把上面的查询替换成:
Articles.find(:all, :conditions => ..., :joins => "LEFT JOIN authors ON articles.author_id=authors.id", :select => "articles.*, authors.name AS author_name")
此外,假如你的视图显示的是可用的文章字段的一个子集,譬如“title”、“author_id”和“created_at”,那么你得把上述代码改成:
Articles.find(:all, :conditions => ...,<br></br> :joins => "LEFT JOIN authors ON articles.author_id=authors.id",<br></br> :select => "articles.id, articles.title, articles.created_at, articles.author_id, authors.name AS author_name")<br></br>
一般说来,仅加载部分对象可以在一定程度上提升查询的速度,尤其在你的模型对象拥有大量字段的时候更是如此。为了使用这项技术达到全面提速,你还得在模型类中定义一个方法,用来访问查询附带的所有属性:
class Articles<br></br> ... <br></br> def author_name<br></br> @attributes['author_name'] ||= author.name<br></br> end<br></br>end<br></br>
当你编写视图代码时,使用这个模式,你就可以不必去了解原来的查询是否存在连接了。
假如你的数据库支持视图,你可以定义一个仅包含必要信息的视图,这样就不必手动编写复杂的查询代码了。这么做的另外一条好处是,可以为你从连接表内加载的字段获得正确的数据类型转换。到目前为止,Rails 尚未提供这些功能,你必须手动为它们编写代码。
给“潮流前沿的伙计们”的一点补充:翻来覆去捣腾这些相同的模式真是让我颇为厌倦,所以我编写了一些扩展代码用来自动完成大部分这类任务。请到我的博客上查看其预发行版。
避免使用笨拙迟缓的 helper 方法
在 Rails 内核中,有许多 helper 方法运行缓慢。一般而言,所有取 URL hash 作参数的 helper 方法都会调用 routing 模块,来生成引用前基础控制器 action 的最短 URL。这就意味着 route 文件中的几个 routes 应该进行检查,这个过程通常煞费时间。哪怕只是使用像下面一个简单的 route 文件:
ActionController::Routing::Routes.draw do |map|<br></br> map.connect '', :controller => "welcome"<br></br> map.connect ':controller/service.wsdl', :action => 'wsdl'<br></br> map.connect ':controller/:action/:id'<br></br>end<br></br>
你将会发现写成
link_to "Look here for joke #{h @joke.title}",<br></br> { :controller => "jokes", :action => "show", :id => @joke },<br></br> { :class => "joke_link" }<br></br>
和直接编写这样精简的 HTML 之间显著的性能差异:
<a href="/jokes/show/<%= @joke.id %>"<br></br> class="joke_link">Look here for joke <%= h @joke.title %></a><br></br>
对于显示大量链接的页面,我所测得的速度提升比例最高能达到 200%(前提是其它部分已经进行了优化)。
为使模板代码可读性更强,并避免不必要的重复,我通常在 application.rb 内加入生成链接的 helper 方法:
def fast_link(text, link, html_options='')<br></br> %(<a href="#{request.relative_url_root}/#{link}"> hmtl_options>#{text})<br></br>end<p>def joke_link(text, link)</p><br></br> fast_link(text, "joke/#{link}", 'class="joke_link"')<br></br>end<br></br>
将上述范例改成:
joke_link "Look here for joke #{h @joke.title}", "show/"
用这种方式来解决 route 生成缓慢的问题显然过于繁琐,一般只有性能要求很高的页面才应当使用。如果你不必马上进行性能调优的话(啊呃,读起来挺像我每天收到的垃圾邮件的感觉),不妨关注我那即将问世的模板优化工具的第一个发行版本,它将在大多数情况下自动帮你完成所有此类工作。
未来 Rails 性能调优的话题
正如上文所说,route 识别和 route 生成的性能需要有些东西来协助解决。我的模板优化工具将解决 route 生成问题。上周又有一个新的 route 实现被整合进 Rails 开发分支中。我进行了一些评测,结果显示,在 route 识别方面,性能似乎得到了一些改善。但就 route 生成而言,结果则比较参差不齐。新的实现是否能改善到一直都比先前版本的速度快,尚且有待观望。
从数据库加载大量 ActiveRecord 对象相对来说是比较慢的,当然程度并不高,因为实际的数据库连接传输开销比较大。不过,由于行数据被表示成用字符串作为键值索引的 hash,在 Rails 内部构造 Ruby 对象的开销相当昂贵。转而使用基于数组的数据行类(array based row class)可以改善这个问题。然而,要做得恰如其分的话,就得牵扯到 ActiveRecord 实现的核心部分,因而我们只能寄希望它在 Rails 2.0 中推出。
最后一点,目前 SQL 查询的构造方式,使得生成查询语句的运算比从数据库获取实际数据的代价更为高昂。要大幅度改善这个现状,我的看法是,可以使绝大多数 SQL 查询只生成一次,然后代入实际参数值进行扩充。当前唯一能解决这个问题的方法只有手动编写你的查询代码。
注:以上关于追求更优性能的可行方案,是我个人的观点,并不代表任何官方“核心团队”对待这些问题的看法。
结语
上文列举的问题并不想给你一种 Rails 可能运行缓慢(乃至于我觉得它太过迟缓)的心里暗示。恰恰相反,我坚信 Rails 是一个卓越的 Web 应用开发框架,对于稳健而又快速的 Web 应用的开发大有裨益,并且带来开发效率的改善。正如所有框架一样,它提供了方便的使用方法,可以大幅度地提升你的开发速度,而且在绝大多数场合下适用于你绝大部分的需求。不过,有些时候,你必须尽可能在每一秒内腾出一些额外的请求,或者你在硬件资源上面受到一定限制,了解如何进行性能调优是大有帮助的。一旦你碰上性能问题,希望本文帮助你理清某些范围的要点,助你一臂之力。
关于作者
Stefan Kaes 是 Rails 性能方面的权威博客 RailsExperess 和于 2007 年初发行的《Performance Rails》一书的作者。Stefan 的书将跻身于 Addison-Wesley 新系列丛书 Professional Ruby 的首发阵容。此系列丛书将于 2006 年底面世,包括 Hal Fulton 的《The Ruby Way》第二版,以及丛书编辑 Obie Fernandez 所著的旗舰大作《Professional Ruby on Rails》。该系列将是一套精辟入里的学习工具型丛书,从专业的高度帮助读者最大限度从 Ruby 和 Rails 中汲取精华。
评论