2
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

ActiveRecordにおけるN+1問題と(preload, eager_load, includes, joins)について

Posted at

はじめに

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等のメソッドを使用します。

検証環境

テーブル間のアソシエーション

Screen Shot 2020-05-20 at 18.28.07.png

各バージョン

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エンジニアな日々

記事を書いてくださった方々に感謝です。
あくまで様々な記事の情報を手を動かしながらまとめただけなので、さらに理解が深めて追加・修正を入れたいと思います。

2
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?