0
0

RailsのN+1問題におけるpreload, eager_load, includesの挙動の違いと使い分け

Posted at

はじめに

N+1問題の対策として以下の3つのメソッドが存在することは知っているものの、挙動の違いや使い分けが思い出せずいちいち調べてしまっています。

  • preloadメソッド
  • eager_loadメソッド
  • includesメソッド

なんとか記憶に定着させたいのでこの記事を書くことにしました。

3つのメソッドの挙動や使い分けについてまとめます。

なおこの記事におけるRailsのバージョンは7.1.3.4です。

N+1問題とは?

N+1問題とは、データベースからのデータ取得時に発生するパフォーマンス上の問題です。

あるテーブルとそれに関連する別のテーブルのデータを一度に取得する際、クエリが過剰に発行されてしまうことを指します。

RailsにおけるActiveRecordのように、ORMを使用するフレームワークでよく発生します。

N+1問題の具体例

たとえば、記事の投稿者(User)とそれに紐づく記事(Article)があるとします。

class User < ApplicationRecord
  has_many :articles
end

class Article < ApplicationRecord
  belongs_to :user
end

コントローラーとビューは以下のようになっています。

controllers/users_controller.rb
class UsersController < ApplicationController
  def index
    @users = User.all
  end
end

views/users/index.html.erb
<% @users.each do |user| %>
  <h2><%= user.name %></h2>
  <ul>
    <% user.articles.each do |article| %>
      <li><%= article.title %></li>
    <% end %>
  </ul>
<% end %>

このコードは一見問題なさそうに見えますが、実際にはindex.html.erbを開いたタイミングで以下のようなクエリが発行されます。

  1. すべてのユーザーを取得するクエリ(1回)
  2. 各ユーザーの記事を取得するクエリ(ユーザーの数だけ実行、つまりN回)

したがって、合計でN+1回のクエリが実行されることになります。

これがN+1問題です。

実際のログと発行されるSQL

ローカル環境でユーザーが100人いる状況を再現してみました。

views/users/index.html.erbを開いた際のログを確認します(一部省略)

Started GET "/users" for 127.0.0.1 at 2024-07-29 21:08:53 +0900
Processing by UsersController#index as HTML
  Rendering layout layouts/application.html.erb
  Rendering users/index.html.erb within layouts/application
  User Load (0.4ms)  SELECT "users".* FROM "users"
  ↳ app/views/users/index.html.erb:1
  Article Load (0.1ms)  SELECT "articles".* FROM "articles" WHERE "articles"."user_id" = ?  [["user_id", 1]]
  ↳ app/views/users/index.html.erb:4
  Article Load (0.0ms)  SELECT "articles".* FROM "articles" WHERE "articles"."user_id" = ?  [["user_id", 2]]
  ↳ app/views/users/index.html.erb:4
  Article Load (0.0ms)  SELECT "articles".* FROM "articles" WHERE "articles"."user_id" = ?  [["user_id", 3]]
  ↳ app/views/users/index.html.erb:4
  Article Load (0.0ms)  SELECT "articles".* FROM "articles" WHERE "articles"."user_id" = ?  [["user_id", 4]]
  ↳ app/views/users/index.html.erb:4
  Article Load (0.0ms)  SELECT "articles".* FROM "articles" WHERE "articles"."user_id" = ?  [["user_id", 5]]
  ↳ app/views/users/index.html.erb:4

...省略...

  Article Load (0.0ms)  SELECT "articles".* FROM "articles" WHERE "articles"."user_id" = ?  [["user_id", 96]]
  ↳ app/views/users/index.html.erb:4
  Article Load (0.0ms)  SELECT "articles".* FROM "articles" WHERE "articles"."user_id" = ?  [["user_id", 97]]
  ↳ app/views/users/index.html.erb:4
  Article Load (0.0ms)  SELECT "articles".* FROM "articles" WHERE "articles"."user_id" = ?  [["user_id", 98]]
  ↳ app/views/users/index.html.erb:4
  Article Load (0.0ms)  SELECT "articles".* FROM "articles" WHERE "articles"."user_id" = ?  [["user_id", 99]]
  ↳ app/views/users/index.html.erb:4
  Article Load (0.0ms)  SELECT "articles".* FROM "articles" WHERE "articles"."user_id" = ?  [["user_id", 100]]
  ↳ app/views/users/index.html.erb:4
  Rendered users/index.html.erb within layouts/application (Duration: 55.1ms | Allocations: 58114)
  Rendered layout layouts/application.html.erb (Duration: 57.2ms | Allocations: 59466)
