翻译 [译] 减慢 Rails 应用的 3 个 ActiveRecord 错用

rennyallen · 2019年03月01日 · 最后由 rennyallen 回复于 2019年03月26日 · 11174 次阅读
本帖已被管理员设置为精华贴

原文:https://www.speedshop.co/2019/01/10/three-activerecord-mistakes.html
作者:Nate Berkopec
译者注:本文采用意译,部分语句为保留原意未作翻译,推荐有条件者查看英文原文。水平有限,请多指教。


3 ActiveRecord Mistakes That Slow Down Rails Apps: Count, Where and Present

许多 Rails 开发者不了解导致 ActiveRecord 执行一条 SQL 查询的真正原因是什么。我们来看看三个常见情况:滥用 count 方法、用 where 来 查询子集,以及 present? 校验。如果你滥用这三个方法,可能会导致额外的 N+1 查询。

ActiveRecord 确实很好,但它是一个已经封装好的抽象化的东西,容易让你忽略掉真实跑在数据库里的 SQL 查询。如果你不了解 ActiveRecord 是怎么工作的,你可能会搞出一些你其实不想要的 SQL 查询。

不幸的是,ActiveRecord 的许多功能的性能消耗意味着我们不能忽略那些不必要的开销,或是仅仅把我们的 ORM 看做一个实现细节。我们需要了解到底哪些查询影响了性能表现。世上没有真正的自由,ActiveRecord 亦如此。

我发现一个典型的情况是,ActiveRecord 在执行一些并不是真正需要的 SQL,然而大多数人完全不知道这个事情。

不必要的 SQL 是造成 controller action 十分缓慢的一个主要原因,特别是当不必要的查询出现在一个 partial 里,而这个 partial 又被一个 collection 里的每个 element 遍历渲染。这在 search action 或者 index action 中时常发生。这是我在面对性能咨询时候最常见的问题之一,它几乎是我遇到过的每个应用都会有的一个问题。

消除不必要的查询的一个方法是,钻研并理解 ActiveRecord 的内部实现细节,理解某一个方法究竟是怎么实现的。 今天,就让我们来看看在 Rails 应用中造成许多不必要查询的三个方法的实现和用法,这三个方法分别是:count, where, present?

我咋知道一个查询是不必要的呢?

我有一个判断某个 SQL 查询是否必要的经验法则。理想情况下,在一个 controller 的 action 中,对于一张表应该只执行一条 SQL 查询。如果你看到某张表超过了一条 SQL 查询,通常你可以找到方法来减少到一条或两条查询。如果你发现针对某张表有许多 SQL 查询,几乎可以说你肯定能找到不必要的查询。(杠精就不要来了,这是一条 guideline,不是 rule, 我知道有的情况下一张表超过一条查询是没问题的)

如果你安装了 NewRelic 的话,某张表的 SQL 查询数量很容易查阅。

另外一条法则是,大多数查询的执行都应该是在一个 action 的 response 的前半段 (during the first half of a controller action’s response),基本上不会在 partial 里面。在 partial 里面执行的查询通常都不是有意的,而且经常都是 N+1。如果你注意在开发环境下看日志的话,这些在一个 controller 执行的时候很容易发现。比如,如果你看到长这样的:

User Load (0.6ms)  SELECT  "users".* FROM "users" WHERE "users"."id" = $1 LIMIT 1  [["id", 2]]
Rendered posts/_post.html.erb (23.2ms)
User Load (0.3ms)  SELECT  "users".* FROM "users" WHERE "users"."id" = $1 LIMIT 1  [["id", 3]]
Rendered posts/_post.html.erb (15.1ms)

... 看到了吧,这个局部视图里面 N+1 了。

我旁边桌子上有个洗眼装置,专治 N+1

通常,如果一个查询在某个 controller action 的中途执行(比如一个 partial 中某个较深的地方),这意味着你没有 preload 你需要的数据。

所以,让我们专门看看 count, wherepresent? 三个方法,为什么他们会造成不必要的 SQL 查询

.count executes a COUNT every time

