Ruby on Rails性能问题解决之道

日期: 2010-09-14 作者:David Berube 来源:TechTarget中国 英文

  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索引的更多信息)。此插件在一个弹出的(只在开发模式下可见)中显示了所有这类信息。

  最后,不要忘记使用类似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%>

  答案:上述代码生成了一个查询外加@posts内的每行一个查询。由于每查询的负荷,这可能会成为一个很大的挑战。罪魁祸首是对p.category.name的调用。这个调用只应用于该特定的post对象,而不是整个@posts数组。幸好,通过使用立即加载,我们可以修复这个问题。

  立即加载 意味着Rail将自动执行所需的查询来加载任何特定子对象的对象。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%>

  该代码最多生成两个查询,而不管在此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_count = Item.count
item_table_size = 10000
if item_count < item_table_size
    (item_table_size – item_count).times do
        Item.create!(:name=>Faker.name, 
                 :category_id=>(1+rand(number_of_categories.to_i)))
    end
end
puts “Running tests…”
Benchmark.bm do |x| 
    [100,1000,10000].each do |size|    
        x.report “size:#{size}, with n+1 problem” do 
            @items=Item.find(:all, :limit=>size)
            @items.each do |i| 
                i.category
            end    
        end    
        x.report “size:#{size}, with :include” do 
            @items=Item.find(:all, :include=>:category, :limit=>size)
            @items.each do |i| 
                i.category
            end    
        end    
    end    
end

  这个脚本使用 :include子句测试在有和没有立即加载的情况下对100、1,000和10,000个对象进行循环操作的速度如何。为了运行此脚本,您可能需要用适合于您的本地环境的参数替换此脚本顶部的这些数据库连接参数。此外,需要创建一个名为test的MySQL数据库。最后,您还需要ActiveRecord和faker这两个gem,二者可通过运行gem install activerecord faker获得。

  在我的机器上运行此脚本生成的结果如清单 4 所示。

  清单 4. 立即加载的基准测试脚本输出

  在所有情况下,使用 :include 的测试总是更为迅速 — 分别快5.02、4.52和6.86倍。当然,具体的输出取决于您的特定情况,但立即加载可明显导致显著的性能改善。

  嵌套的立即加载

  如果您想要引用一个嵌套的关系 — 关系的关系,又该如何呢? 清单 5 展示了这样一个常见的情形:循环遍历所有的post并显示作者的图像,其中Author与Image是belongs_to的关系。

  清单 5. 嵌套的立即加载用例

以下是引用片段:
@posts = Post.all    
@posts.each do |p|  
    <h1><%=p.category.name%></h1>
    <%=image_tag p.author.image.public_filename %> 
    <p><%=p.body%> 
<%end%> 

  此代码与之前一样亦遭遇了相同的N+1问题,但修复的语法却没有那么明显,因为这里所使用的是关系的关系。那么如何才能立即加载嵌套关系呢?

  正确的答案是使用:include子句的哈希语法。清单 6 给出了使用哈希语法的一个嵌套的立即加载。

  清单 6. 嵌套的立即加载解决方案
               

以下是引用片段:
@posts = Post.find(:all, :include=>{ :category=>[],
                                       :author=>{ :image=>[]}} )
@posts.each do |p|  
    <h1><%=p.category.name%></h1>
    <%=image_tag p.author.image.public_filename %> 
    <p><%=p.body%> 
<%end%> 

  正如您所见,您可以嵌套哈希和数组实量(literal)。请注意在本例中哈希和数组之间的惟一区别是哈希可以含有嵌套的子条目,而数组则不能。否则,二者是等效的。 

  间接的立即加载

  并非所有的N+1问题都能很容易地察觉到。例如,清单 7 能生成多少查询?

  清单 7. 间接的立即加载示例用例

以下是引用片段:
<%@user = User.find(5)
    @user.posts.each do |p|%>   
      <%=render :partial=>’posts/summary’,  :locals=>:post=>p
     %> <%end%>

  当然,决定查询的数量需要对 posts/summary partial 有所了解。清单 8 中显示了这个 partial。

  清单 8. 间接立即加载 partial: posts/_summary.html.erb 

以下是引用片段:
<h1><%=post.user.name%></h1>

  不幸的是,答案是 清单 7和清单 8 在post内每行生成一个额外查询,查找用户的名字 — 即便post对象由ActiveRecord从一个已在内存中的User对象自动生成。简言之,Rails并不能关联子记录与其父记录。

  修复方法是使用自引用的立即加载。基本上,由于Rails重载由父记录生成的子记录,所以需要立即加载这些父记录,就如同父与子记录之间是完全分开的关系一样。代码如清单 9 所示。

  清单 9. 间接的立即加载解决方案               

