目次
- はじめに
- n+1問題とは
- n+1を解決する方法
- preload
- eager_load
- includes
- どれを使うのが良いのか?
はじめに
Ruby on RailsではActiveRecordというORMを採用しているため、自身でクエリ文を書かずともデータベースからデータを取得することが可能です。
例えば、あるモデルの全てのデータを取得したいとします。以下のように書けば、望んだデータの取得ができます。
Model.all
これを、SQLで書くと以下のようになります。
SELECT
"model".*
FROM
"models"
これでもまだシンプルな方ではあります。実際には複数の条件を指定して、複数のテーブルと関連づいたデータモデルを扱わなければなりません。そうなると、上記のようにシンプルなクエリ文では済まなくなってきます。
ActiveRecordはこんな感じでよしなにデータを取得してきてくれるのですが、何も考えずに使用していくと、これから解説するn+1問題
が発生する可能性が大いにあるため、しっかり理解して使用する必要があります。
n+1問題とは
簡潔に言うならば、「余計なクエリが発行される」問題のことです。
余計なクエリが発行されるということは、アプリ自体のパフォーマンスが低下することになります。
もっと具体的に解説していきます。
会員テーブル(users)
と投稿テーブル(posts)
があったとして、これらは、1対多
で関連づいています。
投稿一覧画面において、投稿に紐づくユーザーを取得する場合があるとします。
# posts_controller.rb
def index
@posts = Post.all # DBに保存されている全ての投稿を取得する
end
# views/posts/index.html.erb
# 投稿のタイトルと投稿に紐づくユーザー名を表示する
@posts.each do |post|
post.title
post.user.name
end
このようなコードを書くと以下のようなクエリが発行されます。
Post Load (0.2ms) SELECT "posts".* FROM "posts"
User Load (0.1ms) SELECT "users".* FROM "users" WHERE "users"."id" = ? LIMIT ? [["id", x], ["LIMIT", 1]]
User Load (0.1ms) SELECT "users".* FROM "users" WHERE "users"."id" = ? LIMIT ? [["id", x], ["LIMIT", 1]]
User Load (0.1ms) SELECT "users".* FROM "users" WHERE "users"."id" = ? LIMIT ? [["id", x], ["LIMIT", 1]]
User Load (0.1ms) SELECT "users".* FROM "users" WHERE "users"."id" = ? LIMIT ? [["id", x], ["LIMIT", 1]]
User Load (0.1ms) SELECT "users".* FROM "users" WHERE "users"."id" = ? LIMIT ? [["id", x], ["LIMIT", 1]]
User Load (0.1ms) SELECT "users".* FROM "users" WHERE "users"."id" = ? LIMIT ? [["id", x], ["LIMIT", 1]]
User Load (0.1ms) SELECT "users".* FROM "users" WHERE "users"."id" = ? LIMIT ? [["id", x], ["LIMIT", 1]]
User Load (0.1ms) SELECT "users".* FROM "users" WHERE "users"."id" = ? LIMIT ? [["id", x], ["LIMIT", 1]]
-
投稿テーブルの全データを取得
Post Load (0.2ms) SELECT "posts".* FROM "posts"
-
投稿に紐づくユーザーデータを取得
User Load (0.1ms) SELECT "users".* FROM "users" WHERE "users"."id" = ? LIMIT ? [["id", x], ["LIMIT", 1]]
このように投稿に紐づくクエリ
が1回
、投稿ごとユーザーを取得するクエリ
がn回
生成されてしまうこととなります。
なぜこのような問題が発生するかというと、データモデル間(users-posts)にhas_many
やbelongs_to
といったアソシエーションが組まれているためです。
n+1問題を解決する方法
RubyonRailsにはn+1を解決するための便利なメソッドが用意されています。
- preload
- eager_load
- includes
preload
preloadは2つ
のクエリを発行します。
一つは投稿(posts)を取得するクエリ、もう一つは関連づいている会員(users)を取得するクエリです。
def index
@posts = Post.preload(:user)
end
Post Load (30.8ms)
SELECT
"posts".*
FROM
"posts"
User Load (0.2ms)
SELECT
"users".*
FROM
"users"
WHERE
"users"."id" IN(?, ?, ?, ?, ?, ?, ?, ?, ?, ?) [[nil, 2], [nil, 5], [nil, 8], [nil, 4], [nil, 9], [nil, 6], [nil, 7], [nil, 3], [nil, 10], [nil, 1]]
eager_load
eager_loadは引数にとったテーブルを左外部結合(LEFT OUTER JOIN)します。
prelaodとは違うクエリになっているのがわかると思います。
def index
@posts = Post.eager_load(:user)
end
SELECT
"posts"."id" AS t0_r0,
"posts"."title" AS t0_r1,
"posts"."body" AS t0_r2,
"posts"."user_id" AS t0_r3,
"posts"."created_at" AS t0_r4,
"posts"."updated_at" AS t0_r5,
"users"."id" AS t1_r0,
"users"."name" AS t1_r1,
"users"."email" AS t1_r2,
"users"."created_at" AS t1_r3,
"users"."updated_at" AS t1_r4
FROM
"posts"
LEFT OUTER JOIN
"users"
ON "users"."id" = "posts"."user_id"
eager_loadは投稿テーブルと会員テーブルをJoinしているため、一回のクエリで済んでいます。
一回のクエリでデータを取得してくるので、一見preload
よりもパフォーマンスが高いと考える方もいるかもしれませんが、取得してくるデータ量が多ければ多いほど、パフォーマンスは低下します。
includes
includesはpreload
とeager_load
をよしなに切り替えてくれるメソッドになります。
今回の場合はpreloadと同じ挙動になっています。
def index
@posts = Post.includes(:user)
end
Post Load (0.2ms) SELECT "posts".* FROM "posts"
User Load (0.1ms) SELECT "users".* FROM "users" WHERE "users"."id" IN (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) [[nil, 10], [nil, 8], [nil, 1], [nil, 3], [nil, 7], [nil, 5], [nil, 4], [nil, 6], [nil, 9], [nil, 2]]
では、eager_loadと同じ挙動になる場合はどのような場合でしょうか?
includesしたテーブルでwhere句などを使用して条件を絞った場合にeager_loadと同じ挙動(LEFT OUTER JOIN)になります。
def index
@posts = Post.includes(:user).where(user: { id: 1 }) # 会員IDが1の投稿のみを取得する
end
SELECT
"posts"."id" AS t0_r0,
"posts"."title" AS t0_r1,
"posts"."body" AS t0_r2,
"posts"."user_id" AS t0_r3,
"posts"."created_at" AS t0_r4,
"posts"."updated_at" AS t0_r5,
user."id" AS t1_r0,
user."name" AS t1_r1,
user."email" AS t1_r2,
user."created_at" AS t1_r3,
user."updated_at" AS t1_r4
FROM
"posts"
LEFT OUTER JOIN
"users" user
ON user."id" = "posts"."user_id"
WHERE
"user"."id" = ? [["id", 1]]
どれを使うのが良いのか?
とりあえずincludesを書いておけば、Rails側で状況に応じて挙動が異なると思いますが、どう言ったケースでどういった挙動になるのかを理解せずに利用するのはあまりよろしくありません。
個人的には、コードを読む側にとってはpreloadとeager_loadを使い分けていた方が可読性は高いのかなと思います。
ですが、これはコードを書く人やプロジェクトごと認識が違うところではあるので、その時に合った使い方をしましょう。
記事引用元