几乎我工作过的每家公司都有这个问题。似乎并不是很多人知道,在一个 ActiveRecord relation 上调用 count 方法总是会执行一次 SQL 查询,每次都是。这在大多数场景下并不合适,通常来讲,只有在你希望立即执行一次 SQL COUNT 查询的时候才需要用 count

最常见的导致不必要的 count 查询的原因是,你对一个之后会在 view 中用到的 association 调用 count

# _messages.html.erb
# Assume @messages = user.messages.unread, or something like that

<h2>Unread Messages: <%= @messages.count %></h2>

<% @messages.each do |message| %>
blah blah blah
<% end %>

这样就执行了两次查询,a COUNT and a SELECT. The COUNT is executed by @messages.count, and @messages.each executes a SELECT to load all the messages. 改变代码在 partial 中的顺序,把 count 改成 size 来完全消除 COUNT 查询,SELECT 还是保留:

<% @messages.each do |message| %>
blah blah blah
<% end %>

<h2>Unread Messages: <%= @messages.size %></h2>

为什么呢?我们来看看ActiveRecord::Relation 中 size 的实现就行了

# File activerecord/lib/active_record/relation.rb, line 210
def size
  loaded? ? @records.length : count(:all)
end

如果 relation 已经加载(也就是说,relation 对应的查询已经执行并且我们已经存储好了结果),我们可以在已经加载好的记录数组上调用 length 方法。这只是 ruby array 的一个简单方法。如果 ActiveRecord::Relation 还没有加载好,就会触发一次 COUNT 查询。

另一方面,这是 count 的实现 (in ActiveRecord::Calculations):

def count(column_name = nil)
  if block_given?
    # ...
    return super()
  end

  calculate(:count, column_name)
end

当然,calculate的实现并不会缓存任何东西,所以每次调用都会执行一次 SQL

在我们上面的实例中,把 count 改成 size 仍然会触发 COUNT 查询。数据没有被加载吗?当 size被调用的时候,ActiveRecord 仍然会尝试一次 COUNT。我们把方法移到数据加载之后就消除了这条查询。现在,把 header 移动到 partial 的尾部在逻辑上并不太 make sense。我们可以用 load 方法。

<h2>Unread Messages: <%= @messages.load.size %></h2>

<% @messages.each do |message| %>
blah blah blah
<% end %>

load 会立即加载 @messages 的所有记录,而不是懒加载。 它会返回 ActiveRecord::Relation,而不是 records。所以,当 size 方法被调用,记录都是 loaded? 并且已经避免了查询。🐂 🍺

那如果,我们用 messages.load.count 呢?仍然会触发一次 COUNT 查询!

什么时候 count 才不会触发查询呢?只有当这个结果已经被 ActiveRecord::QueryCache 缓存好了的时候。比如这样相同的命令执行两次的时候:


<h2>Unread Messages: <%= @messages.count %></h2>

... lots of other view code, then later:

<h2>Unread Messages: <%= @messages.count %></h2>

我认为大多数 Rails 开发者在他们用 count 的大多数地方,都应该用 size 我不知道为什么好像大家都喜欢用 count 而不是 sizesize 会在合适的时候使用 count,在记录已经加载的时候不做多余的 COUNT。我觉得这是因为当你在操作 ActiveRecord relation 的时候,你还处在 "SQL" 的思维里。你想着:“这是 SQL 语句,我应该写 count 来执行 COUNT 操作!”

所以,你到底什么时候需要用 count 呢?Use it when you won’t actually ever be loading the full association that you’re counting. 比如,来看看 Rubygems.org 上的一个页面,这里展示了一个 gem:

在 "verisions" 列表中,这里调用了 count 来获取这个 gem 的 release version 总数。

这是源码

<% if show_all_versions_link?(@rubygem) %>
  <%= link_to t('.show_all_versions', :count => @rubygem.versions.count), rubygem_versions_url(@rubygem), :class => "gem__see-all-versions t-link--gray t-link--has-arrow" %>
<% end %>

关键是,这个页面上 永远不会 加载这个 gem 的所有版本。这个版本列表里只会加载最近的五个版本。