Completed 200 OK in 59ms (Views: 53.9ms | ActiveRecord: 4.3ms | Allocations: 59711)

見やすくするためにSQLの部分だけ抜粋します。

-- 全てのユーザーを取得するクエリ(1回)
SELECT "users".* FROM "users"

-- 各ユーザーの記事を取得するクエリ(ユーザーの数だけ実行、今回のケースだと100回)
SELECT "articles".* FROM "articles" WHERE "articles"."user_id" = ?  [["user_id", 1]]
SELECT "articles".* FROM "articles" WHERE "articles"."user_id" = ?  [["user_id", 2]]
SELECT "articles".* FROM "articles" WHERE "articles"."user_id" = ?  [["user_id", 3]]
SELECT "articles".* FROM "articles" WHERE "articles"."user_id" = ?  [["user_id", 4]]
SELECT "articles".* FROM "articles" WHERE "articles"."user_id" = ?  [["user_id", 5]]
...
SELECT "articles".* FROM "articles" WHERE "articles"."user_id" = ?  [["user_id", 96]]
SELECT "articles".* FROM "articles" WHERE "articles"."user_id" = ?  [["user_id", 97]]
SELECT "articles".* FROM "articles" WHERE "articles"."user_id" = ?  [["user_id", 98]]
SELECT "articles".* FROM "articles" WHERE "articles"."user_id" = ?  [["user_id", 99]]
SELECT "articles".* FROM "articles" WHERE "articles"."user_id" = ?  [["user_id", 100]]

このケースでは合計101回のクエリが発行されてしまっています。

これは大量のデータを扱う際にパフォーマンス上の問題を引き起こしてしまうため、回避しないといけません。

N+1問題の解消方法

RailsではN+1問題を解消するために、以下の3つのメソッドが用意されています。

  • preloadメソッド
  • eager_loadメソッド
  • includesメソッド

それぞれ順番に説明していきます。

preloadメソッド

preloadメソッドはテーブル毎に1回ずつクエリを発行して関連データを取得するメソッドです。

言葉で説明してもわかりにくいため、実際の挙動で確認を進めます。

先ほどのcontrollers/users_controller.rbpreloadメソッドを書き加えます。

controllers/users_controller.rb
class UsersController < ApplicationController
  def index
    # preload でN+1問題を解消
    @users = User.preload(:articles)
  end
end

views/users/index.html.erbには変更を加えないまま、画面を表示した際のログです。

Started GET "/users" for 127.0.0.1 at 2024-07-30 20:28:16 +0900
Processing by UsersController#index as HTML
  Rendering layout layouts/application.html.erb
  Rendering users/index.html.erb within layouts/application
  User Load (0.4ms)  SELECT "users".* FROM "users"
  ↳ app/views/users/index.html.erb:1
  Article Load (0.3ms)  SELECT "articles".* FROM "articles" WHERE "articles"."user_id" IN (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)  [["user_id", 1], ["user_id", 2], ["user_id", 3], ["user_id", 4], ["user_id", 5], ["user_id", 6], ["user_id", 7], ["user_id", 8], ["user_id", 9], ["user_id", 10], ["user_id", 11], ["user_id", 12], ["user_id", 13], ["user_id", 14], ["user_id", 15], ["user_id", 16], ["user_id", 17], ["user_id", 18], ["user_id", 19], ["user_id", 20], ["user_id", 21], ["user_id", 22], ["user_id", 23], ["user_id", 24], ["user_id", 25], ["user_id", 26], ["user_id", 27], ["user_id", 28], ["user_id", 29], ["user_id", 30], ["user_id", 31], ["user_id", 32], ["user_id", 33], ["user_id", 34], ["user_id", 35], ["user_id", 36], ["user_id", 37], ["user_id", 38], ["user_id", 39], ["user_id", 40], ["user_id", 41], ["user_id", 42], ["user_id", 43], ["user_id", 44], ["user_id", 45], ["user_id", 46], ["user_id", 47], ["user_id", 48], ["user_id", 49], ["user_id", 50], ["user_id", 51], ["user_id", 52], ["user_id", 53], ["user_id", 54], ["user_id", 55], ["user_id", 56], ["user_id", 57], ["user_id", 58], ["user_id", 59], ["user_id", 60], ["user_id", 61], ["user_id", 62], ["user_id", 63], ["user_id", 64], ["user_id", 65], ["user_id", 66], ["user_id", 67], ["user_id", 68], ["user_id", 69], ["user_id", 70], ["user_id", 71], ["user_id", 72], ["user_id", 73], ["user_id", 74], ["user_id", 75], ["user_id", 76], ["user_id", 77], ["user_id", 78], ["user_id", 79], ["user_id", 80], ["user_id", 81], ["user_id", 82], ["user_id", 83], ["user_id", 84], ["user_id", 85], ["user_id", 86], ["user_id", 87], ["user_id", 88], ["user_id", 89], ["user_id", 90], ["user_id", 91], ["user_id", 92], ["user_id", 93], ["user_id", 94], ["user_id", 95], ["user_id", 96], ["user_id", 97], ["user_id", 98], ["user_id", 99], ["user_id", 100]]
  ↳ app/views/users/index.html.erb:1
  Rendered users/index.html.erb within layouts/application (Duration: 118.5ms | Allocations: 26853)
  Rendered layout layouts/application.html.erb (Duration: 269.3ms | Allocations: 60932)
