はじめに
Railsで開発をしていると、必ずといっていいほど出てくるのがN+1問題。
それを防ぐために使うのが includes(いわゆる Eager Loading)です。
私は普段Railsで開発することが多いですが、他言語(Laravel)を書いているときに、「Eager Loadingを使用するよりも、JOINを使った方がいい」と指摘を受けたことがありました。
理由はシンプルで、ORMに頼りすぎると、裏側でどんなSQLが発行されるのか分かりにくくなるから ということでした。
Railsの場合、リレーションをモデルに定義しておけば、ActiveRecordがよしなにSQLを組み立ててくれるため、SQLを意識せずに直感的に書けるのは大きなメリットですよね。
私は正直、深く考えずに「とりあえず includes」で済ませてきたところがあります。
ただ、JOINとEager Loading(includes / preload / eager_load)にはそれぞれ一長一短があります。
そこで今回は改めて、
- JOIN と Eager Loading とは
-
preload/eager_load/includesの違い -
joinsとleft_joinsの違い - それぞれのメソッドでどんなSQLが発行されるのか
- パフォーマンスの比較
などについて、確認していきたいと思います。
JOIN と Eager Loading の違い
よく混乱しがちな用語に「JOIN」と「Eager Loading」があります。
これらは似ているようで目的が異なります。
JOIN
SQLのJOINを使って複数テーブルを結合することを指します。
Railsでは joins や left_joins がこれにあたります。
条件検索や集計に強いですが、関連レコード自体をキャッシュするわけではありません。
Eager Loading
N+1問題を避けるために、関連レコードをあらかじめまとめてロードしておくことを指します。
Railsでは includes, preload, eager_load がこれにあたります。
条件検索というよりは、関連にアクセスするときの効率化が目的です。
preload / eager_load / includes の違い
Railsには関連読み込みの方法がいくつかあります。
| メソッド | SQLの発行回数 | JOINするか | 用途 |
|---|---|---|---|
preload |
2回 | No | 関連をまとめて読み込み、N+1を防ぐ(JOINは使わない) |
eager_load |
1回 | Yes (LEFT OUTER JOIN) | 条件やソートで関連テーブルを使う場合にJOINしてまとめて取得 |
includes |
2回 or 1回 | 状況による | 状況に応じて preload または eager_load に自動で切り替わる |
-
preloadは単純に関連を別クエリでまとめて取得する -
eager_loadは LEFT OUTER JOIN を使って1クエリで取得する -
includesは、状況に応じてpreloadかeager_loadに変換される- 条件や並び替えで関連テーブルを使う場合は
eager_loadに切り替わる
- 条件や並び替えで関連テーブルを使う場合は
簡単な例で確認してみます。
モデルの準備
まずは、Post(記事)と Comment(コメント)のモデルを用意していきます。
モデルの生成
以下のコマンドでモデルを作成します。
# Post モデルの作成
rails g model Post title:string body:text
# Comment モデルの作成
rails g model Comment body:text post:references
マイグレーションの実行
生成されたマイグレーションファイルを元に、データベースにテーブルを作成します。
rails db:migrate
モデル間の関連付け
モデル同士のリレーションを定義します。
class Post < ApplicationRecord
has_many :comments, dependent: :destroy
end
class Comment < ApplicationRecord
belongs_to :post
end
サンプルデータの作成
最後に、サンプルデータを投入します。
今回は1万件の Post と、10万件の Comment を作成します。
# Post を 10,000 件作成
puts "Creating posts..."
posts = (1..10_000).map do |i|
{
title: "Post #{i}",
body: "This is the body of Post #{i}",
created_at: Time.current,
updated_at: Time.current
}
end
# 一括挿入
Post.insert_all(posts)
puts "Posts created: #{Post.count}"
# Comment を各 Post に 10 件ずつ(合計 100,000 件)
puts "Creating comments..."
comments = []
batch_size = 1_000
Post.pluck(:id).each do |post_id|
10.times do |j|
comments << {
body: "Comment #{j} for Post #{post_id}",
post_id: post_id,
created_at: Time.current,
updated_at: Time.current
}
end
# バッチサイズに達したらまとめて insert
if comments.size >= batch_size
Comment.insert_all(comments)
comments.clear
end
end
# 残りがあれば insert
Comment.insert_all(comments) if comments.any?
puts "Comments created: #{Comment.count}"
puts "Seeding completed!"
seedファイルを実行します。
rails db:seed
N+1問題の例
posts = Post.limit(10)
posts.each do |post|
puts "#{post.title}: #{post.comments.size}"
end
発行されるSQL:
SELECT * FROM posts LIMIT 10;
SELECT * FROM comments WHERE post_id = 1;
SELECT * FROM comments WHERE post_id = 2;
...
10件の Post に対して10回のクエリが発行されます。これがN+1問題です。
preload / includes の場合
# preload
posts = Post.preload(:comments).limit(10)
# includes
posts = Post.includes(:comments).limit(10)
発行されるSQL:
SELECT * FROM posts LIMIT 10;
SELECT * FROM comments WHERE post_id IN (1,2,...,10);
- 2クエリで完了。N+1は解消
- ただし「コメント数で絞り込む」などの集計条件には弱い
includes が JOIN に切り替わる場合(eager_load)
# eager_load
Post.eager_load(:comments).where(comments: { body: "error" })
# includes
Post.includes(:comments).where(comments: { body: "error" })
発行されるSQL:
SELECT posts.id AS t0_r0, posts.title AS t0_r1, ...
FROM posts
LEFT OUTER JOIN comments
ON posts.id = comments.post_id
WHERE comments.body = 'error';
-
whereで関連テーブルのカラムを参照すると自動で LEFT OUTER JOIN(eager_load相当) に切り替わる - 1クエリで完了
- JOINが複雑になるとパフォーマンスに影響する可能性がある
- 集計条件(COUNT / HAVING)には対応できない
joins の場合
posts = Post.joins(:comments).limit(10)
発行されるSQL:
SELECT posts.* FROM posts
INNER JOIN comments ON posts.id = comments.post_id
LIMIT 10;
- 1クエリで完結
- ただし Post がコメント数に応じて重複して返る(
distinctが必要なことも) - JOIN対象が大きいと逆に遅くなる可能性がある
「コメントが10件以上ある投稿」を取得したい場合の例
# NG: includes だけでは動かない
# includes は通常2クエリを発行するので、SQLレベルでの COUNT が書けないため
Post.includes(:comments).where("COUNT(comments.id) >= 10")
# OK
Post.joins(:comments)
.group("posts.id")
.having("COUNT(comments.id) >= 10")
# さらにコメントを後で参照したい場合は preload を追加
Post.left_joins(:comments)
.group("posts.id")
.having("COUNT(comments.id) >= 10")
.preload(:comments) # N+1 対策
Railsの includes は「カラムを直接参照した場合」に限り、JOINされます。
上記のNG例のように、COUNT(comments.id) はカラム参照ではなく集計関数なので、ActiveRecordはそれを見ても「関連をJOINすべきだ」とは判断できません。
つまり、includes はここでは preload 相当(=2クエリ)で動こうとします。その結果、COUNTを含むSQLが正しく構築できずエラーになります。
(補足): joins と left_joins について
joins と left_joins の違いをわかりやすく表にまとめると以下のようになります。
| 項目 | Post.joins(:comments) |
Post.left_joins(:comments) |
|---|---|---|
| SQLの種類 | 内部結合(INNER JOIN) | 左外部結合(LEFT OUTER JOIN) |
| 結果に含まれる Post | コメントがあるものだけ | コメントがあるものもないものも含む |
| コメントがない Post の扱い | 結果に含まれない | 結果に含まれる(コメント列は NULL) |
| 使用例 | 「コメントがある投稿だけ取得したい」場合 | 「コメントがない投稿も含めて取得したい」場合 |
| パフォーマンス | 内部結合なので高速(条件による) | 外部結合の分だけやや遅くなる可能性 |
ポイントは以下のようになります。
-
joinsは結合条件に合致する行だけを返す -
left_joinsは左テーブル(ここでは posts)の全行を返し、右テーブル(comments)に対応する行がなければ NULL が入る
こちらも簡単な例を載せます。
posts テーブル
| id | title |
|---|---|
| 1 | Post A |
| 2 | Post B |
| 3 | Post C |
comments テーブル
| id | post_id | body |
|---|---|---|
| 1 | 1 | Comment 1-A |
| 2 | 1 | Comment 1-B |
| 3 | 3 | Comment 3-A |
1. Post.joins(:comments) の場合
SELECT posts.*, comments.*
FROM posts
INNER JOIN comments ON comments.post_id = posts.id;
| post_id | title | comment_id | body |
|---|---|---|---|
| 1 | Post A | 1 | Comment 1-A |
| 1 | Post A | 2 | Comment 1-B |
| 3 | Post C | 3 | Comment 3-A |
2. Post.left_joins(:comments) の場合
SELECT posts.*, comments.*
FROM posts
LEFT OUTER JOIN comments ON comments.post_id = posts.id;
| post_id | title | comment_id | body |
|---|---|---|---|
| 1 | Post A | 1 | Comment 1-A |
| 1 | Post A | 2 | Comment 1-B |
| 2 | Post B | NULL | NULL |
| 3 | Post C | 3 | Comment 3-A |
パフォーマンスの比較
Railsコンソールで以下を実行します。
require 'benchmark'
Benchmark.bm do |x|
x.report("N+1") do
posts = Post.limit(100)
posts.each { |p| p.comments.size }
end
x.report("includes") do
posts = Post.includes(:comments).limit(100)
posts.each { |p| p.comments.size }
end
x.report("joins") do
posts = Post.joins(:comments).limit(100).distinct
posts.each { |p| p.comments.size }
end
x.report("eager_load") do
posts = Post.eager_load(:comments).limit(100)
posts.each { |p| p.comments.size }
end
end
実行結果:
user system total real
N+1 0.051825 0.012811 0.064636 ( 0.152406)
includes 0.017557 0.001876 0.019433 ( 0.038053)
joins 0.032663 0.008755 0.041418 ( 0.102003)
eager_load 0.017008 0.002077 0.019085 ( 0.021954)
今回の結果では、以下のようなことがわかりました。
- N+1が発生すると圧倒的に遅い
-
eager_loadが最も高速(JOINしてまとめて取得 → 実行時間最小) -
includesもかなり速い(2クエリで効率よく取得) -
joinsはincludesより遅い(SQL結合の結果をRailsがRuby側で整理する必要があり、その分処理コストが発生する)
ベンチマーク上は、eager_load > includes > joins > N+1 という順序でしたが、eager_load が常に最速とは限らず、大量データをJOINするケースでは逆にパフォーマンスが落ちることもあります。
基本は includes を使い、必要に応じて eager_load や joins を選ぶ、というのが実務上では重要になるかと思います。
EXPLAINでクエリ計画を確認
パフォーマンスが悪いと感じるときにやるべきことはSQLの実行計画を確認することです。
例: joins のSQLをDBに直接投げるとき
EXPLAIN SELECT posts.*
FROM posts
INNER JOIN comments
ON posts.id = comments.post_id
LIMIT 10;
出力例(MySQL):
| id | select_type | table | type | possible_keys | key | key_len | ref | rows | filtered | Extra |
|----|-------------|----------|------|---------------------------|---------------------------|---------|----------------------------------|------|----------|-------------|
| 1 | SIMPLE | posts | ALL | PRIMARY | NULL | NULL | NULL | 9938 | 100.0 | NULL |
| 1 | SIMPLE | comments | ref | index_comments_on_post_id | index_comments_on_post_id | 8 | rails8_work_development.posts.id | 9 | 100.0 | Using index |
上記の EXPLAIN 結果から、次のようなことがわかります。
- posts テーブルは、フルテーブルスキャン(ALL) になっている
- comments テーブルは、post_id インデックス を利用して効率的に結合されている(Using index)
- rows 列を見ると、comments 側の参照件数が少ないことがわかる
type が ALL になっている場合は、テーブル全件を順番に読み込んでいることを意味します。このようなケースでは、適切なカラムにインデックスを追加することで、クエリの実行速度を改善できる可能性があります。
まとめ
-
preloadは2クエリでN+1を回避。JOINは使わない -
eager_loadは1クエリでJOIN。条件・ソートで便利 -
includesは状況に応じてpreloadまたはeager_loadに変換される - 集計条件を使う場合は
joins/left_joinsを使用する - 遅い場合は
EXPLAINでSQL計画を確認する- フルスキャンになっていないか?
- インデックスが効いているか?
いかがでしたか?
これらは混同しやすいポイントですが、Railsの便利メソッドに頼りすぎず、SQLレベルの挙動を確認することがパフォーマンス改善の第一歩にも繋がります。
「どんなSQLが発行されているか」を意識し、状況に応じて preload / eager_load / joins を使い分けるようにしましょう!