所以,count 方法在这里就完美适用,虽然 size 在逻辑上是相等的(也会执行一次 COUNT,因为 @verisions.loaded? 为 false)

我的建议是,在你的 app/views 目录下搜索 count 调用,确保他们真的 make sense. 如果你不是 100% 确定你真的需要在那个地方立即做 SQL COUNT 查询,那就把它改成 size。最坏的情况下,如果关联关系没有加载,ActiveRecord 仍然会执行一次 COUNT。如果你之后在 view 中会用到这个关联关系,把它改成 load.size

.where means filtering is done by the database

来看看这代码有什么问题:(_post.html.erb)

<% @posts.each do |post| %>
  <%= post.content %>
  <%= render partial: :comment, collection: post.active_comments %>
<% end %>

and in post.rb:

class Post < ActiveRecord::Base
  def active_comments
    comments.where(soft_deleted: false)
  end
end

如果你说,“this causes a SQL query to be executed on every rendering of the post partial”,回答正确!where 总是会发生查询。我没把 controller 层的代码写出来,因为这没有影响。你不能用诸如 includes 的预加载方法来 stop 这个查询,where 总是会执行查询的!

当你在关联关系上调 scope 时也是一样的。想象我们有一个这样的 comment model:

class Comment < ActiveRecord::Base
  belongs_to :post

  scope :active, -> { where(soft_deleted: false) }
end

允许我总结两条规则:

  1. 当你在 render collections 的时候,不要在关联关系上调用 scope;
  2. 不要在一个 ActiveRecord::Base 类里面的实例方法里写查询方法,比如 where

在关联关系上调用 scope 意味着我们不能预加载结果。在上面的例子中,我们可以预加载一个 post 中的 comments,但是我们不能预加载一个 post 的 active comments, 所以我们对于 collection 中的 每个 element 都必须到数据库中去执行新的查询。

如果你只做一次这个操作还没什么问题,但是如果对于一个 collection 的 每个 element 都这样就不行了(比如上面的,每一篇 post) 在这样的情况下可以用 scope - 比如,一个 PostsController#show action 中,只显示一篇 post 以及和它相关联的 comments. 但如果是 collections, scopes on associations 总会造成 N + 1.

我找到了解决这个问题的最佳方法是建一个新的 associationJustin Weiss 的 "Practicing Rails" 中的 这篇文章 教会了我预加载 rails scopes 相关的东西。Idea 就是去建一个可以 preload 的新的 association:

class Post
  has_many :comments
  has_many :active_comments, -> { active }, class_name: "Comment"
end

class Comment
  belongs_to :post
  scope :active, -> { where(soft_deleted: false) }
end

class PostsController
  def index
    @posts = Post.includes(:active_comments)
  end
end

view 层没有变化,但是现在只用执行两条 SQL 了,一条在 Posts 表上,一条在 Comments 表上。Nice!

<% @posts.each do |post| %>
  <%= post.content %>
  <%= render partial: :comment, collection: post.active_comments %>
<% end %>

第二条经验是,不要把查询方法(比如 where) 放到 ActiveRecord::Base 类里的某个实例方法中。这个可能不太明显,来看个示例:

class Post < ActiveRecord::Base
  belongs_to :post

  def latest_comment
    comments.order('published_at desc').first
  end

如果 view 层长这样,会发生什么呢?

<% @posts.each do |post| %>
  <%= post.content %>
  <%= render post.latest_comment %>
<% end %>

不管你预加载了什么,对于每一篇 post 都有 SQL 查询。以我的经验,每一个 ActiveRecord::Base 类里的实例方法,最终都会在一个 collection 中被调用。一个方法,可能最开始是路人甲写的,然后路人乙加了个新的 feature 但是并没注意,没有完全理解这个方法的实现,然后,N+1 出现了。我刚给的这个示例可以用前面说的 association 来重写,虽然仍然会有 N+1,但至少可以通过正确的预加载轻松解决。

那么哪些 ActiveRecord 方法是我们在 model 的实例方法中应该避免的呢?一般来说,基本包括所有的 QueryMethods, FinderMethodsCalculations. 这些方法通常都会执行 SQL 查询,并且预加载还不一定治得了。

