Ruby 语言常以其灵活性为人所称道。正如 Dick Sites 所言,您可以 “为了编程而编程”。Ruby on Rails 扩展了核心 Ruby 语言,但正是 Ruby 本身使得这种扩展成为了可能。Ruby on Rails 使用了该语言的灵活性,这样一来,无需太多样板或额外的代码就可以轻松编写高度结构化的程序:无需额外工作,就可以获得大量标准的行为。虽然这种轻松自由的行为并不总是完美的,但毕竟您可以无需太多工作就可以获得很多好的架构。
例如,Ruby on Rails 基于模型-视图-控制器(Model-View-Controller,MVC)模式,这意味着大多数 Rails 应用程序都可以清晰地分成三个部分。模型部分包含了管理应用程序数据所需的行为。通常,在一个 Ruby on Rails 应用程序中,模型和数据库表之间的关系是 1:1;Ruby on Rails 默认使用的对象关系映射(ORM)ActiveRecord 负责管理模型与数据库的交互,这意味着 Ruby on Rails 程序通常都具有(如果有的话)很少量的 SQL 代码。第二个部分是视图,它包含创建发送至用户的输出所需要的代码;它通常由 HTML、JavaScript 等组成。最后的一个部分是控制器,它将来自用户的输入转变为正确的模型,然后使用适当的视图呈现响应。
Rails 的倡导者通常都乐于将其易用性方面的提高归功于 MVC 范型 — 以及 Ruby 和 Rails 二者的其他一些特性,并称很少有程序员能够在较短的时间内创建更多的功能。当然,这意味着投入到软件开发的成本将能够产生更多的商业价值,因此 Ruby on Rails 开发愈发流行。
不过,最初的开发成本并不是事情的全部,还有其他的后续成本需要考虑,比如应用程序运行的维护成本和硬件成本。Ruby on Rails 开发人员通常会使用测试和其他的敏捷开发技术来降低维护成本,但是这样一来,很容易忽视具有大量数据的 Rails 应用程序的有效运行。虽然 Rails 能够简化对数据库的访问,但它并不总是能够如此有效。
Rails 应用程序为何运行缓慢?
Rails 应用程序之所以运行缓慢,其中有几个很基本的原因。第一个原因很简单:Rails 总是会做一些假设为您加速开发。通常,这种假设是正确而有帮助的。不过,它们并不总能有益于性能,并且还会导致资源使用的效率低下 — 尤其是数据库资源。
例如,使用等同于 SELECT * 的一个 SQL 语句,ActiveRecord 会默认选择查询上的所有字段。在具有为数众多的列的情况下 — 尤其是当有些字段是巨大的 VARCHAR 或 BLOB 字段时 — 就内存使用和性能而言这种行为很有问题。
另一个显著的挑战是 N+1 问题,本文将对此进行详细的探讨。这会导致很多小查询的执行,而不是一个单一的大查询。例如,ActiveRecord 无从知道一组父记录中的哪一个会请求一个子记录,所以它会为每个父记录生成一个子记录查询。由于每查询的负荷,这种行为将导致明显的性能问题。
其他的挑战则更多地与 Ruby on Rails 开发人员的开发习惯和态度相关。由于 ActiveRecord 能够让如此众多的任务变得轻而易举,Rails 开发人员常常会形成 “SQL 不怎样” 的一种态度,即便在更适合使用 SQL 的时候,也会避免 SQL。创建和处理数量巨大的 ActiveRecord 对象的速度会非常缓慢,所以在有些情况下,直接编写一个无需实例化任何对象的 SQL 查询会更快些。
由于 Ruby on Rails 常被用来降低开发团队的规模,又由于 Ruby on Rails 开发人员通常都会执行部署和维护生产中的应用程序所需的一些系统管理任务,因此若对应用程序的环境知之甚少,就很可能出问题。操作系统和数据库有可能未被正确设置。比如,虽然并不最优,MySQL my.cnf 设置常常在 Ruby on Rails 部署内保留它们的默认设置。此外,可能还会缺少足够的监控和基准测试工具来提供性能的长期状况。当然,这并不是在责怪 Ruby on Rails 开发人员;这是非专业化导致的后果;在有些情况下,Rails 开发人员有可能是这两个领域的专家。
最后一个问题是 Ruby on Rails 鼓励开发人员在本地环境中进行开发。这么做有几个好处 — 比如,开发延迟的减少和分布性的提高 — 但它并不意味着您可以因为工作站规模的减少而只处理有限的数据集。他们如何开发以及代码将被部署于何处之间的差异可能会是一个大问题。即便您在一个性能良好的轻载本地服务器上处理小规模的数据已经很长一段时间,也会发现对于拥塞的服务器上的大型数据此应用程序会有很明显的性能问题。
当然,Rails 应用程序具有性能问题的原因可能有很多。查出 Rails 应用程序有何潜在性能问题的最佳方法是,利用能为您提供可重复、准确度量的诊断工具。
检测性能问题
最好的工具之一是 Rails 开发日志,它通常位于每个开发机器上的 log/development.log 文件内。它具有各种综合指标:响应请求所花费的总时间、花费在数据库内的时间所占的百分比、生成视图所花时间的百分比等。此外,还有一些工具可用来分析此日志文件,比如 development-log-analyzer。
在生产期间,通过查看 mysql_slow_log 可以找到很多有价值的信息。更为全面的介绍超出了本文的讨论范围,更多信息可以在 参考资料 部分找到。
其中一个最强大也是最为有用的工具是 query_reviewer 插件(参见 参考资料)。这个插件可显示在页面上有多少查询在执行以及页面生成需要多长时间。并且它还会自动分析 ActiveRecord 生成的 SQL 代码以便发现潜在问题。例如,它能找到不使用 MySQL 索引的查询,所以如果您忘记了索引一个重要的列并由此造成了性能问题,那么您将能很容易地找到这个列(有关 MySQL 索引的更多信息,参见 参考资料)。此插件在一个弹出的 <div>(只在开发模式下可见)中显示了所有这类信息。
最后,不要忘记使用类似 Firebug、yslow、Ping 和 tracert 这样的工具来检测性能问题是来自于网络还是资源加载问题。
接下来,让我们来看具体的一些 Rails 性能问题及其解决方案。
N+1 查询问题
N+1 查询问题是 Rails 应用程序最大的问题之一。例如,清单 1 内的代码能生成多少查询?此代码是一个简单的循环,遍历了一个假想的 post 表内的所有 post,并显示 post 的类别和它的主体。
清单 1. 未优化的 Post.all 代码
<%@posts = Post.all(@posts).each do |p|%> <h1><%=p.category.name%></h1> <p><%=p.body%></p> <%end%></div>
答案:上述代码生成了一个查询外加 @posts 内的每行一个查询。由于每查询的负荷,这可能会成为一个很大的挑战。罪魁祸首是对 p.category.name 的调用。这个调用只应用于该特定的 post 对象,而不是整个 @posts 数组。幸好,通过使用立即加载,我们可以修复这个问题。
立即加载 意味着 Rails 将自动执行所需的查询来加载任何特定子对象的对象。Rails 将使用一个 JOIN SQL 语句或一个执行多个查询的策略。不过,假设指定了将要使用的所有子对象,那么将永远不会导致 N+1 的情形,在 N+1 情形下,一个循环的每个迭代都会生成额外的一个查询。清单 2 是对 清单 1 内代码的修订,它使用了立即加载来避免 N+1 问题。
清单 2. 用立即加载优化后的 Post.all 代码
<%@posts = Post.find(:all, :include=>[:category] @posts.each do |p|%> <h1><%=p.category.name%></h1> <p><%=p.body%></p> <%end%></div>
该代码最多生成两个查询,而不管在此 posts 表内有多少行。
当然,并不是所有情况都如此简单。处理复杂的 N+1 查询情况需要更多的工作。那么做这么多努力值得么?让我们来做一些快速的测试。
测试 N+1
使用清单 3 内的脚本,可以发现查询可以达到 — 多慢 — 或多快。 清单 3 展示了如何在一个独立脚本中使用 ActiveRecord 来建立一个数据库连接、定义表并加载数据。然后,可以使用 Ruby 的内置基准测试库来查看哪种方式更快,快多少。
清单 3. 立即加载基准测试脚本
require 'rubygems'
require 'faker'
require 'active_record'
require 'benchmark'
# This call creates a connection to our database.
ActiveRecord::Base.establish_connection(
:adapter => "mysql",
:host => "127.0.0.1",
:username => "root", # Note that while this is the default setting for MySQL,
:password => "", # a properly secured system will have a different MySQL
# username and password, and if so, you'll need to
# change these settings.
:database => "test")
# First, set up our database...
class Category < ActiveRecord::Base
end
unless Category.table_exists?
ActiveRecord::Schema.define do
create_table :categories do |t|
t.column :name, :string
end
end
end
Category.create(:name=>'Sara Campbell\'s Stuff')
Category.create(:name=>'Jake Moran\'s Possessions')
Category.create(:name=>'Josh\'s Items')
number_of_categories = Category.count
class Item < ActiveRecord::Base
belongs_to :category
end
# If the table doesn't exist, we'll create it.
unless Item.table_exists?
ActiveRecord::Schema.define do
create_table :items do |t|
t.column :name, :string
t.column :category_id, :integer
end
end
end
puts "Loading data..."
item_