Completed 200 OK in 364ms (Views: 271.9ms | ActiveRecord: 4.4ms | Allocations: 68551)

今回はログを一切省略していないため、これだけでも発行されるSQLが大幅に削減されていることがわかります。

先ほどと同様にSQLの部分だけを抜き出した上で、わかりやすさのためにプレースホルダーの部分のみ整形します。

-- 全てのユーザーを取得するクエリ(1回)
SELECT "users".* FROM "users"

-- 各ユーザーの記事をまとめて取得するクエリ(1回)
SELECT "articles".* FROM "articles" WHERE "articles"."user_id" IN (1, 2, 3, ... 98, 99, 100) 

このように関連するテーブル毎に1回ずつクエリを発行することでN+1問題を解消するのがpreloadメソッドです。

eager_loadメソッド

eager_loadメソッドはLEFT OUTER JOINを用いて関連テーブルを結合し、1回のクエリで関連データを全て取得するメソッドです。

こちらも実際の挙動で確認します。

先ほどのcontrollers/users_controller.rbに書いたpreloadの部分をeager_loadメソッドに書き換えます。

controllers/users_controller.rb
class UsersController < ApplicationController
  def index
    # eager_load でN+1問題を解消
    @users = User.eager_load(:articles)
  end
end

views/users/index.html.erbには変更を加えないまま、画面を表示した際のログです。

Started GET "/users" for 127.0.0.1 at 2024-07-30 20:44:10 +0900
Processing by UsersController#index as HTML
  Rendering layout layouts/application.html.erb
  Rendering users/index.html.erb within layouts/application
  SQL (0.2ms)  SELECT "users"."id" AS t0_r0, "users"."name" AS t0_r1, "users"."created_at" AS t0_r2, "users"."updated_at" AS t0_r3, "articles"."id" AS t1_r0, "articles"."user_id" AS t1_r1, "articles"."title" AS t1_r2, "articles"."content" AS t1_r3, "articles"."created_at" AS t1_r4, "articles"."updated_at" AS t1_r5 FROM "users" LEFT OUTER JOIN "articles" ON "articles"."user_id" = "users"."id"
  ↳ app/views/users/index.html.erb:1
  Rendered users/index.html.erb within layouts/application (Duration: 55.3ms | Allocations: 16854)
  Rendered layout layouts/application.html.erb (Duration: 69.6ms | Allocations: 32376)
Completed 200 OK in 75ms (Views: 68.6ms | ActiveRecord: 2.6ms | Allocations: 34290)

同じくSQL部分だけを抜粋します。

SELECT 
    "users"."id" AS t0_r0,
    "users"."name" AS t0_r1,
    "users"."created_at" AS t0_r2,
    "users"."updated_at" AS t0_r3,
    "articles"."id" AS t1_r0,
    "articles"."user_id" AS t1_r1,
    "articles"."title" AS t1_r2,
    "articles"."content" AS t1_r3,
    "articles"."created_at" AS t1_r4,
    "articles"."updated_at" AS t1_r5
FROM "users" 
LEFT OUTER JOIN "articles" ON "articles"."user_id" = "users"."id"

LEFT OUTER JOINusersテーブルとarticlesテーブルを結合した上で必要なデータを1回のクエリで取得しています。

このようにテーブル結合を使って1回のクエリ発行のみでN+1問題を解消するのがeager_loadメソッドです。

