【Rails】N+1問題を解消するeager_load、preload、 includes、joins、 left_outer_joinsの違いについて整理する

Engineering
この記事を書いた人

PharmaXというオンライン薬局のスタートアップで薬剤師・エンジニアとして働いています。Rails・React・TypeScriptなどを書きます。英語が得意でTOEIC900点・通訳案内士資格取得。主に薬剤師の働き方やプログラミング、英語学習について書きます。当サイトではアフィリエイトプログラムを利用して商品を紹介しています。
>> 詳しいプロフィール

Tomoyuki Katoをフォローする

 

Railsにはデータベースアクセスする際に以下のようなメソッドが用意されている。

  • eager_load
  • joins
  • preload
  • includes
  • left_outer_joins

 

これらのメソッドはN+1問題を解決するのに重要なものだが、使い方をすぐ忘れてしまうの改めて整理してみる。

 

N+1問題とは無駄なクエリが発行されてしまう問題のこと

 

N+1問題とは、データベースからのレコード取得時に無駄なクエリが発行されてしまう問題のことだ。

 

例えば、あるユーザーが投稿した記事をすべて表示するといった処理を考えてみる。

 

# ruby
User.all.each do |user|
  user.posts.each do |post|
    puts post.title
  end
end

 

上記のコードでは、User.allで全ユーザーを取得(1回のクエリ)し、そのユーザーごとにuser.postsでそのユーザーの投稿を取得している(ユーザー数N回のクエリ)。

 

つまり、必要なクエリ数はユーザー数(N)+1となり、ユーザー数が増えるほどクエリ数も増えてしまう。

 

これがN+1問題だ。

 

eager_load

 

まずはeager_loadについて解説。

eager_loadは、指定した関連データを全て一度に読み込むメソッド。

 

この操作ではキャッシュを生成される。

つまり、一度eager_loadを用いてデータを読み込んだ後、再度同じデータを参照する際には、データベースに対する新たなクエリは発行されない。

 

# ruby
User.eager_load(:posts).each do |user|
  user.posts.each do |post|
    puts post.title
  end
end

 

ここでは、User.eager_load(:posts)により、ユーザーとポストを一度のクエリで読み込む。

すると、以下のようなSQLが発行される。

 

# sql
SELECT users.*, posts.* FROM users LEFT OUTER JOIN posts ON posts.user_id = users.id;

 

これにより、一度のクエリで全てのユーザーとその関連する投稿を取得できる。

この結果、N+1問題は解消される。

 

preload

preloadメソッドは、各レコードの関連データを個別に読み込むためのメソッド。

一度読み込んだデータはキャッシュに保存され、再度参照する際にはキャッシュから読み込まれる。

 

# ruby
User.preload(:posts).each do |user|
  user.posts.each do |post|
    puts post.title
  end
end

 

このコードを実行すると、以下のSQLが発行される。

 

# sql
SELECT * FROM users;
SELECT * FROM posts WHERE user_id IN (...);

 

preloadとeager_loadの使い分けでいうと、基本はpreloadを使って、whereで関連先のテーブル(今回で言うとposts)の絞り込みをする必要があるなら、eager_loadを使うみたいな感じが良さそう。

 

includes

 

includesメソッドは、eager_loadとpreloadのハイブリッド。

Railsが自動的にどちらか最適な方法を選択する。

 

個人的にeager_loadとpreloadを適宜使い分ければ良いと思っているので、あまり使っていない。

 

joins

 

次にjoinsメソッドについて解説。joinsは、関連データを一緒に読み込むことができるが、取得したデータを利用するためには明示的な指定が必要。

 

# ruby
books = Book.limit(10) 
books.filter { |book| book.author.name == 'Stephen King' }

 

このコードは、10冊の書籍の中から、著者がスティーヴン・キングであるものを抽出している。

 

しかし、各書籍に対してその都度、関連する著者を取得するため、N+1問題が発生している状態だ。

 

このコードを実行すると、以下のようなSQLが発行される。

# sql
Book Load (0.3ms)  SELECT "books".* FROM "books" LIMIT ?  [["LIMIT", 10]]
Author Load (0.1ms)  SELECT "authors".* FROM "authors" WHERE "authors"."id" = ? LIMIT ?  [["id", 1], ["LIMIT", 1]]
Author Load (0.1ms)  SELECT "authors".* FROM "authors" WHERE "authors"."id" = ? LIMIT ?  [["id", 2], ["LIMIT", 1]]
Author Load (0.1ms)  SELECT "authors".* FROM "authors" WHERE "authors"."id" = ? LIMIT ?  [["id", 3], ["LIMIT", 1]]
Author Load (0.1ms)  SELECT "authors".* FROM "authors" WHERE "authors"."id" = ? LIMIT ?  [["id", 4], ["LIMIT", 1]]
Author Load (0.1ms)  SELECT "authors".* FROM "authors" WHERE "authors"."id" = ? LIMIT ?  [["id", 5], ["LIMIT", 1]]
以下省略

 

