加速 Ruby on Rails:消除 N+1 查询问题

  Ruby 语言常以其灵活性为人所称道正如 Dick Sites 所言您可以 “为了编程而编程”Ruby _disibledevent=>JavaScript 等组成最后个部分是控制器它将来自用户输入转变为正确模型然后使用适当视图呈现响应

  Rails 倡导者通常都乐于将其易用性方面提高归功于 MVC 范型 — 以及 Ruby 和 Rails 2者其他些特性并称很少有员能够在较短时间内创建更多功能当然这意味着投入到软件Software开发成本将能够产生更多商业价值因此 Ruby _disibledevent=>操作系统和数据库有可能未被正确设置比如虽然并不最优MySQL my.cnf 设置常常在 Ruby _disibledevent=>服务器上处理小规模数据已经很长段时间也会发现对于拥塞服务器上大型数据此应用会有很明显性能问题

  当然Rails 应用具有性能问题原因可能有很多查出 Rails 应用有何潜在性能问题最佳思路方法是利用能为您提供可重复、准确度量诊断工具

  检测性能问题

  最好工具的是 Rails 开发日志它通常位于每个开发机器上 log/development.log 文件内它具有各种综合指标:响应请求所花费总时间、花费在数据库内时间所占百分比、生成视图所花时间百分比等此外还有些工具可用来分析此日志文件比如 development-log-analyzer

  在生产期间通过查看 mysql_slow_log 可以找到很多有价值信息

  其中个最强大也是最为有用工具是 query_reviewer 插件这个插件可显示在页面上有多少查询在执行以及页面生成需要多长时间并且它还会自动分析 ActiveRecord 生成 SQL 代码以便发现潜在问题例如它能找到不使用 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%> 


  答案:上述代码生成了个查询外加 @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, :=>[: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 ting for MySQL, 
 :password => "",   # a properly secured system will have a dferent MySQL 
              # username and password, and  so, you'll need to 
              # change these tings. 
 :database => "test") 
 
# First,  up our database... 
 Category < ActiveRecord::Base 
end 
 
unless Category.table_exists? 
 ActiveRecord::Schema. do 
 create_table :categories do |t| 
  t.column :name, : 
 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 
 
 Item < ActiveRecord::Base 
 belongs_to :category 
end 
 
# If the table doesn't exist, we'll create it. 
 
unless Item.table_exists? 
 ActiveRecord::Schema. do 
 create_table :items do |t| 
  t.column :name, : 
  t.column :category_id, :eger 
 end 
 end 
end 
 
puts "Loading data..." 
 
item_count = Item.count 
item_table_size = 10000 
 
 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 :" do 
  @items=Item.find(:all, :=>:category, :limit=>size) 
  @items.each do |i| 
  i.category 
  end 
 end 
 end 
end 


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

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

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

-- create_table(:categories) 
 -> 0.1327s 
-- create_table(:items) 
 -> 0.1215s 
Loading data... 
Running tests... 
  user   system   total    real 
size:100, with n+1 problem 0.030000  0.000000  0.030000 ( 0.045996) 
size:100, with : 0.010000  0.000000  0.010000 ( 0.009164) 
size:1000, with n+1 problem 0.260000  0.040000  0.300000 ( 0.346721) 
size:1000, with : 0.060000  0.010000  0.070000 ( 0.076739) 
size:10000, with n+1 problem 3.110000  0.380000  3.490000 ( 3.935518) 
size:10000, with : 0.470000  0.080000  0.550000 ( 0.573861) 


  在所有情况下使用 : 测试总是更为迅速 — 分别快 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 问题但修复语法却没有那么明显这里所使用是关系关系那么如何才能立即加载嵌套关系呢?

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

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

@posts = Post.find(:all, :=>{ :category=>, 
                    :author=>{ :image=>}} ) 
@posts.each do |p|   
 <h1><%=p.category.name%></h1> 
 <%=image_tag p.author.image.public_filename %> 
 <p><%=p.body%> 
 <%end%> 


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

  间接立即加载

  并非所有 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, :=>{:posts=>[:user]}) 
 ...snip... 


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

  种方式是使用缓存Cache技术Rails V2.1 内置了简单缓存Cache访问使用 Rails.cache.read、 Rails.cache.write 及相关思路方法可以轻松创建自己简单缓存Cache机制并且后端可以是个简单内存后端、个基于文件后端或个分布式缓存Cache服务器但您无需创建自己缓存Cache解决方案;您可以使用个预置 Rails 插件比如 Nick Kallen cache money 插件这个插件提供了 write-through 缓存Cache并以 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 


  相反Rails 提供了系列分组和聚合可以像清单 11 所示那样使用它们

清单 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 
  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 也会遭遇性能问题所幸监控和修复这些问题技术相对简单且易学而且即便是复杂问题只要有耐心并对性能问题源头有所了解也是可以解决



Tags: 

延伸阅读

最新评论

发表评论