はじめに
開発ではじめて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モデル
class Article < ApplicationRecord
has_many :comments
end
Commentモデル
class Comment < ApplicationRecord
belongs_to :article
has_many :likes
end
Likeモデル
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)を実行します。
- includesしたテーブルでwhere句などを使用して条件を絞った場合
- includesしたassociationに対して、joinsかreferencesを呼んでいる場合
- 任意の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問題を回避しやすく、メモリ使用量も抑えられます