any?, exists? and present?

Rails 开发者一直被一个烦恼困扰着 -- 那就是他们要给各个变量加特定的断言方法。present? 已经散播到了 Rails 项目的各个角落,比 13 世纪欧洲的瘟疫还快。绝大部分时候,断言方法没有增加什么,很冗余,其实我们想要的只是一个 true or false 的判断,只用写个变量名就能搞定的那种。

这是来自 CodeTriage 的一个 示例,这是我朋友Richard Schneeman 写的一个免费的 Rails 开源项目:

class DocComment < ActiveRecord::Base
  belongs_to :doc_method, counter_cache: true

  # ... things removed for clarity...

  def doc_method?
    doc_method_id.present?
  end
end

present? 在这里做什么?第一,把 doc_method_idnil 或者一个 Integer 转成 true 或者 false. 对于断言方法必须要返回 true 或者 false 这两个值中的一个,还是说只要返回一个逻辑上为真或假的值就行,有些人有自己鲜明的观点,我没有。但是加 present? 同时也带了其他东西进来,让我们来看看实现

class Object
  def present?
    !blank?
  end
end

blank? 是一个比“这个对象是真还是假”还要复杂的问题。空数组和哈希是真,但是是 blank, 空字符串也是 blank? 的。然而,在上面这个示例中,doc_method_id 的值只会是 nil 或者是 Integer,这意味着 present? 在逻辑上和 !! 是相等的:

def doc_method?
  !!doc_method_id
  # same as doc_method_id.present?
end

这种场景下 present? 是错误的工具。如果你不关心你要断言的值是不是空的(比如 [], {}),那就使用其他更简单更快速的语言特性。我甚至有时候还看到有人在一个布尔值上这样搞,这意味着你只是在增加冗余度,而且会让我感觉,这个地方是不是有什么我没想到的奇怪的 edge case?

这些是我的观点,我理解你可能会不同意。present? 在处理可能为空 ("") 的字符串时比较适用。

大家遇到问题的地方是调用断言方法的时候,比如 present?, on ActiveRecord::Relation objects. 我们假设现在你想知道一个 ActiveRecord::Relation 是否有数据,从英语语言角度上讲,你可以用同义词 any?/present?/exists? 或者是他们的反义词 none?/blank?/empty?. 你选哪个当然都没问题,对吧?挑一个你大声读出来最顺口的?No.

你觉得下面的代码会执行怎样的 SQL?假设 @comments is an ActiveRecord::Relation.

- if @comments.any?
  h2 Comments on this Post
  - @comments.each do |comment|

答案是两条 SQL。首先是由 @comments.any? 触发的存在性检查, (SELECT 1 AS one FROM ... LIMIT 1), 然后 @comments.each 这一行会去 load 整个 relation (SELECT "comments".* FROM "comments" WHERE ...).

What about this?

- unless @comments.load.empty?
  h2 Comments on this Post
  - @comments.each do |comment|

这样只会执行一条 SQL - @comments.load 会立即 load the entire relation with SELECT "comments".* FROM "comments" WHERE ....

And this one?

- if @comments.exists?
  This post has
  = @comments.size
  comments
- if @comments.exists?
  h2 Comments on this Post
  - @comments.each do |comment|

