n+1問題とは

目次

  • はじめに
  • 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_manybelongs_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)します。

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はpreloadeager_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を使い分けていた方が可読性は高いのかなと思います。

ですが、これはコードを書く人やプロジェクトごと認識が違うところではあるので、その時に合った使い方をしましょう。