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