N+1問題を解決するActiveRecordのメソッドの使い分け

はじめに

イタンジ株式会社の開発の越智です。現在、「物件管理くん」というサービスの開発を担当しています。
本記事ではRailsアプリケーションのN+1問題を解消する方法として挙げられる、ActiveRecordのメソッドについてまとめます。

N+1問題とは?

N+1問題とは、ループ処理の中で都度 SQL を発行してしまい、大量の SQL が発行されてパフォーマンスが低下してしまう問題のことです。
これだけだと抽象的すぎるので、具体例を示します。
以下のようなテーブルを考え、companiesテーブル:storesテーブル = 1:多の関係とします。

今回使用するテーブルのER図
以下は、企業(Company)に紐づく店舗(Store)を標準出力するコードです。

companies = Company.all
companies.each { puts _1.stores }

このコードは一見何の問題もないように見えますが、実際には、以下のようなクエリが発行されています。

Company Load (88.8ms)  SELECT `companies`.* FROM `companies`
Store Load (10.5ms)  SELECT `stores`.* FROM `stores` WHERE `stores`.`company_id` = 1
Store Load (1.4ms)  SELECT `stores`.* FROM `stores` WHERE `stores`.`company_id` = 2
Store Load (2.0ms)  SELECT `stores`.* FROM `stores` WHERE `stores`.`company_id` = 3
Store Load (1.0ms)  SELECT `stores`.* FROM `stores` WHERE `stores`.`company_id` = 4
...
...
...

最初に全ての企業をDBから取得するクエリを1回発行し、
次にその企業一つ一つに紐づく店舗群を取得するクエリをN回(企業のレコード数をNとする)発行するので、合計で1 + N回のクエリが発行されています。
このように1 + N回クエリを発行してしまい、パフォーマンスが下がってしまう現象をN+1問題と言います。
(クエリ発行数の順番的に、1+N問題と言われることもあります。)

このようなN+1問題をRailsで解決する方法として、以下のメソッドが挙げられます。

  • preload
  • eager_load
  • includes

これらのメソッドは、eager loadingと呼ばれるrailsの仕組みによって、クエリの発行回数を抑えてくれる便利なメソッドです。 以下では、各メソッドについて述べます。

preload

preloadメソッドは、親テーブルと指定した関連テーブルを取得するクエリを分けて発行し、関連テーブルのデータ配列を取得してキャッシュするメソッドです。 先ほどのコードをpreloadを使って書き換えると、以下のようになります。

companies = Company.preload(:stores)
companies.each { puts _1.stores }

上記のコードによって発行されるクエリは、以下のような感じになります。

Company Load (--ms)  SELECT `companies`.* FROM `companies`
Store Load (--ms)  SELECT `stores`.* FROM `stores` WHERE `stores`.`company_id` IN (1, 2, 3, 4, ...)

クエリの発行数が1+N回から2回になっていて、companiesとstoresのデータをそれぞれ別々のクエリに分けて取得しているのがわかります。
ちなみに、多:多の関係の場合でも取得でき、その際は3回SQLが発行されます。 (詳細は割愛します。)

また、以下のコードのように、preloadした関連先で絞り込みを行った場合は、ActiveRecordのエラークラスがraiseされます。 where句はcompaniesテーブルに対する絞り込みになり、stores.name='店舗A'のレコードをSELECTしようとしているので、そのようなカラムが存在しないのは当然ですね。

Company.preload(:stores).where(stores: { name: '店舗A' }).each { puts _1.stores }
Company Load (--ms)  SELECT `companies`.* FROM `companies` WHERE `stores`.`name` = '店舗A'

ActiveRecord::StatementInvalid: Mysql2::Error: Unknown column 'stores.name' in 'where clause'

最後にpreloadの懸念点について述べます。 preloadは親テーブルのレコード数が多いと、IN句が大きくなってしまいがちなため、DB側でのメモリサイズやSQLのサイズの設定値を気にかけておく必要があります。

eager_load

eager_loadメソッドは、指定されたすべての関連付けをLEFT OUTER JOINで読み込むメソッドです。 preloadと同様に、eager_loadを使って書き換えると、以下のようになります。

companies = Company.eager_load(:stores)
companies.each { puts _1.stores }

上記のコードによって発行されるクエリは、以下のような感じになります。

SQL (--ms)  SELECT `companies`.`id` AS t0_r0, `companies`.`name` AS t0_r1, `companies`.`telephone_number` AS t0_r2, `companies`.`created_at` AS t0_r3, `companies`.`updated_at` AS t0_r4, `stores`.`id` AS t1_r0, `stores`.`company_id` AS t1_r1, `stores`.`name` AS t1_r2, `stores`.`email` AS t1_r3, `stores`.`password_digest` AS t1_r4, `stores`.`created_at` AS t1_r5, `stores`.`updated_at` AS t1_r6 FROM `companies` LEFT OUTER JOIN `stores` ON `stores`.`company_id` = `companies`.`id`

