はじめに
ActiveRecordで検索が複数テーブルにまたがる際にN+1問題を解決するために、よくincludes
が使われている記事を見てそれを参考にしていましたがあんまり良くないという話を聞き色々と調べた結果をまとめた備忘録です。
N+1問題について
ループ処理の中で都度SQLを発行してしまい、大量のSQLが発行されてパフォーマンスが低下する問題のことを指します。
ActiveRecordなどOR Mapperを使用している際に発生しがちです。
なぜ起こるのか:Lazy loading
遅延読み込みともいい ActiveRecord ではこれがデフォルトです。関連するテーブルが必要になった時にクエリを発行して必要な値を取り出します。
メモリを確保する量は少なくてすみますが、関連するテーブルを使うごとにSQLが発行されることになり動作が重くなります。
例えば何かの一覧を作成しているときに、
- 一覧に表示されるデータを取得する -> SELECTを一回実行(N個のレコードを取得)
- レコードそれぞれの関連データの取得 -> SELECTをN回実行
という動作で合計N+1のクエリを実行することになるので、Nが増えれば増えるほどパフォーマンスが悪くなります。
解決策:Eager loading
一括読み込みともいいます。
予め関連するテーブルを全てメモリ上に確保してしまうことを言います。
クエリが少なくて済むため描写が高速になりますが、関連するテーブルも全てメモリ上にあるのでその分のメモリを消費します。
件数絞り込みをした上で使用するのが良さそうです。
preload
, eager_load
, includes
等のメソッドを使用します。
検証環境
テーブル間のアソシエーション
各バージョン
Ruby | Rails(ActiveRecord) | MySQL |
---|---|---|
2.7.1 | 6.0.3.1 | 5.6 |
joins
User.joins(:posts).where(posts: { id: 1 })
# User Load (2.4ms) SELECT `users`.* FROM `users` INNER JOIN `posts` ON `posts`.`user_id` = `users`.`id` WHERE `posts`.`id` = 1 LIMIT 11
Post.joins(:user).where(users: {name: 'user1' })
# Post Load (2.6ms) SELECT `posts`.* FROM `posts` INNER JOIN `users` ON `users`.`id` = `posts`.`user_id` WHERE `users`.`name` = 'user1' LIMIT 11
INNER JOINでテーブルを結合します。LEFT OUTER JOINを行いたい場合はleft_joins
を使います。
単にJOIN句のクエリを作成するのでアソシエーションはキャッシュしないのでN+1問題が起こりますが、その分メモリ消費を抑えることができます。
なのでJOINしたテーブルのデータを使わない場合において、データの絞り込み(where
, order
)にはjoins
が推奨されています。
preload
User.preload(:posts)
# User Load (2.8ms) SELECT `users`.* FROM `users` LIMIT 11
# Post Load (2.3ms) SELECT `posts`.* FROM `posts` WHERE `posts`.`user_id` IN (1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11)
Post.preload(:user)
# Post Load (3.0ms) SELECT `posts`.* FROM `posts` LIMIT 11
# User Load (1.9ms) SELECT `users`.* FROM `users` WHERE `users`.`id` IN (28, 22, 40, 5, 41, 1, 23, 11, 34, 47, 15)
指定した関連テーブルごとにクエリを実行して取得してキャッシュします。
JOINしてテーブル結合をするわけではないので、preload
したテーブルでの絞り込みは使えず例外を投げます。
複数のアソシエーションをEager loadingするときや大きなテーブル等を扱う等JOINしたくないときに使うのが良さそうです。
クエリの中でIN句がありますが、主テーブルにレコード数が多い場合IN句が膨らんでいきMySQLなどデータベース側でのエラーを引き起こす可能性があるのでページネーション等で件数の絞り込みをした方が良さそうです。
eager_load
User.eager_load(:posts)
# SQL (2.5ms) SELECT DISTINCT `users`.`id` FROM `users` LEFT OUTER JOIN `posts` ON `posts`.`user_id` = `users`.`id` LIMIT 11
# SQL (3.2ms) 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`.`content` AS t1_r1, `posts`.`user_id` AS t1_r2, `posts`.`created_at` AS t1_r3, `posts`.`updated_at` AS t1_r4 FROM `users` LEFT OUTER JOIN `posts` ON `posts`.`user_id` = `users`.`id` WHERE `users`.`id` IN (1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11)
User.eager_load(:posts).where(posts: { id: 1 })
# SQL (2.5ms) SELECT DISTINCT `users`.`id` FROM `users` LEFT OUTER JOIN `posts` ON `posts`.`user_id` = `users`.`id` WHERE `posts`.`id` = 1 LIMIT 11
# SQL (1.7ms) 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`.`content` AS t1_r1, `posts`.`user_id` AS t1_r2, `posts`.`created_at` AS t1_r3, `posts`.`updated_at` AS t1_r4 FROM `users` LEFT OUTER JOIN `posts` ON `posts`.`user_id` = `users`.`id` WHERE `posts`.`id` = 1 AND `users`.`id` = 10
Post.eager_load(:user)
# SQL (1.9ms) SELECT `posts`.`id` AS t0_r0, `posts`.`content` AS t0_r1, `posts`.`user_id` AS t0_r2, `posts`.`created_at` AS t0_r3, `posts`.`updated_at` AS t0_r4, `users`.`id` AS t1_r0, `users`.`name` AS t1_r1, `users`.`created_at` AS t1_r2, `users`.`updated_at` AS t1_r3 FROM `posts` LEFT OUTER JOIN `users` ON `users`.`id` = `posts`.`user_id` LIMIT 11
Post.eager_load(:user).where(users: { name: 'user1' })
# SQL (2.3ms) SELECT `posts`.`id` AS t0_r0, `posts`.`content` AS t0_r1, `posts`.`user_id` AS t0_r2, `posts`.`created_at` AS t0_r3, `posts`.`updated_at` AS t0_r4, `users`.`id` AS t1_r0, `users`.`name` AS t1_r1, `users`.`created_at` AS t1_r2, `users`.`updated_at` AS t1_r3 FROM `posts` LEFT OUTER JOIN `users` ON `users`.`id` = `posts`.`user_id` WHERE `users`.`name` = 'user1' LIMIT 11
LEFT OUTER JOINで指定したデータを結合して取得しキャッシュします。
テーブルを結合しているのでeager_load
したテーブルの要素でデータの絞り込みができます。
1対N関連のテーブルをLEFT JOINしたSQLが返すレコードは重複を含んだものになってくるため絞り込みが難しい形となり、それを防ぐためにdistinctをつけたクエリを発行することで、絞り込み対象のidリストを取得しています。しかしこのdistinctのSQLがスロークエリになりやすいようなので1対Nのアソシエーションに使うのは向きません。
includes
User.includes(:posts)
# User Load (3.1ms) SELECT `users`.* FROM `users` LIMIT 11
# Post Load (3.3ms) SELECT `posts`.* FROM `posts` WHERE `posts`.`user_id` IN (1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11)
User.includes(:posts).where(posts: { id: 1 })
# SQL (1.9ms) SELECT DISTINCT `users`.`id` FROM `users` LEFT OUTER JOIN `posts` ON `posts`.`user_id` = `users`.`id` WHERE `posts`.`id` = 1 LIMIT 11
# SQL (3.0ms) 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`.`content` AS t1_r1, `posts`.`user_id` AS t1_r2, `posts`.`created_at` AS t1_r3, `posts`.`updated_at` AS t1_r4 FROM `users` LEFT OUTER JOIN `posts` ON `posts`.`user_id` = `users`.`id` WHERE `posts`.`id` = 1 AND `users`.`id` = 28
Post.includes(:user)
# Post Load (2.6ms) SELECT `posts`.* FROM `posts` LIMIT 11
# User Load (1.8ms) SELECT `users`.* FROM `users` WHERE `users`.`id` IN (28, 22, 40, 5, 41, 1, 23, 11, 34, 47, 15)
Post.includes(:user).where(users: { name: 'user1' })
# SQL (2.6ms) SELECT `posts`.`id` AS t0_r0, `posts`.`content` AS t0_r1, `posts`.`user_id` AS t0_r2, `posts`.`created_at` AS t0_r3, `posts`.`updated_at` AS t0_r4, `users`.`id` AS t1_r0, `users`.`name` AS t1_r1, `users`.`created_at` AS t1_r2, `users`.`updated_at` AS t1_r3 FROM `posts` LEFT OUTER JOIN `users` ON `users`.`id` = `posts`.`user_id` WHERE `users`.`name` = 'user1' LIMIT 11
デフォルトで preload
として機能し、where
など絞り込みがある場合、eager_load
と同じ挙動をします。
複数アソシエーションを指定した場合にはアソシエーションごとに別々の挙動を取ることはなく、必ず全てのアソシエーションが preload
されるかeager_load
されるかの挙動になります。
まとめ
メソッド | キャッシュ | クエリ | アソシエーション先のデータ参照 |
---|---|---|---|
joins | しない | INNER JOIN | できる |
eager_load | する | LEFT OURTER JOIN | できない |
preload | する | それぞれSELECT | できる |
inclides | する | 場合による | できる |
基本的にinclude
はクエリが制御しづらいのであまり使わないようにして、1対1 (belong_to), N対1 (has_one) アソシエーションについてはLEFT JOINでまとめて取得したほうが効率的だと思うのでeager_load
を使い、has_many (1対N) の場合のアソシエーションに関してはpreload
を使用するとのが良さそうです。
参考にした文献
Active Record Query Interface — Ruby on Rails Guides
Active Record Query Interface — Ruby on Rails Guides
ActiveRecordのjoinsとpreloadとincludesとeager_loadの違い - Qiita
[Improving Database performance and overcoming common N+1 issues in Active Record using includes, preload, eager_load, pluck, select, exists? | Saeloun Blog] (https://blog.saeloun.com/2020/01/08/activerecord-database-performance-n-1-includes-preload-eager-load-pluck)
ORM の eager loading と lazy loadingについて|withnicのWebエンジニアな日々
記事を書いてくださった方々に感謝です。
あくまで様々な記事の情報を手を動かしながらまとめただけなので、さらに理解が深めて追加・修正を入れたいと思います。