四次!exists? 没有记忆功能 (doesn't memoize itself) 并且 不会 load the relation. 这里 exists? 会触发 SELECT 1 ..., .size 会触发一次 COUNT,因为 relation 还没有被加载。然后下一个 exists? 会触发另一次 SELECT 1 ...,最后,@comments 会加载整个 relation! 耶!快乐吗? 你可以减少到 1 次查询,像这样:

- if @comments.load.any?
  This post has
  = @comments.size
  comments
- if @comments.any?
  h2 Comments on this Post
  - @comments.each do |comment|

取决于你的 Rails 版本,4.2 or 5.0 or 5.1+,行为会变。

Here’s how it works in Rails 5.1+:

Here’s how it works in Rails 5.0:

Here’s how it works in Rails 4.2:

any?, empty?none? 让我想起 size 的实现 - 如果数据已经加载 (loaded?),简单地在已有数组上调一下方法就好;如果数据还没加载,就执行 SQL 查询。exists? 和其他的 ActiveRecord::Calculations 一样,没有内置 caching 或者 memoization。这意味着在某些场景下, exists? 这个人们喜欢用的方法,实际上比 present? 要糟糕很多!

这六个断言方法,在英语里都是问的同一个意思,却有着完全不同的实现和含义,并且结果会因你使用的 Rails 版本而异。所以,我把上面的东西提炼成一些具体的建议:

  • 如果你调了 present? 或者 blank? 之后并不会完全用到这个 ActiveRecord::Relation, 就不应该用 present?blank?. 比如,@my_relation.present?; @my_relation.first(3).each.

  • any?, none? and empty? should probably be replaced with present? or blank? unless you will only take a section of the ActiveRecord::Relation using first or last. They will generate an extra existence SQL check if you’re just going to use the entire relation if it exists. In essence, change @users.any?; @users.each... to @users.present?; @users.each... or @users.load.any?; @users.each..., but @users.any?; @users.first(3).each is fine.

  • exists? is a lot like count - it is never memoized, and always executes a SQL query. Most people probably do not actually want this behavior, and would be better off using present? or blank?

Conclusion

随着你的应用的大小和复杂度不断增加,不必要的 SQL 会给你的应用表现拖后腿。每一次 SQL 查询都是和数据库的交互 (round-trip back to the database),通常至少需要 1ms, 对于复杂的 WHERE 语句会更多。虽然一次额外的 exists? 检查没什么大问题,但如果这突然发生在一个表的每一行,或者一个 collection 里的 partial 里,你就摊上事了!

ActiveRecord 是一个很牛逼的抽象,但是数据库访问毕竟不是"无门槛的",我们需要清楚 ActiveRecord 内部是怎么工作的才能避免不必要的数据库访问。

App Checklist

  • Look for uses of present?, none?, any?, blank? and empty? on objects which may be ActiveRecord::Relations. Are you just going to load the entire array later if the relation is present? If so, add load to the call (e.g. @my_relation.load.any?)

  • 小心地使用 exists? - 它总会执行 SQL 查询。只在合适的场景下使用它 - 其他情况就使用 present? 或者其他调用 empty? 的方法

  • Be extremely careful using where in instance methods on ActiveRecord objects - they break preloading and often cause N+1s when used in rendering collections.

  • count always executes a SQL query - audit its use in your codebase, and determine if a size check would be more appropriate.

代码优化就是点点滴滴做起👍

huacnlee 将本帖设为了精华贴。 03月01日 22:31

开发者 都不看 Sql log?

感谢楼主分享~学习啦

注重细节

不要把查询方法(比如 where) 放到 ActiveRecord::Base 类里的某个实例方法中。这个可能不太明显,来看个示例:

class Post < ActiveRecord::Base belongs_to :post

def latest_comment comments.order('published_at desc').first end


这个要怎么改,才能避免 N+1

@xiaoPP

class Post < ActiveRecord::Base
  has_one :latest_comment, -> { order(published_at: :desc) }, class_name: "Comment"
end

这样行吧

很赞,可以从目前项目入手,慢慢查找需要优化的地方。

@wootaw 这种也不是很完美 , 会生成这样的 sql

SELECT `comments`.* FROM `comments` WHERE `comments`.`post_id` IN (1,2,3,4,5 ) ORDER BY `comments
`.`created_at` DESC

关联所有的 comments 都被查询出来了,如果关联的 comments 数量很大,那速度应该还不如 N+1 吧

fangxing204 回复

我觉得关键是在于预加载,这样写虽然也是会查询所有的 comments,但是用了 association 的话可以 Post.includes(:latest_comments),通过把 scope 转换成 association 然后预加载,这样最后的 SQL 数量是会有所减少的。可以参考下:https://www.justinweiss.com/articles/how-to-preload-rails-scopes/

来 ruby 社区的第一天就碰到认识的人了。😀 😀 😀

ShareManT 回复

幸会幸会😀

需要 登录 后方可回复, 如果你还没有账号请 注册新账号