ここで、joinsメソッドを使用してN+1問題を解消する。

 

# ruby
books = Book.joins(:author).where(authors: {name: 'Stephen King'})

 

このコードでは、BookモデルとAuthorモデルをINNER JOINして、Authorモデルのname属性で絞り込みを行っている。

 

上記のコードを実行すると、以下のようなSQLが発行される。

# sql
Book Load (3.0ms)  SELECT "books".* FROM "books" INNER JOIN "authors" ON "authors"."id" = "books"."author_id" WHERE (authors.name = 'Stephen King')

 

N+1が解消され、1回のSQLでデータ取得を行っていることが確認できる。

 

つまり、joinsメソッドを使うことで、関連モデルにある属性で絞り込みたい場合のN+1問題を解消できることがわかる。

 

left_outer_joins

 

最後にleft_outer_joinsメソッドについて。

left_outer_joinsは、指定した関連データを左外部結合で読み込むメソッドだ。

 

# ruby
books = Book.limit(10) 
books.filter { |book| book.author.name == 'Stephen King' }

 

このコードを実行すると、以下のSQLが発行される。

 

# sql
Book Load (0.3ms)  SELECT "books".* FROM "books" LIMIT ?  [["LIMIT", 10]]
Author Load (0.1ms)  SELECT "authors".* FROM "authors" WHERE "authors"."id" = ? LIMIT ?  [["id", 1], ["LIMIT", 1]]
Author Load (0.1ms)  SELECT "authors".* FROM "authors" WHERE "authors"."id" = ? LIMIT ?  [["id", 2], ["LIMIT", 1]]
Author Load (0.1ms)  SELECT "authors".* FROM "authors" WHERE "authors"."id" = ? LIMIT ?  [["id", 3], ["LIMIT", 1]]
Author Load (0.1ms)  SELECT "authors".* FROM "authors" WHERE "authors"."id" = ? LIMIT ?  [["id", 4], ["LIMIT", 1]]
Author Load (0.1ms)  SELECT "authors".* FROM "authors" WHERE "authors"."id" = ? LIMIT ?  [["id", 5], ["LIMIT", 1]]
以下省略

 

ここで、left_outer_joinsメソッドを使用してN+1問題を解消する。

 

# ruby
books = Book.left_outer_joins(:author).where(authors: {name: 'Stephen King'})

 

このコードを実行すると、以下のようなSQLが発行される。

#sql
Book Load (3.0ms)  SELECT "books".* FROM "books" LEFT OUTER JOIN "authors" ON "authors"."id" = "books"."author_id" WHERE (authors.name = 'Stephen King')

 

N+1が解消され、1回のSQLでデータ取得を行っていることが確認できる。

 

つまり、left_outer_joinsメソッドを使うことで、関連モデルにある属性で絞り込みたい場合のN+1問題を解消できることがわかる。

 

ここでjoinsメソッドとleft_outer_joinsメソッドの違いについて説明する。

joinsメソッドはINNER JOINを行い、関連モデルにレコードが存在しない場合は結果から除外される。

 

一方、left_outer_joinsメソッドはLEFT OUTER JOINを行い、関連モデルにレコードが存在しない場合でも結果に含まれる。

 

このため、関連モデルにレコードが必ず存在するとは限らない場合や、関連モデルにレコードが存在しない場合でも情報を取得したい場合は、left_outer_joinsメソッドを使用すると良いだろう。

 

まとめ

最後に簡単にまとめ

  • 発行されるSQL:
    • eager_load:LEFT OUTER JOIN
    • preload:モデルごとにSELECT句を1回ずつ発行
    • includeseager_loadまたはpreloadのどちらかをRailsが適宜自動判定
    • joins:INNER JOIN
    • left_outer_joins:LEFT OUTER JOIN
  • キャッシュ生成の有無:
    • eager_load:生成する
    • preload:生成する
    • includeseager_loadまたはpreloadに従い、生成する
    • joins:生成しない
    • left_outer_joins:生成しない
  • どのような時に使用すると良いか:
    • eager_load:where句で関連先テーブルを絞り込みたいとき
    • preload:where句で関連先テーブルの絞り込みが必要ないとき
    • includes:基本は使用しない
    • joins:関連テーブルで絞り込みをしたいとき(関連モデルにレコードが存在しない場合は結果から除外したい場合)
    • left_outer_joins:関連テーブルで絞り込みをしたいとき(関連モデルにレコードが必ず存在するとは限らない場合や、関連モデルにレコードが存在しない場合でも情報を取得したい場合)