N+1查詢:Rails開發者的隱藏陷阱 (1/3)
Rails 開發者很容易沉浸在 ActiveRecord 帶來的開發高效率上,而忽略了 ActiveRecord 很容易不小心就產生了效能差的 SQL 查詢,所以寫了這系列文章做紀錄。
Fix N+1 queries
問題: 已經加了 includes ,為什麼還會產生 N+1?
說明: 在 latest_comment
方法中,.last
並不會導致 ActiveRecord::Relation 返回單一記錄 comments 進行有效的序列查詢。
class Posts < ApplicationRecord
has_many :comments
def latest_comment
comments.order(:created_at).last
end
end
Post.includes(:comments).each do |post|
puts post.latest_comment
end
解決方案一
在關聯時先進行排序,Rails 將執行一個 SQL 查詢以便取得所有相關的 comments,並根據 created_at 進行排序。
這些已排序的 comments 會與它們各自的 post 關聯。
因此,當你在每個 post 上調用 post.comments.last
時,它只是從已經加載並排序好的 comments 中取得最後一條。這不需要觸發任何新的 SQL 查詢,因此避免了 N+1 問題。
class Post < ApplicationRecord
has_many :comments, -> { order(:created_at) }
def latest_comment
self.comments.last
end
end
Post.includes(:comments).each do |post|
puts post.latest_comment
end
解決方案二
不要為所有文章都載入所有的 comments,使用 has_one 關聯來取得每篇文章的 latest_comment
。透過設定的 scope,只提供每篇文章的最新留言。
class Post < ApplicationRecord
has_many :comments
has_one :latest_comment, -> { Comment.latest_comments_for_posts }, class_name: "Comment"
end
class Comment < ApplicationRecord
belongs_to :post
def self.latest_comments_for_posts
latest_comments_ids = select("max(id)").group(:post_id)
where(id: latest_comments_ids)
end
end
Post.includes(:latest_comment).all.each do |post|
puts post.latest_comment.body
end
Preload Data 再 select
問題: 已經加了 includes ,為什麼還會產生 N+1?
說明: 一開始已經 preload 所有 comments ,但在 my_comments
中使用了 .where
他一樣會多出一次的sql 查詢。
def Post < ApplicationRecord
has_many :comments
def my_comments
self.comments.where( :user_id => self.user_id )
end
end
Post.includes(:comments).each do |post|
puts post.my_comments
end
解決方案
將 active_record 用法改成使用 ruby 的語法,那麼它不會產生新的 SQL 查詢,而是在已經加載到記憶體中進行篩選。
def Post < ApplicationRecord
has_many :comments
def my_comments
self.comments.select{ |x| x.user_id == self.user_id }
end
end
inverse_of 參數和 N+1 queries 也有關係
使用 inverse_of 可以手動指定反向關聯的名稱是什麼,通常不需要使用是因為,ActiveRecord 自己就可以根據慣例推導出來。
這邊舉個可能需要反向關聯的例子:
<% Post.includes(:comments).each do |post| %>
<%= post.title %>
<% post.comments.each do |comment| %>
<%= comment.content %>
<%= comment.post.title %>
<% end %>
<% end %>
由於有 includes(:comments)
的關係,所以 post.comments.each
的確不會造成 N+1 問題,但是這一行 <%= comment.post.title %>
只有在 Rails 正確知道反向關聯的前提下,才不會造成重複跑去撈已經知道的 Post,又產生新的 SQL 查詢,當我們有搭配使用 -> { where 條件 }
、:foreign_key、:through
等參數時,Rails 就不會推導了。例如上述的範例,只要補個 :foreign_key 且不要手動給 :inverse_of
class Comment < ApplicationRecord
belongs_to :post, :foreign_key => :post_id
end
在你的 log 中你就會看到 N+1 queries 問題冒出來了,這邊的情況很容易在比較複雜的 View 頁面發生。