1 minute read

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 頁面發生。