25
7

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【Rails】N+1問題を解決するメソッドについて

Posted at

はじめに

開発ではじめてActiveModelSerializerを使った時に、あるエラーがきっかけでincludesメソッドの理解が深まりました。
includesをはじめとした様々なメソッドについてまとめました。

前提知識

ActiveModelSerializer

RailsでデータをJSON形式に変換するための機能です。
(ActiveModelSerializerに関しては、別途投稿します)

includesメソッド

関連するテーブルをまとめて取得します。

モデル名.includes(:関連名)

発生したエラー

きっかけは、以下のようなエラーが発生したことです。

Bullet::Notification::UnoptimizedQueryError: user: root
GET /api/models
USE eager loading detected Model => [:関連名]  
Add to your query: .includes([:関連名])

エラーの意味

  • gem「Bullet」が、非効率なクエリを検出したというエラーです
  • アソシエーションで定義した[:関連名]に対して、Eager Loadingしています

原因

主な原因は、N+1問題です。
N+1問題とは、あるレコードのクエリが1回発行されたら、関連するレコードのクエリが追加でN回発行される状況です。

解決方法

Add to your query: .includes([:関連名])

クエリに.includes([:関連名])を追加してください」と書かれているので、includesメソッドを使用し、関連するテーブルのレコードを取得します。

具体例

ブログ記事とコメント、お気に入りの関係を例に考えてみます。

アソシエーション

Articleモデル

app/models/article.rb
class Article < ApplicationRecord
  has_many :comments
end

Commentモデル

app/models/comment.rb
class Comment < ApplicationRecord
  belongs_to :article
  has_many :likes
end

Likeモデル

app/models/like.rb
class Like < ApplicationRecord
  belongs_to :comment
end

1つの記事には複数のコメントがつきます。
1つのコメントには複数のいいね(お気に入り)がつきます。

includesの実装例

includesを使用した場合

posts = Post.includes(:comments, :likes)
SELECT "posts".* FROM "posts" LEFT OUTER JOIN "comments" ON
"comments"."post_id" = "posts"."id" LEFT OUTER JOIN "likes" ON
"likes"."comment_id" = "comments"."id"

Postモデルのレコードを取得する際に、関連するcommentsとlikesも一度に取得します。

includesのデフォルトの動作

基本的には後述のpreloadと同様の動作をし、関連レコードを個別に取得します。

eager_loadを行う条件

以下の条件を満たす場合は、後述のeager_loadと同様の動作(JOIN)を実行します。

  1. includesしたテーブルでwhere句などを使用して条件を絞った場合
  2. includesしたassociationに対して、joinsかreferencesを呼んでいる場合
  3. 任意のassociationに対してeager_loadメソッドを呼んでいる場合

includesを使用しない場合

posts = Post.all
posts.each do |post|
  comments = post.comments
  likes = post.likes
end
# 全てのPostを取得
SELECT * FROM posts;

# 1つ目のPostのコメントを取得
SELECT "comments".* FROM "comments" WHERE "comments"."post_id" = 1

# 1つ目のPostのいいねを取得
SELECT "likes".* FROM "likes" WHERE "likes"."comment_id" = 1

# 2つ目のPostのコメントを取得
SELECT "comments".* FROM "comments" WHERE "comments"."post_id" = 2

このように各Postに対して、CommentとLikeを個別に取得するためのクエリが発行されます。N+1問題が発生するため、パフォーマンスが低下しやすいです。

その他のメソッド

eager_load

LEFTJOINを行い、モデルと関連付けられているモデルの両方を取得する1つのクエリを開始します。
ちなみにincludesとは、eager_loadのエイリアス(別名)です。

posts = Post.eager_load(:comments, :likes)
SELECT posts.*, comments.*, likes.* FROM posts
LEFT OUTER JOIN comments ON comments.post_id = posts.id
LEFT OUTER JOIN likes ON likes.post_id = posts.id

includesとの違い

includesとの大きな違いは、eager_loadでは常にJOINを行うということです。すべての関連付けに対して、LEFT OUTER JOINを明示的に強制します。

preload

取得したすべてのPostに対して、関連付けされるコメントやいいね毎に、レコードがまとめて取得されます。取得したレコードはメモリ上で結合されます。

posts = Post.preload(:comments, :likes)
SELECT * FROM posts;
SELECT * FROM comments WHERE post_id IN (1, 2, 3, ...);
SELECT * FROM likes WHERE post_id IN (1, 2, 3, ...);

preloadはJOINを使用せずに、個別のSELECT文で関連レコードを取得します。

それぞれメソッドの動作の違いと使い分け

動作の違い

メソッド名 特徴
includes 条件によってはJOINを行い、一度に関連レコードを取得する
eager_load 常にJOINを行い、一度に関連レコードを取得。includesのエイリアス
preload JOINせずに関連レコードを個別に取得する

使い分け

要件によっては、使い分ける必要がありそうです。

比較項目 includes/eager_load preload
JOINの使用 使用する 使用しない
パフォーマンス 一般的には高速 複雑な関連付けでは高速な場合もある
メモリ消費 preloadより多い可能性あり includes/eager_loadよりは少ない
N+1問題 発生する可能性がある 発生する可能性が低い
柔軟性 低い 高い

まとめ

  • includes: JOINを使用して関連レコードを一度に取得します。条件によってeager_loadと同様の動作をします
  • eager_load: JOINを使用して、関連レコードを一度に取得します。シンプルで高速な場合が多いですが、データ量が多い場合や複雑な関連付けの場合、パフォーマンスが低下する可能性があります
  • preload: 関連レコードを個別に取得し、メモリ上で結合します。JOINを使用しないため、N+1問題を回避しやすく、メモリ使用量も抑えられます

参考記事

25
7
2

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
25
7

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?