Development

RailsエンジニアがN+1解決で使用するincludesの解説

ブログ作成者紹介

氏名:D・H
所属:開発部
入社年:2024年4月

解説の流れ

  1. はじめに
  2. 結論
  3. includesメソッドで条件を指定しない場合
  4. includesメソッドで条件を指定する場合
  5. まとめ

はじめに

includesメソッドの内部構造について解説します。 Railsで開発をしていると、N+1問題が発生することがあります。
多くのRailsエンジニアは、この問題を解決するためにincludesメソッドを使用します。
Railsでの開発を始めたばかりの頃は、includesメソッドの動作を十分に理解せずに使っていたため、同じような状況にある方々のために、includesメソッドの動作について解説します。

結論

まず結論から言います。
includesメソッドは、メソッドチェーンにwhereが入るかどうかによって、eager_loadとpreloadを使い分けています。
さらにいうと、whereで条件指定が入ると、eager_loadを使用し、条件指定が入らないとpreloadを使用します。

  • preload: 関連データを個別のSQLクエリでロードします。
  • eager_load: 関連データをテーブル結合(LEFT OUTER JOIN)してロードします。

includesは、eager_loadとpreloadを使い分けてN+1問題を解決してくれます。

説明のために使用するデータベース構成

単純でシンプルな1対1のリレーション構成になります。

includesメソッドで条件を指定しない場合

includesメソッドのみを使用した場合はpreloadメソッドと同じ挙動です。

includes

irb(main):010* User.includes(:profile).each do |user|
irb(main):011*   user.profile.bio
irb(main):012> end
  User Load (5.1ms)  SELECT "users".* FROM "users"
  Profile Load (1.2ms)  SELECT "profiles".* FROM "profiles" WHERE "profiles"."user_id" IN ($1, $2, $3, $4, $5)  [["user_id", 1], ["user_id", 2], ["user_id", 3], ["user_id", 4], ["user_id", 5]]

preload

irb(main):007* User.preload(:profile).each do |user|
irb(main):008*   user.profile.bio
irb(main):009> end
  User Load (4.1ms)  SELECT "users".* FROM "users"
  Profile Load (1.4ms)  SELECT "profiles".* FROM "profiles" WHERE "profiles"."user_id" IN ($1, $2, $3, $4, $5)  [["user_id", 1], ["user_id", 2], ["user_id", 3], ["user_id", 4], ["user_id", 5]]

生成されるSQL

SQLのログの内容を確認していただくと内容が同じなのがわかると思います。 ログだとわかりにくいので以下に記述し直しました。

SELECT "users".* FROM "users";
SELECT "profiles".* FROM "profiles" WHERE "profiles"."user_id" IN (1, 2, 3, 4, 5);

User.includes(:profile)を実行すると、まずusersテーブルから全てのユーザーを取得します。 その後、取得したユーザーのIDを使って、profilesテーブルから関連するプロフィールを取得します。 具体的には、profilesテーブルのuser_idが、最初のクエリで取得したユーザーのIDに一致するレコードを取得します。

おまけ

counメソッドではなくsizeメソッドを使用しないとN+1の対策が無駄になりますので注意してください。 sizeメソッドは、既にロードされている関連オブジェクトの数を返すため、追加のSQLクエリを発行しません。
一方、countメソッドはSQLクエリを発行します。

includesメソッドで条件を指定する場合

includesメソッドはwhereで条件指定があると、LEFT OUTER JOINでテーブルを結合して、関連属性も同時に取得します。
これはeager_loadメソッドと同じ挙動です。 eager_loadは特定の条件がなくてもテーブルを結合します。 もしwhere句が含まれている場合、関連するテーブルを結合することで、条件に一致するデータのみを取得できます。

includes

irb(main):001* User.includes(:profile).where(profiles: {status: "active"}).each do |user|
irb(main):002*   puts user.profile.status
irb(main):003> end
SQL (0.3ms)  SELECT "users"."id" AS t0_r0, "users"."name" AS t0_r1, "users"."created_at" AS t0_r2, "users"."updated_at" AS t0_r3, "profiles"."id" AS t1_r0, "profiles"."bio" AS t1_r1, "profiles"."status" AS t1_r2, "profiles"."user_id" AS t1_r3, "profiles"."created_at" AS t1_r4, "profiles"."updated_at" AS t1_r5 FROM "users" LEFT OUTER JOIN "profiles" ON "profiles"."user_id" = "users"."id" WHERE "profiles"."status" = $1  [["status", 0]]

eager_load

irb(main):004* User.eager_load(:profile).where(profiles: {status: "active"}).each do |user|
irb(main):005*   puts user.profile.status
irb(main):006> end
SQL (4.2ms)  SELECT "users"."id" AS t0_r0, "users"."name" AS t0_r1, "users"."created_at" AS t0_r2, "users"."updated_at" AS t0_r3, "profiles"."id" AS t1_r0, "profiles"."bio" AS t1_r1, "profiles"."status" AS t1_r2, "profiles"."user_id" AS t1_r3, "profiles"."created_at" AS t1_r4, "profiles"."updated_at" AS t1_r5 FROM "users" LEFT OUTER JOIN "profiles" ON "profiles"."user_id" = "users"."id" WHERE "profiles"."status" = $1  [["status", 0]]

生成されるSQL

SQLのログの内容を確認していただくと内容が同じなのがわかると思います。 ログだとわかりにくいので以下に記述し直しました。 テーブルを結合して、自身と関連づけられた属性をまとめて取得しているのがわかります。 まとめて取得していることで無駄なSQLが発行されないようにしています。

SELECT "users"."id", "users"."name", "users"."created_at", "users"."updated_at",
       "profiles"."id", "profiles"."bio", "profiles"."status", "profiles"."user_id",
       "profiles"."created_at", "profiles"."updated_at"
FROM "users"
LEFT OUTER JOIN "profiles" ON "profiles"."user_id" = "users"."id"
WHERE "profiles"."status" = 'active';

注意

joinsメソッドやleft_outer_joinsメソッドというテーブル結合するだけのメソッドもありますが、こちらには関連テーブルの属性は含まれないのでN+1の解決にはなりません。
下のコードを見るととuserは全てのカラムを取得していますが、profileは属性が取得されていないことがわかります。
ただ、関連データの属性値でフィルタリングしたいだけの際はN+1を考慮する必要がない場合があります。
その際は関連属性をロードしないほうが処理が軽くなるので注意してください。

irb(main):020> User.joins(:profile).where(profile: {status:  "active"})
  User Load (4.8ms)  SELECT "users".* FROM "users" INNER JOIN "profiles" "profile" ON "profile"."user_id" = "users"."id" WHERE "profile"."status" = $1 /* loading for pp */ LIMIT $2  [["status", 0], ["LIMIT", 11]]

irb(main):021> User.left_outer_joins(:profile).where(profile: {status:  "active"})
  User Load (2.8ms)  SELECT "users".* FROM "users" LEFT OUTER JOIN "profiles" "profile" ON "profile"."user_id" = "users"."id" WHERE "profile"."status" = $1 /* loading for pp */ LIMIT $2  [["status", 0], ["LIMIT", 11]]

まとめ

一番大切なのは内部でどのようなSQLで構成されているのかをしっかりと把握することです。
SQLの内容を把握できていればプロジェクトのルールを優先してincludes、preload、eager_loadを使い分けて使用すれば良いと思います。

参考

https://railsguides.jp/active_record_querying.html#関連付けをeager-loadingする

関連記事

%d人のブロガーが「いいね」をつけました。