includesメソッド

includesメソッドはRailsが内部的にpreloadまたはeager_loadのどちらかを選択して実行してくれるメソッドです。

通常はpreloadを使用しますが、条件によってはeager_loadを実行します。

これも動作確認をしてみましょう。

まずは先ほどのeager_loadの部分をincludesに書き換えます。

controllers/users_controller.rb
class UsersController < ApplicationController
  def index
    # includes でN+1問題を解消
    @users = User.includes(:articles)
  end
end

views/users/index.html.erb表示した際のログを確認します。

Started GET "/users" for 127.0.0.1 at 2024-07-30 20:56:33 +0900
Processing by UsersController#index as HTML
  Rendering layout layouts/application.html.erb
  Rendering users/index.html.erb within layouts/application
  User Load (2.2ms)  SELECT "users".* FROM "users"
  ↳ app/views/users/index.html.erb:1
  Article Load (0.3ms)  SELECT "articles".* FROM "articles" WHERE "articles"."user_id" IN (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)  [["user_id", 1], ["user_id", 2], ["user_id", 3], ["user_id", 4], ["user_id", 5], ["user_id", 6], ["user_id", 7], ["user_id", 8], ["user_id", 9], ["user_id", 10], ["user_id", 11], ["user_id", 12], ["user_id", 13], ["user_id", 14], ["user_id", 15], ["user_id", 16], ["user_id", 17], ["user_id", 18], ["user_id", 19], ["user_id", 20], ["user_id", 21], ["user_id", 22], ["user_id", 23], ["user_id", 24], ["user_id", 25], ["user_id", 26], ["user_id", 27], ["user_id", 28], ["user_id", 29], ["user_id", 30], ["user_id", 31], ["user_id", 32], ["user_id", 33], ["user_id", 34], ["user_id", 35], ["user_id", 36], ["user_id", 37], ["user_id", 38], ["user_id", 39], ["user_id", 40], ["user_id", 41], ["user_id", 42], ["user_id", 43], ["user_id", 44], ["user_id", 45], ["user_id", 46], ["user_id", 47], ["user_id", 48], ["user_id", 49], ["user_id", 50], ["user_id", 51], ["user_id", 52], ["user_id", 53], ["user_id", 54], ["user_id", 55], ["user_id", 56], ["user_id", 57], ["user_id", 58], ["user_id", 59], ["user_id", 60], ["user_id", 61], ["user_id", 62], ["user_id", 63], ["user_id", 64], ["user_id", 65], ["user_id", 66], ["user_id", 67], ["user_id", 68], ["user_id", 69], ["user_id", 70], ["user_id", 71], ["user_id", 72], ["user_id", 73], ["user_id", 74], ["user_id", 75], ["user_id", 76], ["user_id", 77], ["user_id", 78], ["user_id", 79], ["user_id", 80], ["user_id", 81], ["user_id", 82], ["user_id", 83], ["user_id", 84], ["user_id", 85], ["user_id", 86], ["user_id", 87], ["user_id", 88], ["user_id", 89], ["user_id", 90], ["user_id", 91], ["user_id", 92], ["user_id", 93], ["user_id", 94], ["user_id", 95], ["user_id", 96], ["user_id", 97], ["user_id", 98], ["user_id", 99], ["user_id", 100]]
  ↳ app/views/users/index.html.erb:1
  Rendered users/index.html.erb within layouts/application (Duration: 13.6ms | Allocations: 14369)
  Rendered layout layouts/application.html.erb (Duration: 33.9ms | Allocations: 29716)
Completed 200 OK in 42ms (Views: 33.2ms | ActiveRecord: 3.0ms | Allocations: 31628)

preloadの時と同じログが出力されました。

次にincludesを使用した上で条件を指定してみます。

controllers/users_controller.rb
class UsersController < ApplicationController
  def index
    # articles の id が50以上のデータを取得する
    @users = User.includes(:articles).where(articles: { id: 50..Float::INFINITY })
  end
end

この場合のログを確認します。

