0
0

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初心者必見!】JOIN(joins / left_joins )と Eager Loading(includes / preload / eager_load)の使い分け

0
Posted at

はじめに

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 の違い
  • joinsleft_joins の違い
  • それぞれのメソッドでどんなSQLが発行されるのか
  • パフォーマンスの比較

などについて、確認していきたいと思います。

JOIN と Eager Loading の違い

よく混乱しがちな用語に「JOIN」と「Eager Loading」があります。
これらは似ているようで目的が異なります。

JOIN

SQLのJOINを使って複数テーブルを結合することを指します。
Railsでは joinsleft_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 は、状況に応じて preloadeager_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 について

joinsleft_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クエリで効率よく取得)
  • joinsincludes より遅い(SQL結合の結果をRailsがRuby側で整理する必要があり、その分処理コストが発生する)

ベンチマーク上は、eager_load > includes > joins > N+1 という順序でしたが、eager_load が常に最速とは限らず、大量データをJOINするケースでは逆にパフォーマンスが落ちることもあります。

基本は includes を使い、必要に応じて eager_loadjoins を選ぶ、というのが実務上では重要になるかと思います。

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 を使い分けるようにしましょう!

0
0
0

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
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?