companiesテーブルとstoresテーブルをLEFT OUTER JOINしているので、クエリの発行数が、1+N回から1回になっています。
ちなみに、多:多の関係の場合でも1回のクエリで取得できます。(詳細は割愛します。)

また、preloadとは異なり、以下のようにeager_loadした関連先で絞り込みを行った場合は、関連先の要素で絞り込みを行うことが出来ます。
companiesテーブルとstoresテーブルをLEFT OUTER JOINで結合したものからstores.name='店舗A'のレコードをSELECTしようとしているので、取得できます。

Company.eager_load(:stores).where(stores: { name: '店舗A' }).each { puts _1.stores }
SELECT `companies`.`id` AS t0_r0, `companies`.`name` AS t0_r1, `companies`.`telephone_number` AS t0_r2, `companies`.`created_at` AS t0_r3, `companies`.`updated_at` AS t0_r4, `stores`.`id` AS t1_r0, `stores`.`company_id` AS t1_r1, `stores`.`name` AS t1_r2, `stores`.`email` AS t1_r3, `stores`.`password_digest` AS t1_r4, `stores`.`created_at` AS t1_r5, `stores`.`updated_at` AS t1_r6 FROM `companies` LEFT OUTER JOIN `stores` ON `stores`.`company_id` = `companies`.`id` WHERE `stores`.`name` = '店舗A'

#<Store:0x0000ffff982a2c98>
=> [#<Company:0x0000ffff982a2f90
  id: 1,
  name: "店舗A",
  telephone_number: nil,
  created_at: Sun, 05 Mar 2023 09:11:05.141184000 UTC +00:00,
  updated_at: Sun, 05 Mar 2023 09:11:05.141184000 UTC +00:00>]

最後にeager_loadの懸念点について述べます。 親と子テーブルが1:多多:多の関係の時は、LEFT OUTER JOINを行うと、
SQLが返すレコードは親テーブルについて重複を含んだものを取得することになってしまいます。 このような場合は、eager_loadよりも取得するデータに重複がないpreloadを使用した方が良さそうです。

includes

includesは、条件によって挙動が変わるメソッドで、具体的に言うと、
デフォルトではpreloadと同じ挙動、関連先のテーブルの要素で絞り込みを行った場合などはeager_loadと同じ挙動をします。

preloadと同様に、includesを使って書き換えると、以下のようになります。 preloadと同じ挙動をしているのがわかるかと思います。

companies = Company.includes(:stores)
companies.each { puts _1.stores }

上記のコードによって発行されるクエリは、以下のような感じになります。

Company Load (63.8ms)  SELECT `companies`.* FROM `companies` INNER JOIN `stores` ON `stores`.`company_id` = `companies`.`id` WHERE `stores`.`id` = 1
Store Load (14.7ms)  SELECT `stores`.* FROM `stores` WHERE `stores`.`company_id` = 1

関連先のテーブルの要素で絞り込みを行った場合は、以下のようなクエリを発行し、eager_loadと同じ挙動になっていることがわかります。

Company.includes(:stores).where(stores: { name: '店舗A' }).each { puts _1.stores }
SQL (13.2ms)  SELECT `companies`.`id` AS t0_r0, `companies`.`name` AS t0_r1, `companies`.`telephone_number` AS t0_r2, `companies`.`created_at` AS t0_r3, `companies`.`updated_at` AS t0_r4, `stores`.`id` AS t1_r0, `stores`.`company_id` AS t1_r1, `stores`.`name` AS t1_r2, `stores`.`email` AS t1_r3, `stores`.`password_digest` AS t1_r4, `stores`.`created_at` AS t1_r5, `stores`.`updated_at` AS t1_r6 FROM `companies` LEFT OUTER JOIN `stores` ON `stores`.`company_id` = `companies`.`id` WHERE `stores`.`name` = '店舗A'

上記のメソッドの使い分け

まずincludesメソッドは、
上記で述べたように、条件によって挙動が変わり、パッと見でどのような挙動をするかわかりにくい、という観点から個人的にはあまり使用をお勧めしません。
(チーム内でもあまり使わない方針になっています。)

次に、eager_loadとpreloadの使い分けですが、
基本的に親と子テーブルが1:多や多:多の関係の時はpreloadで、 親と子テーブルが多:1の場合関連先のテーブルの要素で絞り込みを行う場合はeager_loadを用いるのが良さそうです。

最後に

以上が、RailsアプリケーションのN+1問題を解消するメソッドである、preload、eager_load、includesの挙動と使い分けになります。 この記事が誰かの一助になれば幸いです!!

参考文献

railsguides.jp