🦧

N+1問題の複数の正しい解決方法

2021/04/21に公開

前提

N+1問題の解決としてbullet入れてメッセージ通りにincludesでなんとなく回避していましたが、データ量が増えるにつれパフォーマンスが低下する恐れがあるため今のうちに改善したいと考え本記事を書きました。

preload, eager_load, includes共通点とそれぞれの違い

いずれのメソッドもメモリ上でアソシエーション先のデータを保持しておくという方法を採用しています。データ量が多くなるとメモリを圧迫することになりますが、その際はfind_eachなどでDBを分割して取り出す必要があります。

以下、Storeモデルと1:Nの関係にあるProductモデルを例とします。

preload

Store.preload(:products)

発行されるSQL
# Store Load (3.0ms)  SELECT `stores`.* FROM `stores` LIMIT 11
# Product Load (1.2ms)  SELECT `products`.* FROM `products` WHERE `products`.`store_id` IN (1, 2, 3, 4, 6)

指定したアソシエーションに基づきSQLを複数に分け、データを取得しています。

発行されたSQLを見ると、
まずStoreのレコードを取得し、それらのidがstore_idと一致するレコードをProductテーブルより取得しています。
後述しますがアソシエーション先のカラムで絞り込みを行うことはできません。

eager_load

Store.eager_load(:products)

発行されるSQL
# SQL (2.2ms)  SELECT DISTINCT `stores`.`id` FROM `stores` LEFT OUTER JOIN `products` ON `products`.`store_id` = `stores`.`id` LIMIT 11

# SQL (4.5ms)  SELECT `stores`.`id` AS t0_r0, `stores`.`email` AS t0_r1, `stores`.`encrypted_password` AS t0_r2, `stores`.`storename` AS t0_r3, `stores`.`reset_password_token` AS t0_r4, `stores`.`reset_password_sent_at` AS t0_r5, `stores`.`remember_created_at` AS t0_r6, `stores`.`confirmation_token` AS t0_r7, `stores`.`confirmed_at` AS t0_r8, `stores`.`confirmation_sent_at` AS t0_r9, `stores`.`unconfirmed_email` AS t0_r10, `stores`.`created_at` AS t0_r11, `stores`.`updated_at` AS t0_r12, `stores`.`description` AS t0_r13, `products`.`id` AS t1_r0, `products`.`name` AS t1_r1, `products`.`price` AS t1_r2, `products`.`store_id` AS t1_r3, `products`.`created_at` AS t1_r4, `products`.`updated_at` AS t1_r5, `products`.`category_id` AS t1_r6, `products`.`description` AS t1_r7, `products`.`reviews_count` AS t1_r8, `products`.`stars_average` AS t1_r9 FROM `stores` LEFT OUTER JOIN `products` ON `products`.`store_id` = `stores`.`id` WHERE `stores`.`id` IN (3, 1, 2, 4, 6)

LEFT OUTER JOINによってデータが取り出されキャッシュされるので、1対NやN対Nの場合アソシエーション先のテーブル数に応じて同じだけレコードが取得されるのでその分速度が遅くなる可能性があります。
一方で、1対1やN対1の場合は無駄なテーブルの取得が起こらないのでSQLの発行回数が少ない分preloadよりもパフォーマンスが良いです。

また、LEFT OUTER JOINでは両テーブルのカラムが取得されるのでアソシエーション先のカラムで絞り込みを行うことが可能です。

Store.eager_load(:products).where(products: {name: "テストケーキ_0"})

# SQL (1.5ms)  SELECT DISTINCT `stores`.`id` FROM `stores` LEFT OUTER JOIN `products` ON `products`.`store_id` = `stores`.`id` WHERE `products`.`name` = 'テストケーキ_0' LIMIT 11
# SQL (2.9ms)  SELECT `stores`.`id` AS t0_r0, `stores`.`email` AS t0_r1, `stores`.`encrypted_password` AS t0_r2, `stores`.`storename` AS t0_r3, `stores`.`reset_password_token` AS t0_r4, `stores`.`reset_password_sent_at` AS t0_r5, `stores`.`remember_created_at` AS t0_r6, `stores`.`confirmation_token` AS t0_r7, `stores`.`confirmed_at` AS t0_r8, `stores`.`confirmation_sent_at` AS t0_r9, `stores`.`unconfirmed_email` AS t0_r10, `stores`.`created_at` AS t0_r11, `stores`.`updated_at` AS t0_r12, `stores`.`description` AS t0_r13, `products`.`id` AS t1_r0, `products`.`name` AS t1_r1, `products`.`price` AS t1_r2, `products`.`store_id` AS t1_r3, `products`.`created_at` AS t1_r4, `products`.`updated_at` AS t1_r5, `products`.`category_id` AS t1_r6, `products`.`description` AS t1_r7, `products`.`reviews_count` AS t1_r8, `products`.`stars_average` AS t1_r9 FROM `stores` LEFT OUTER JOIN `products` ON `products`.`store_id` = `stores`.`id` WHERE `products`.`name` = 'テストケーキ_0' AND `stores`.`id` = 1


Store.preload(:products).where(products: {name: "テストケーキ_0"})
# preloadの場合はエラーが発生する
# Store Load (6.1ms)  SELECT `stores`.* FROM `stores` WHERE `products`.`name` = 'テストケーキ_0' LIMIT 11
Traceback (most recent call last):
ActiveRecord::StatementInvalid (Mysql2::Error: Unknown column 'products.name' in 'where clause')

includes

includesはアソシエーション先のカラムで絞り込みを行う場合はeager_load、そうでない場合はpreloadとなります。
一見良いとこどりのように思えますが、N対1や1:1の時にSQLの発行回数が増えてしまうのでパフォーマンス低下を引き起こすことがあります。
例)Userモデルに対してhas_oneで紐づくProfileモデル

User.eager_load(:profile)
=>
# SQL (0.9ms)  SELECT `users`.`id` AS t0_r0, `users`.`email` AS t0_r1, `users`.`encrypted_password` AS t0_r2, `users`.`reset_password_token` AS t0_r3, `users`.`reset_password_sent_at` AS t0_r4, `users`.`remember_created_at` AS t0_r5, `users`.`confirmation_token` AS t0_r6, `users`.`confirmed_at` AS t0_r7, `users`.`confirmation_sent_at` AS t0_r8, `users`.`unconfirmed_email` AS t0_r9, `users`.`created_at` AS t0_r10, `users`.`updated_at` AS t0_r11, `users`.`username` AS t0_r12, `users`.`uid` AS t0_r13, `users`.`provider` AS t0_r14, `profiles`.`id` AS t1_r0, `profiles`.`user_id` AS t1_r1, `profiles`.`name` AS t1_r2, `profiles`.`created_at` AS t1_r3, `profiles`.`updated_at` AS t1_r4, `profiles`.`money` AS t1_r5 FROM `users` LEFT OUTER JOIN `profiles` ON `profiles`.`user_id` = `users`.`id` LIMIT 11

User.includes(:profile)
=>
# User Load (1.2ms)  SELECT `users`.* FROM `users` LIMIT 11
# Profile Load (1.1ms)  SELECT `profiles`.* FROM `profiles` WHERE `profiles`.`user_id` IN (1, 2, 3)

結論

1:N、N:Nの場合はpreload
1:1,N:1の場合、またはアソシエーション先のデータを参照している場合はeager_load
includesは使わない方が吉

参考

ActiveRecordのincludesは使わずにpreloadとeager_loadを使い分ける理由
Active Record クエリインターフェイス

Discussion