
こんにちは!ゼネットの中川です。
今回はRailsにおけるinclude/preload/joinの違いとSQLについてという内容となります。
それぞれデータを扱うときに使うものになりますが、どのような違いがあるのかまとめてみました。
N+1問題とは
Railsアプリケーションでよく発生する代表的なパフォーマンス問題の一つがN+1問題になります。
例えば、UserとPostが1対Nの関係にあるとします。
次のように、ユーザー一覧とその投稿タイトルを表示したいケースを考えます。
users = User.limit(5)
users.each do |user|
puts user.posts.first&.title
end
一見すると問題のないコードですが、実際には以下のようなSQLが発行されています。
SELECT "users".* FROM "users" LIMIT 5
SELECT "posts".* FROM "posts" WHERE "posts"."user_id" = 1 ORDER BY "posts"."id" ASC LIMIT 1
SELECT "posts".* FROM "posts" WHERE "posts"."user_id" = 2 ORDER BY "posts"."id" ASC LIMIT 1
SELECT "posts".* FROM "posts" WHERE "posts"."user_id" = 3 ORDER BY "posts"."id" ASC LIMIT 1
SELECT "posts".* FROM "posts" WHERE "posts"."user_id" = 4 ORDER BY "posts"."id" ASC LIMIT 1
SELECT "posts".* FROM "posts" WHERE "posts"."user_id" = 5 ORDER BY "posts"."id" ASC LIMIT 1
最初にユーザー情報を取得するために1回のSQL文が流れます。
その後、ユーザーに関連する投稿を取得するのですが、再度そこで投稿を取得するためのSQLが流れることになります。
もし、ユーザーが100人いれば
1回(users取得)+100回(各post取得) = 101回のSQLが実行される
という状態になりとても非効率な情報取得を行っていることがわかります。
なぜ問題なの?
- SQLの発行回数がデータ件数に比例して増えていく
- ユーザー数やリレーション数が増えるほどパフォーマンスが劣化
- 本番環境でレスポンス遅延やDB負荷増大を引き起こす原因になる
preload/includes/joinの違い
N+1を回避する方法としてRailsにはincludes、preloadが用意されています。
実際に発行されるSQLとメモリの観点からそれぞれの特徴と使い分けを整理したいと思います。
preloadの動作例
通常、UserとPostのように1対多の関係を持つモデルで、次のようなコードを書いた場合Userに関連しているPostデータを一括で事前に読み込めるようになります。
コード例
users = User.preload(:posts).limit(5)
実際に発行されるSQL
User Load (0.1ms) SELECT "users".* FROM "users"
Post Load (0.2ms) SELECT "posts".* FROM "posts" WHERE "posts"."user_id" IN (1, 2, 3, 4, 5)
2つのクエリのみで、ユーザーとそれに紐づく投稿が取得できる為、ループの中で新たにSQLが発行されるということはありません。
その結果、パフォーマンスが改善されるというものになります。
eager_loadの動作例
eager_loadは関連先をJOINを使って一本のSQLでまとめて取得するものになります。
コード例
users = User.eager_load(:posts).limit(5)
users.each do |user|
puts "#{user.name}:#{user.posts.first&.title}"
end
実際に発行されるSQL
SELECT DISTINCT "users"."id" FROM "users" LEFT OUTER JOIN "posts" ON "posts"."user_id" = "users"."id" LIMIT 5 /*application='Myapp'*/
SELECT "users"."id" AS t0_r0, "users"."name" AS t0_r1, "users"."created_at" AS t0_r2, "users"."updated_at" AS t0_r3, "posts"."id" AS t1_r0, "posts"."title" AS t1_r1, "posts"."body" AS t1_r2, "posts"."user_id" AS t1_r3, "posts"."created_at" AS t1_r4, "posts"."updated_at" AS t1_r5 FROM "users" LEFT OUTER JOIN "posts" ON "posts"."user_id" = "users"."id" WHERE "users"."id" IN (1, 2, 3, 4, 5) /*application='Myapp'*/
このように、usersとpostsがLEFT OUTER JOINされた1本のクエリだけが発行されます。
DISTINCT "users"になっているのは、JOINの結果としてユーザー1件に対して複数行が返ってしまうのをRailsが吸収を行っているためです。
includesの動作例
内部的には先ほど紹介したpreloadとeager_loadの両方の動作を持ち、状況に応じて適切な方式を自動で選択してくれるメソッドとなります。
単に関連を参照するだけの場合は、includesは内部的にpreloadと同様の動きをします。
コード例
users = User.includes(:posts).limit(5)
users.each do |user|
puts user.posts.first.title
end
実際に発行されるSQL
User Load (4.6ms) SELECT "users".* FROM "users" LIMIT 5 /*application='ArBench'*/
Post Load (0.2ms) SELECT "posts".* FROM "posts" WHERE "posts"."user_id" IN (1, 2, 3, 4, 5)
また、includesを使っていても、以下のように関連テーブルを条件やソートに使用するとRailsは自動的にeager_loadに切り替え、1本のJOINクエリを発行します。
コード例
User.includes(:posts).where(posts: {title: "test"}).limit(5)
発行されるSQL
SELECT DISTINCT "users"."id" FROM "users" LEFT OUTER JOIN "posts" ON "posts"."user_id" = "users"."id" WHERE "posts"."title" = 'test'
SELECT "users"."id" AS t0_r0, "users"."name" AS t0_r1, "users"."created_at" AS t0_r2, "users"."updated_at" AS t0_r3, "posts"."id" AS t1_r0, "posts"."user_id" AS t1_r1, "posts"."title" AS t1_r2, "posts"."body" AS t1_r3, "posts"."created_at" AS t1_r4, "posts"."updated_at" AS t1_r5 FROM "users" LEFT OUTER JOIN "posts" ON "posts"."user_id" = "users"."id" WHERE "posts"."title" = 'test' AND "users"."id" IN (1, 2, 3, 4, 5)
このように関連テーブルを条件に含めるとincludesは自動的にeager_loadと同等の動きをします。
joinsの動作例
N+1問題は解消されないが、関連テーブルをINNER JOINで結合し、検索条件や絞り込みに利用するためのメソッドになります。
他と違う点としては関連データをオブジェクトとしてメモリに保持することができません。
コード例
users = User.joins(:posts).limit(5)
実際に発行されるSQL
SELECT "users".* FROM "users" INNER JOIN "posts" ON "posts"."user_id" = "users"."id"
技術負債になるケースについて
preloadが「技術負債」になるケース
当初はN+1問題を防ぐために導入していたpreloadが、サービスの成長によって「1ユーザーが持つ関連データ」が増加し、結果として"毎リクエストで数十MB以上のメモリを確保する設計に膨張"してしまったという事例です。
preloadは「クエリが2回で済む」というメリットがある一方で、"関連を全件オブジェクト化してしまう"というコストが常に存在するため、「将来データが増え続けるモデルか?」を考えずに採用すると確実に積もる負債になります。
includesが「技術負債」になるケース
includesはN+1問題を解決するための強力な仕組みですが、安易に使うとメモリ爆発や意図しないJOIN最適化の原因になります。
不要な関連まで一括ロードしてしまう
users = User.includes(:posts, :comments, :likes)
上記のような内容はN+1を防ぎますが、実際の画面で投稿しか使っていない場合でも、commentsとlikesも同時にロードしてしまいます。
データ件数が増えるほど、メモリを圧迫し、応答速度が遅くなってしまいます。
joinsが「技術負債」になるケース
joinsは関連テーブルをINNER JOINで結合しますが、JOIN先のテーブルのデータはSELECTされません。
そのため、JOINで取得した後に関連のデータにアクセスを行うと、Railsは必要なデータをその都度取りに行くため、結果的にN+1クエリが発生してしまいます。
以下のように一見良さそうなjoinですが
users = User.joins(:posts).limit(5)
ここで以下のようにpostにアクセスした瞬間N+1が再発
users.each do |u|
u.posts.first&.title
end
発行されるSQL
SELECT "users".* FROM "users" INNER JOIN "posts" ON "posts"."user_id" = "users"."id" LIMIT 5
SELECT "posts".* FROM "posts" WHERE "posts"."user_id" = 1 ORDER BY "posts"."id" ASC LIMIT 1
SELECT "posts".* FROM "posts" WHERE "posts"."user_id" = 1 ORDER BY "posts"."id" ASC LIMIT 1
SELECT "posts".* FROM "posts" WHERE "posts"."user_id" = 1 ORDER BY "posts"."id" ASC LIMIT 1
SELECT "posts".* FROM "posts" WHERE "posts"."user_id" = 1 ORDER BY "posts"."id" ASC LIMIT 1
SELECT "posts".* FROM "posts" WHERE "posts"."user_id" = 1 ORDER BY "posts"."id" ASC LIMIT 1
上記のような「関連を実際に使う」前提なら最初からinclude/preloadを使うべきです。
どのメソッドを選べばいいのか
ここまでpreload/includes/joinsの特徴や落とし穴を見てきましたが、「結局どれを使えばいいの?」という疑問が出てくると思います。
ここでは使い分け基準を整理したいと思います。
検索・集計だけを行いたい場合はjoins
検索、集計を行いたい場合はjoinsが適切です。
- DBレベルのJOINのため高速
- Rubyオブジェクトを作らないためメモリ使用量が少ない
ただし、注意点としてはjoinsは関連オブジェクトをキャッシュしないため、ループ内で参照するなど行うとN+1が発生してしまうためそこだけ注意です。
関連を表示で使う(条件なし)の場合はpreload
ユーザー一覧で投稿数を出すなど、画面表示で関連データをそのまま利用する場合はpreloadが最適です。
JOINされても困らず、Railsに自動判定を任せても安全な軽い関連を使う場合はincludes
includesはRailsが内部的にpreloadとeager_loadを自動で切り替えてくれる便利なメソッドです。
ただし、大量データや重い関連を扱う場合にはJOIN化がパフォーマンスの低下につながる可能性があるため、どんな場面でも安易にincludesを使ってよいわけではありません。
JOINに化けても困らない軽い関連とは?
ここで言う「軽い関連」は以下のようなものがあります。
- レコード数が少ない
- JOINしても行が増えずらい
- SELECT対象のカラム数も少ない
- DISTINCTをかませても負荷にならない
まとめ
今回Railsのpreload、include、joinsの違いとそれぞれの特徴など整理をしてみました。
N+1を避ける方法は複数ありますが、最適解は「画面で何をしたいか」によって決まってくると思います。
今回の内容が、Railsで関連を扱う際の判断基準として少しでも参考になれば嬉しいです。