Started GET "/users" for 127.0.0.1 at 2024-07-30 21:00:42 +0900
Processing by UsersController#index as HTML
  Rendering layout layouts/application.html.erb
  Rendering users/index.html.erb within layouts/application
  SQL (0.2ms)  SELECT "users"."id" AS t0_r0, "users"."name" AS t0_r1, "users"."created_at" AS t0_r2, "users"."updated_at" AS t0_r3, "articles"."id" AS t1_r0, "articles"."user_id" AS t1_r1, "articles"."title" AS t1_r2, "articles"."content" AS t1_r3, "articles"."created_at" AS t1_r4, "articles"."updated_at" AS t1_r5 FROM "users" LEFT OUTER JOIN "articles" ON "articles"."user_id" = "users"."id" WHERE "articles"."id" >= ?  [["id", 50]]
  ↳ app/views/users/index.html.erb:1
  Rendered users/index.html.erb within layouts/application (Duration: 3.4ms | Allocations: 3950)
  Rendered layout layouts/application.html.erb (Duration: 16.1ms | Allocations: 5305)
Completed 200 OK in 17ms (Views: 16.2ms | ActiveRecord: 0.2ms | Allocations: 6037)

今度はeager_loadと同様にLEFT OUTER JOINを使ったクエリが発行されています。

以上のように、includesメソッドを使った場合、通常はpreloadが採用され、関連データに対して条件を指定する場合はeager_loadを採用するのが一般的な挙動です。

3つのメソッドの使い分け

ここからはpreloadeager_loadincludesの使い分けについてまとめます。

基本はpreloadを使う

基本的にはpreloadを使うのが良いと思われます。

preloadは複数のクエリに分割して必要なデータを取得するため、メモリ使用量の点でeager_loadよりも優れているからです。

特にデータ量やテーブルのカラム数が多かったりする場合、eager_loadでテーブル結合してしまうと、すごく重いクエリを実行することになってしまいます。

また実装時点ではeager_loadの方が速くても、レコード数が増えてくるにつれてpreloadの方が速くなることも考えられます。

よって、不都合がなければpreloadの採用を基本とした方が安全で、事故を引き起こす可能性が低いです。

関連テーブルに条件を指定する場合はeager_loadを使う

とはいえ、関連テーブルに条件を指定したい場合はeager_loadを用いてLEFT OUTER JOINをするしかありません。

この場合はeager_loadを使いましょう。

また、単に条件指定で絞り込みをしたいだけであればjoinsメソッドを使えばメモリ使用量を節約できます。

関連テーブルのレコードのキャッシュが不要な場合はeager_loadを避けてjoinsメソッドを使いましょう。

includesは非推奨

includesは「Railsが内部的にpreloadまたはeager_loadのどちらかを選択して実行してくれるメソッド」だと説明しました。

これだけ聞くとincludesを使うのが最適なように思えるかもしれません。

しかしincludesは非推奨とされるケースもそれなりにあります。

その理由は以下の2点です。

  1. 関連テーブルが複数あるときに個別最適化できない
  2. 内部挙動が明確ではない

1. 関連テーブルが複数あるときに個別最適化できないに関して、たとえば以下のコードがあったとします。

def index
  @users = User.includes(:articles, :comments)
end

この場合、「articleseager_load」・「commentspreload」といった個別の使い分けはできず、LEFT OUTER JOINをすべきテーブルが1つでもあれば、すべてeager_loadでの読み込みがされてしまいます。

これがパフォーマンスの低下を招く恐れがあるのです。

2. 内部挙動が明確ではないに関して、includesが内部でpreloadeager_loadのどちらを採用しているかはクエリの文脈にも依存してしまいます。

そのためどちらを採用しているか常に明白とはかぎらず、予期せぬ挙動を取りかねません。

これはデバッグやパフォーマンスチューニングの際に問題の特定を困難にしてしまう可能性があります。

「便利だから」と安易にincludesを使うのは避けて、明示的にpreloadeager_loadのどちらかを使った方がわかりやすいです。

もちろん小規模な開発や実装スピード重視の場合はincludesでも問題ありませんが、その場合でもどちらのメソッドを採用しているかは把握しておいた方が良いでしょう。

おわりに

最後にまとめです。

  • preloadは関連テーブル毎に個別にクエリを発行してデータを取得する
  • eager_loadは関連テーブルを結合して1つのクエリで必要なデータを全て取得する
  • includespreloadeager_loadを内部で自動的に使い分ける
  • 基本はpreloadを使っておくのが無難
  • 関連テーブルに条件指定したい時はeager_loadを使うが、関連テーブルのデータが不要であればjoinsメソッドで条件指定を代替できる
  • includesは一見便利そうだが非推奨とされることも多い

3つのメソッドの違いをしっかりと理解して最適な使い分けができるようになりましょう。

参考資料

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