以下是引用片段:
<%@user = User.find(5, :include=>{:posts=>[:user]})
  …snip…

  虽然有悖于直觉,但这种技术与上述技术的工作原理大致相似。但是,很容易使用这种技术进行过多的嵌套,尤其是在体系结构复杂的情况下。简单的用例还好,比如 清单9内所示的,但繁复的嵌套也会出问题。在一些情况下,过多地加载Ruby对象有可能会比处理N+1问题还要缓慢 — 尤其是当每个对象并没有被整个树遍历时。在该种情况下,N+1问题的其他解决方案可能更为适合。

  一种方式是使用缓存技术。Rails V2.1 内置了简单的缓存访问。使用 Rails.cache.read、 Rails.cache.write 及相关方法,可以轻松创建自己的简单缓存机制,并且后端可以是一个简单的内存后端、一个基于文件的后端或一个分布式缓存服务器。在 参考资料 部分可以找到有关 Rails 内置缓存支持的更多信息。但您无需创建自己的缓存解决方案;您可以使用一个预置的 Rails 插件,比如Nick Kallen的cache money 插件。这个插件提供了write-through缓存并以Twitter上使用的代码为基础。

  当然,并不是所有的Rails问题都与查询的数量有关。

  Rails分组和聚合计算

  您可能遇到的一个问题是在Ruby所做的工作本应由数据库完成。这考验了Ruby的强大程度。很难想象在没有任何重大激励的情况下人们会自愿在C中重新实现其数据库代码的各个部分,但很容易在Rails内对ActiveRecord对象组进行类似的计算。但是,Ruby总是要比数据库代码慢。所以请不要使用纯Ruby的方式执行计算,如清单 10 所示。

  清单 10. 执行分组计算的不正确方式

以下是引用片段:
all_ages = Person.find(:all).group_by(&:age).keys.uniq
  oldest_age = Person.find(:all).max

  清单 11. 执行分组计算的正确方式

以下是引用片段:
  all_ages = Person.find(:all, :group=>[:age])  
  oldest_age = Person.calcuate(:max, :age)

  ActiveRecord::Base#find有大量选项可用于模仿SQL。更多信息可以在Rails文档内找到。注意,calculate方法可适用于受数据库支持的任何有效的聚合函数,比如 :min、:sum 和 :avg。此外,calculate能够接受若干实参,比如 :conditions。查阅Rails文档以获得更详细的信息。   

  不过,并不是在SQL内能做的所有事情在Rails内也能做。如果插件不够,可以使用定制SQL。

  用Rails定制SQL

  假设有这样一个表,内含人的职业、年龄以及在过去一年中涉及到他们的事故的数量。可以使用一个定制SQL语句来检索此信息,如清单 12 所示。

  清单 12. 用 ActiveRecord 定制 SQL 的例子

以下是引用片段:
sql = “SELECT profession,
              AVG(age) as average_age,  
              AVG(accident_count) 
         FROM persons 
        GROUP 
           BY profession”
Person.find_by_sql(sql).each do |row|   
  puts “#{row.profession}, ” <<
       “avg. age: #{row.average_age}, ” <<
       “avg. accidents: #{row.average_accident_count}”
end

  这个脚本应该能生成清单 13 所示的结果。

  清单 13. 用ActiveRecord定制SQL的输出               

以下是引用片段:
Programmer, avg. age: 18.010, avg. accidents: 9
  System Administrator, avg. age: 22.720, avg. accidents: 8

  当然,这是最简单的例子。您可以自己想象一下如何能将此例中的SQL扩展成一个有些复杂性的SQL语句。您还可以使用ActiveRecord::Base.connection.execute法运行其他类型的SQL语句,比如ALTER TABLE语句,如清单 14 所示。

  清单 14. 用 ActiveRecord定制非查找型SQL

以下是引用片段:
ActiveRecord::Base.connection.execute “ALTER TABLE some_table CHANGE COLUMN…”

  大多数的模式操作,比如添加和删除列,都可以使用Rails的内置方法完成。但如果需要,也可以使用执行任意SQL代码的能力。

  结束语

  与所有的框架一样,如果不多加小心和注意,Ruby on Rails也会遭遇性能问题。所幸的是,监控和修复这些问题的技术相对简单且易学,而且即便是复杂的问题,只要有耐心并对性能问题的源头有所了解,也是可以解决的。

我们一直都在努力坚持原创.......请不要一声不吭,就悄悄拿走。

我原创,你原创,我们的内容世界才会更加精彩!

【所有原创内容版权均属TechTarget,欢迎大家转发分享。但未经授权,严禁任何媒体(平面媒体、网络媒体、自媒体等)以及微信公众号复制、转载、摘编或以其他方式进行使用。】

微信公众号

TechTarget微信公众号二维码

TechTarget

官方微博

TechTarget中国官方微博二维码

TechTarget中国

相关推荐