はじめに
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
コントローラーとビューは以下のようになっています。
class UsersController < ApplicationController
def index
@users = User.all
end
end
<% @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回)
- 各ユーザーの記事を取得するクエリ(ユーザーの数だけ実行、つまり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.rb
にpreload
メソッドを書き加えます。
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
メソッドに書き換えます。
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 JOIN
でusers
テーブルとarticles
テーブルを結合した上で必要なデータを1回のクエリで取得しています。
このようにテーブル結合を使って1回のクエリ発行のみでN+1問題を解消するのがeager_load
メソッドです。
includes
メソッド
includes
メソッドはRailsが内部的にpreload
またはeager_load
のどちらかを選択して実行してくれるメソッドです。
通常はpreload
を使用しますが、条件によってはeager_load
を実行します。
これも動作確認をしてみましょう。
まずは先ほどのeager_load
の部分をincludes
に書き換えます。
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
を使用した上で条件を指定してみます。
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つのメソッドの使い分け
ここからはpreload
・eager_load
・includes
の使い分けについてまとめます。
基本は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. 関連テーブルが複数あるときに個別最適化できない
に関して、たとえば以下のコードがあったとします。
def index
@users = User.includes(:articles, :comments)
end
この場合、「articles
はeager_load
」・「comments
はpreload
」といった個別の使い分けはできず、LEFT OUTER JOIN
をすべきテーブルが1つでもあれば、すべてeager_load
での読み込みがされてしまいます。
これがパフォーマンスの低下を招く恐れがあるのです。
2. 内部挙動が明確ではない
に関して、includes
が内部でpreload
とeager_load
のどちらを採用しているかはクエリの文脈にも依存してしまいます。
そのためどちらを採用しているか常に明白とはかぎらず、予期せぬ挙動を取りかねません。
これはデバッグやパフォーマンスチューニングの際に問題の特定を困難にしてしまう可能性があります。
「便利だから」と安易にincludes
を使うのは避けて、明示的にpreload
かeager_load
のどちらかを使った方がわかりやすいです。
もちろん小規模な開発や実装スピード重視の場合はincludes
でも問題ありませんが、その場合でもどちらのメソッドを採用しているかは把握しておいた方が良いでしょう。
おわりに
最後にまとめです。
-
preload
は関連テーブル毎に個別にクエリを発行してデータを取得する -
eager_load
は関連テーブルを結合して1つのクエリで必要なデータを全て取得する -
includes
はpreload
とeager_load
を内部で自動的に使い分ける - 基本は
preload
を使っておくのが無難 - 関連テーブルに条件指定したい時は
eager_load
を使うが、関連テーブルのデータが不要であればjoins
メソッドで条件指定を代替できる -
includes
は一見便利そうだが非推奨とされることも多い
3つのメソッドの違いをしっかりと理解して最適な使い分けができるようになりましょう。
参考資料