Laravel・PHP開発

LaravelのN+1問題を確実に防ぐ:Lazy Loading制限の導入と本番運用の設計

Laravelで一覧画面を表示したとき、親モデルの取得後に関連モデルへのクエリが大量に発行されることがある。たとえば、投稿を1回取得した後、各投稿の著者を参照するたびに著者取得のクエリが実行されるケースである。…

LaravelのN+1問題を確実に防ぐ:Lazy Loading制限の導入と本番運用の設計

投稿が100件あれば、クエリ数は最低でも「投稿一覧の1回」と「著者取得の100回」で合計101回になる。これがEloquentにおけるN+1問題である。

原因は、関連データのLazy Loading、つまり遅延読み込みにある。Laravelのコードは短く書ける。しかし、短いコードが少ないクエリで実行されるとは限らない。N+1問題は、実装時の見た目では発見しにくく、データ量が増えた段階で初めてボトルネックとして現れる。

Laravel 8.43.0では、Lazy Loadingを検知するためのModel::preventLazyLoading()が追加された。開発環境とテスト環境でこの機能を有効にすれば、N+1問題を画面表示や負荷試験の後で推測する必要がなくなる。関連データの読み込み漏れを、例外としてその場で検知できるためである。

LaravelにおけるN+1問題とLazy Loadingの仕組み

Eloquentのリレーションは、属性へアクセスした時点で読み込める。たとえば、Postモデルにauthor()というリレーションが定義されている場合、テンプレート内で$post->authorを参照すると、著者データが未取得ならデータベースへ問い合わせる。

この動作が単一のモデルに対して行われる限り、問題は目立たない。問題になるのは、複数のモデルをループ処理する場合である。

投稿一覧を取得する処理がPost::query()->get()だけなら、発行されるクエリは投稿取得の1回である。その後、テンプレートで各投稿のauthorへアクセスすると、著者が事前に取得されていないため、投稿ごとに関連データのクエリが発行される。

クエリの構造は次のようになる。

1. 投稿一覧を取得するクエリを1回発行する。

2. 1件目の投稿について著者を取得する。

3. 2件目の投稿について著者を取得する。

4. 投稿の件数分だけ著者取得を繰り返す。

親データの件数をNとすると、クエリ数は1 + Nになる。これがN+1問題の名前の由来である。

Lazy Loadingは便利だが、実行計画が見えにくい

Lazy Loadingの利点は、関連データを必要な箇所で取得できる点にある。すべての関連を常に読み込む必要がないため、単純な処理では記述量が減る。

一方で、データベースアクセスがモデル属性へのアクセスに隠れる。コントローラーから見れば、一覧取得は1回のget()で終わっているように見える。しかし、ビューやリソースクラス、シリアライザーが関連属性を参照すると、そこで追加クエリが発生する。

この隠蔽が、N+1問題の検出を難しくする。

特に次のような構成では、発生箇所が分散する。

  • コントローラーでモデルを取得する。

-リソースクラスで関連モデルを参照する。

  • ビューで別の関連を参照する。
  • APIレスポンスの変換処理でさらに属性へアクセスする。
  • 共通のアクセサが内部でリレーションを呼び出す。

モデル取得側だけを確認しても、実際のクエリ数は判断できない。SQLログを確認しない限り、Lazy Loadingによる追加アクセスを見落とす可能性がある。

N+1問題はデータ量に比例して悪化する

N+1問題の厄介な点は、少量のデータでは正常に見えることである。投稿が数件であれば、追加クエリが発生していても画面の応答時間に大きな変化が出ない場合がある。

しかし、一覧件数が増えると、関連取得の回数も増える。親モデルが100件なら関連取得は最大100回、1000件なら最大1000回になる。1回のクエリが高速でも、接続処理、SQL実行、結果取得、モデル生成が積み重なる。

データベースの処理時間だけでなく、アプリケーションとデータベース間の通信回数も増える。したがって、N+1問題は単純なSQLの遅さとは異なる。クエリの計算量と発行回数がボトルネックになる問題である。

N+1問題の本質は、遅いクエリが1本あることではない。不要なクエリを、データ件数に応じて増やしていることである。

preventLazyLoadingによるN+1問題の自動検知

Laravel 8.43.0以降では、EloquentモデルのLazy Loadingを制限できる。利用するメソッドはModel::preventLazyLoading()である。

この設定を有効にすると、ロードされていないリレーションへアクセスしたときにIlluminate\Database\LazyLoadingViolationExceptionが発生する。つまり、関連データの読み込み漏れを、SQLログの確認や本番監視より前に検知できる。

設定場所は通常、AppServiceProviderboot()メソッドである。アプリケーション全体のEloquentモデルに適用されるため、モデルごとに個別設定する必要はない。

実運用では、次の条件付き設定を使う。

Model::preventLazyLoading(! app()->isProduction());

この式は、本番環境ではない場合にtrueを渡す。ローカル環境、テスト環境、ステージング環境ではLazy Loadingを制限し、本番環境では制限しない構成である。

例外によって検知できる範囲

Lazy Loading制限を有効にした状態で、事前読み込みされていないリレーションへアクセスすると例外が発生する。これにより、次のような実装を検出できる。

  • 一覧取得後に、ループ内で関連モデルを参照している。
  • APIリソースが、取得元で読み込んでいないリレーションを参照している。
  • ビューの条件分岐の中で、未取得の関連を参照している。
  • アクセサや変換処理が内部的にリレーションを呼び出している。
  • テストデータが少ないため、これまで問題として表面化していなかった処理。

検知対象は、SQLの実行時間ではない。Lazy Loadingが発生した事実である。したがって、例外が出なかったからといって、クエリ全体が最適化されているとは限らない。

反対に、例外が出た箇所は、少なくとも読み込み戦略を明示すべき箇所である。with()load()、またはモデルの常時読み込みを検討する起点になる。

設定を追加するだけではN+1問題は解決しない

preventLazyLoading()は、クエリを自動的にまとめる機能ではない。Lazy Loadingを検知し、例外を投げる機能である。

例外が発生した後に、開発者がEager Loadingへ修正する必要がある。ここを誤ると、設定を追加しただけで性能改善が完了したと判断してしまう。

処理の流れは次の通りである。

1. 開発環境でLazy Loading制限を有効にする。

2. 画面表示やテストでLazyLoadingViolationExceptionを発生させる。

3. どのリレーションが未取得だったかを確認する。

4. データ取得時にwith()を追加する。

5. 取得済みモデルに対して必要な場合はload()を使う。

6. 同じ処理を再実行し、例外が消えたことを確認する。

7. クエリ数と取得データ量を確認する。

この順序であれば、検知と修正を分離できる。最初からすべての関連を読み込むのではなく、実際のアクセス箇所に応じて読み込み戦略を決定できる。

本番環境を保護する条件付き設定

Lazy Loadingを本番環境でも常に禁止する設計には、注意が必要である。

本番で未検知のアクセス箇所が残っていた場合、リレーション参照だけで例外が発生する。画面やAPIが500エラーになる可能性があるためである。

N+1問題は確かに性能上の問題である。しかし、本番リクエストを例外で停止させる設定は、別の障害を作る。性能改善と可用性のトレードオフを分けて考える必要がある。

開発・テスト環境での強制検知

ローカル環境では、例外をそのまま表示できる。開発者が修正対象をすぐ確認できるため、Lazy Loading制限との相性がよい。

テスト環境でも同様である。機能テストやAPIテストが実行される際に未取得リレーションへアクセスすれば、テストが失敗する。画面を手動で確認しなくても、N+1の混入を検知できる。

ただし、テストが対象画面やAPIを通過していなければ、例外は発生しない。preventLazyLoading()は実行されたコードに対して働く仕組みである。未実行の処理まで静的に解析する機能ではない。

そのため、テストの網羅範囲も重要になる。特に一覧API、管理画面、検索結果、関連データを含むレスポンスは、明示的にテスト対象へ含める必要がある。

ステージング環境での扱い

ステージング環境は、本番に近いデータ量と構成で検証する場所である。N+1問題を検知する目的では、Lazy Loading制限を有効にする価値が高い。

本番環境と同じ条件で動かすことだけを優先すると、ステージングで問題を見逃す可能性がある。ステージングでは、エラーを許容してでも実装上の欠陥を表面化させる方が、リリース前検証としては合理的である。

ただし、バッチ処理や外部連携など、既存コードが多くのモデルに依存している場合は、いきなり全体へ適用すると修正範囲が広がる。導入時は、テスト対象と主要な画面から適用する方法もある。

本番環境で無効化する理由

本番環境でpreventLazyLoading(true)を固定する構成は、N+1問題を確実に失敗させる。しかし、未検知のコードが存在する限り、障害の原因にもなる。

特に次のようなコードは見落とされやすい。

  • 例外処理の中でのみ呼ばれるリレーション参照。
  • 特定の権限を持つユーザーだけが利用する管理画面。
  • データ件数が特定条件を超えたときだけ実行される処理。
  • 非同期ジョブやコマンド処理に含まれるモデルアクセス。
  • APIレスポンスの条件分岐でのみ利用される関連データ。

このため、標準的な導入方針は「本番では停止させず、開発・テスト・ステージングで検知する」である。性能問題を防ぐための設定が、可用性を壊す設定になってはならない。

shouldBeStrictでEloquentの暗黙動作を減らす

Laravelには、Lazy Loading以外のEloquentの暗黙動作も制限する仕組みがある。Model::shouldBeStrict()を使うと、Eloquentの厳格化設定をまとめて有効にできる。

ファクターは3つである。

  • Lazy Loadingの制限。
  • 未登録属性の代入制限。
  • 存在しない属性へのアクセス制限。

Eloquentは柔軟である。存在しない属性や定義されていない入力を扱う場面でも、設定によっては処理が進むことがある。この柔軟性は開発速度に寄与する一方、タイプミスや設計漏れを見逃す要因になる。

厳格化設定を有効にすると、暗黙に許容されていた動作が例外として現れる。エラーの数は一時的に増える。しかし、問題が発生した位置を早く特定できる。

preventLazyLoadingとshouldBeStrictの選択

Lazy Loadingだけを対象にするなら、Model::preventLazyLoading()が適している。既存アプリケーションへ段階的に導入する場合も、影響範囲を絞りやすい。

Eloquent全体の暗黙動作を見直すなら、Model::shouldBeStrict()を検討する。こちらはLazy Loading以外の挙動にも影響するため、既存コードとの互換性を確認しながら導入する必要がある。

導入の判断は、次のように整理できる。

設定主な検知対象導入時の影響適した段階
preventLazyLoading()未取得リレーションへのアクセスN+1候補を例外として検知まずLazy Loadingを制御したい場合
shouldBeStrict()Lazy Loading、属性代入、属性アクセスEloquentの暗黙動作全体を厳格化新規開発や既存コードの整理
条件付き設定非本番環境だけで制限本番リクエストへの影響を抑制多くの運用環境で現実的

どちらを使う場合でも、本番環境を条件から除外する設計が基本になる。全環境で一律に厳格化するのではなく、検知する環境とサービス提供する環境を分けるべきである。

既存アプリケーションへの導入順序

既存プロジェクトでいきなりshouldBeStrict()を有効にすると、複数の種類の例外が同時に発生する可能性がある。原因の切り分けが難しくなる。

段階的に導入するなら、次の順序が扱いやすい。

1. 主要な一覧画面やAPIのクエリを確認する。

2. preventLazyLoading()だけを非本番環境で有効にする。

3. 発生した例外を、with()またはload()で修正する。

4. 主要なテストが通る状態を作る。

5. 未定義属性や属性代入の問題を整理する。

6. 必要に応じてshouldBeStrict()へ移行する。

この順序では、N+1問題と属性関連の問題を別々に扱える。修正の粒度を保ちやすい。

検知されたN+1問題をEager Loadingで修正する

Lazy Loading制限で例外が発生したら、次に読み込み方法を修正する。代表的な方法は、取得時のEager Loadingと、取得後の追加読み込みである。

クエリ発行時にwith()を使う

取得時に関連モデルが必要であることが分かっているなら、with()を使う。

投稿と著者を同時に利用する処理では、Post::with('author')->get()のように関連を指定する。これにより、投稿を取得した後で各投稿の著者を個別に取得するのではなく、最初から必要な関連を読み込む構成になる。

ここで重要なのは、with()を使えば必ず最適なクエリになるという意味ではない。関連データをどこまで取得するかは、レスポンスの仕様とデータ量で決まる。

著者の全カラムが不要で、名前だけ必要であれば、取得するカラムを絞る設計を検討する。ただし、Eloquentのリレーションを正しく紐付けるために必要なキーまで除外すると、関連解決に問題が起きる。カラム制限は、主キーや外部キーの扱いを確認した上で行うべきである。

取得後にload()を使う

モデルを取得した後、処理の条件によって関連が必要になる場合はload()を使う。

たとえば、最初の処理では投稿だけを扱い、特定の条件を満たした場合だけ著者情報を使う構成である。この場合、最初から常に著者を取得するより、必要になった時点で取得済みモデルへload('author')を適用する方が意図に合う。

load()は、すでに取得されたモデル集合に対して関連を追加で読み込むための方法である。ループ内で$post->authorへアクセスするのではなく、ループに入る前に関連を読み込む点が重要になる。

悪い構造は、次のような形である。

  • 投稿一覧を取得する。
  • ループを開始する。
  • 各投稿の中で著者へアクセスする。
  • 著者が未取得なら、投稿ごとにクエリが発行される。

修正後は、次の流れになる。

  • 投稿一覧を取得する。
  • 必要な条件を判定する。
  • ループの前にload('author')を呼び出す。
  • ループでは、すでに読み込まれた著者を参照する。

取得前に必要性が分かるならwith()、取得後に条件が決まるならload()である。この区別をつけると、読み込み責務が明確になる。

ネストしたリレーションを扱う

関連の関連を参照する場合は、ネストしたリレーションも明示する必要がある。

投稿から著者を参照し、著者から所属組織を参照する処理なら、投稿だけをwith('author')しても、著者の組織まで事前に読み込まれるとは限らない。必要な階層をwith('author.organization')のように指定する。

ネストしたリレーションでは、読み込み対象が増える。不要な階層まで常に取得すると、クエリ数ではなく結果データ量がボトルネックになる可能性がある。

つまり、N+1を避ける目的で関連を無制限に追加するのは適切ではない。レスポンスや画面が実際に使う階層だけを読み込む設計が必要である。

条件付きのEager Loading

関連モデルを取得する場合でも、すべての関連行が必要とは限らない。状態や権限、公開条件によって対象を絞ることがある。

この場合は、with()に条件を持たせる設計を検討する。ただし、親モデルの一覧と関連モデルの条件が一致しない場合、画面上の意味が変わる可能性がある。

たとえば、公開済みの著者だけを読み込む場合、著者が存在しない投稿として扱われる投稿が発生する。これはクエリの問題ではなく、データ仕様の問題である。

Eager Loadingの修正では、単に例外を消すのではなく、次の点を確認する必要がある。

  • 関連が存在しない場合の表示。
  • 関連データの公開条件。
  • 権限による表示差分。
  • APIレスポンスに含める属性。
  • ページネーションとの組み合わせ。
  • ネストした関連の取得範囲。

N+1の修正は、データ取得の最適化であると同時に、レスポンス仕様の整理でもある。

モデルの$withを使う場合の判断

特定のモデルを取得するたびに、必ず同じリレーションが必要になる場合は、モデル内にprotected $with = ['relationName'];を定義できる。

この設定を持つモデルは、取得時に指定されたリレーションを常にEager Loadingする。呼び出し側で毎回with()を書く必要がなくなる。

たとえば、画面とAPIのほぼすべてで著者情報を使う場合、モデル側に常時読み込みを定義する設計には合理性がある。呼び出し側の指定漏れも減る。

一方で、常時読み込みにはコストがある。著者を使わない処理でも関連データを取得するためである。管理用バッチや集計処理など、取得件数が多く関連を使わない処理では、不要な読み込みになる可能性がある。

$withが適するケース

$withが適するのは、関連がモデルの基本情報として扱われる場合である。

  • どの画面でも表示する識別情報。
  • APIの基本レスポンスに必ず含まれる関連。
  • 取得漏れが仕様上許容されない関連。
  • 読み込み対象が小さく、追加コストが限定的な関連。

ただし、関連データのサイズが大きい場合は慎重に扱う必要がある。常時読み込みはN+1を防ぐが、すべての取得処理へ追加の負荷を持ち込む。

呼び出し側のwith()が適するケース

with()が適するのは、画面やAPIごとに必要な関連が異なる場合である。

記事詳細では著者とタグが必要だが、管理画面の一覧では著者名だけ必要ということがある。この場合、モデルにすべての関連を常時設定すると、取得量が過剰になる。

クエリ側で必要な関連を指定すれば、ユースケースごとにデータ取得を制御できる。責務は増えるが、性能と可読性のバランスを取りやすい。

判断基準は次の通りである。

  • 常に必要ならモデルの$with
  • 画面や処理によって異なるならwith()
  • 条件付きで必要ならload()
  • 大量データを扱う処理では常時読み込みを避ける。

APIリソースとビューに潜むN+1

N+1問題はコントローラーだけを確認しても不十分である。APIリソースやビューが、未取得のリレーションを参照することがある。

APIリソースでauthorの情報を返しているなら、リソースへモデルを渡す前に著者を読み込むべきである。リソース側で暗黙にLazy Loadingさせると、データ取得の責務がレスポンス変換層へ移る。

ビューでも同様である。テンプレート内の$post->author->nameは、見た目には単なる表示処理である。しかし、著者が未取得ならデータベースアクセスを伴う。

ビューの中にクエリが発生すること自体を完全に避ける必要はない。ただし、一覧ループの中で発生するクエリは、設計上の意図を確認すべきである。表示処理からクエリ数を推測しにくい構成は、後の保守で問題になる。

アクセサ経由のLazy Loading

見落としやすいのが、アクセサや算出属性でリレーションを参照するケースである。

モデルに定義した表示用の属性が、内部で$this->author$this->commentsを参照している場合、呼び出し側はリレーションを直接書いていなくてもLazy Loadingが発生する。

このタイプの問題は、クエリコードと表示コードの距離が離れる。例外が発生したときは、モデルのアクセサ、リソースの変換処理、シリアライズ対象の属性を確認する必要がある。

また、JSON化の過程でリレーションが参照されることもある。APIレスポンスに含める属性を増やしただけで、クエリ数が増える可能性がある。レスポンスの変更時には、取得クエリも同時に確認するべきである。

テストでN+1問題を再発させない

一度修正したN+1問題も、後から再発する。新しい表示項目を追加したとき、リレーションを変更したとき、APIレスポンスを拡張したときに、取得側のwith()が更新されないことがある。

preventLazyLoading()をテスト環境で有効にしておけば、テストがその処理を通過した時点で再発を検知できる。

テストでは、次のような経路を対象にする。

1. 複数件の親モデルを作成する。

2. 各親モデルに関連データを設定する。

3. 一覧画面やAPIを実行する。

4. レスポンスの生成まで完了させる。

5. Lazy Loading例外が発生しないことを確認する。

親モデルが1件だけのテストでは、N+1のクエリ増加を評価しにくい。N+1問題は件数に比例して現れるため、複数件のデータを通したテストが必要である。

ただし、テストでクエリ数を固定する場合は、実装詳細との結合が強くなる。with()の内部クエリ構造が変わっただけでテストが壊れる可能性があるため、クエリ数の検証は目的を限定して使うべきである。

Lazy Loading例外を禁止するテストと、クエリ数そのものを検証するテストは役割が異なる。前者は設計漏れの検知、後者は特定処理の性能契約である。

導入時に発生する問題と切り分け

既存コードで大量に例外が発生する

古いコードベースでは、Lazy Loadingを前提に実装された箇所が多い。制限を有効にすると、画面、API、ジョブ、コマンドで例外が連続して発生することがある。

この状態で全件を一度に修正するのは非効率である。主要なユーザー経路から順に対応する方がよい。

優先度は、次のように決められる。

  • リクエスト数が多い一覧画面。
  • APIクライアントが多いエンドポイント。
  • 大量のモデルを扱う処理。
  • 応答時間がすでに問題になっている機能。
  • 今後も変更が多いコード。

例外を握りつぶして導入だけ完了させるのは避けるべきである。問題箇所が残るため、設定の意味が失われる。

リレーション名の誤り

with()で指定した名前が、モデルに定義されたリレーション名と一致していない場合、期待した関連読み込みにならない。

リレーションメソッドと属性アクセスの名前が異なる設計では、さらに混乱が増える。モデルのメソッド、呼び出し側のwith()、ビューやリソースの参照名を揃える必要がある。

関連を読み込んだのに例外が消えない

with()を追加しても例外が消えない場合、別のリレーションが発生している可能性がある。

たとえば、著者を読み込んだ後、著者の所属組織を参照しているケースである。親から直接参照している関連だけでなく、関連先が持つリレーションまで確認する必要がある。

また、条件分岐によって異なる関連が呼ばれる場合もある。代表的なデータだけで確認すると、特定条件の経路を見落とす。

load()をループ内で呼ぶ

load()を使っていても、ループ内で実行すれば問題は解決しない。

ループの各回でload()を呼ぶと、結果として投稿ごとに関連取得が発生する。load()はループの外で、対象モデルの集合に対して実行する必要がある。

読み込み処理の位置は、メソッド名より重要である。with()でもload()でも、親データの集合をまとめて扱える位置に置くことがN+1防止の条件になる。

N+1対策を設計ルールにする

N+1問題を個別のバグとして修正するだけでは、再発を防げない。リレーションの読み込み責務を、アプリケーションの設計ルールに含める必要がある。

コントローラーやサービス層で、処理が必要とする関連を明示する。ビューやAPIリソースは、取得済みのモデルを表示する役割に限定する。これにより、データベースアクセスの位置を追いやすくなる。

サービス層でクエリを組み立てる場合は、取得メソッドの名前から読み込み内容が推測できる設計も有効である。たとえば、一覧表示用の取得処理と、集計用の取得処理で、必要な関連を分ける。

一つの汎用クエリですべての画面を処理しようとすると、不要な関連まで読み込むか、逆に読み込み漏れが発生する。ユースケースごとの取得戦略を持つ方が、性能と保守性を両立しやすい。

読み込み戦略を決める基準

Eager Loadingを追加するかどうかは、次の観点で判断する。

  • その処理で関連データを必ず使うか。
  • 親モデルが一覧で複数件取得されるか。
  • 関連データのサイズが大きくないか。
  • APIや画面の仕様上、関連が必須か。
  • 条件によって関連が不要になるか。
  • バッチや集計処理で大量取得されるか。

親モデルが1件でも、詳細画面で関連が必要ならwith()は有効である。N+1を防ぐだけでなく、取得するデータの意図をクエリに表現できるためである。

一方、関連データを使わない処理にまで$withを適用すると、取得量が増える。すべてのN+1対策を常時読み込みで解決する設計は、別のボトルネックを作る。

N+1対策は、関連を多く読み込むことではない。処理に必要な関連を、必要なタイミングで、集合として読み込むことである。

実装前後で確認するべきポイント

preventLazyLoading()を導入する場合、確認対象は設定コードだけではない。アプリケーションの実行経路まで見る必要がある。

導入前

  • Laravelのバージョンが8.43.0以降であるか確認する。
  • Eloquentモデルのリレーション定義を確認する。
  • 一覧、詳細、API、ジョブ、コマンドの取得処理を分類する。
  • ループ内で関連属性を参照している箇所を確認する。
  • APIリソースやアクセサが関連を参照していないか調べる。
  • 本番環境で例外停止させない条件を決める。

導入後

  • 非本番環境でLazy Loading例外が発生することを確認する。
  • 発生した箇所にwith()またはload()を追加する。
  • ネストした関連の読み込み漏れを確認する。
  • 関連を常時読み込むモデルでは、不要な処理への影響を確認する。
  • 複数件のデータで一覧やAPIを実行する。
  • テスト環境でも同じ制限が有効であることを確認する。
  • 修正後に例外が消えただけでなく、レスポンス内容が変わっていないか確認する。

この確認を行えば、N+1問題を単発の性能調査ではなく、開発工程の中で検知できる。

結論

LaravelのN+1問題は、EloquentのLazy Loadingがループ処理の中で実行されることで発生する。親モデルの取得が1回でも、関連モデルへのアクセスがN回発生すれば、クエリ数は1 + Nになる。

Laravel 8.43.0以降では、Model::preventLazyLoading()を利用できる。Model::preventLazyLoading(! app()->isProduction());のように環境を限定すれば、開発・テスト・ステージングでN+1を例外として検知しつつ、本番環境の予期しない停止を避けられる。

修正方法は、処理の構造に応じて使い分ける。

  • 取得時点で関連が必要ならwith()を使う。
  • 取得後に条件が決まるならload()を使う。
  • 常に必要な小さな関連ならモデルの$withを検討する。
  • Lazy Loading以外の暗黙動作も制御するならshouldBeStrict()を使う。
  • 本番環境では、検知よりも可用性を優先し、条件付きで制限する。

改善効果は、設定を追加した時点では発生しない。例外で検知し、読み込み戦略を修正した時点で初めてクエリ発行回数が減る。ここを分けて考える必要がある。

  • preventLazyLoading()の効果は、N+1候補の自動検知である。
  • with()load()の効果は、関連取得の集合化である。
  • $withの効果は、常時必要な関連の指定漏れ防止である。
  • shouldBeStrict()の効果は、Eloquentの暗黙動作の厳格化である。
  • 条件付き設定の効果は、本番障害のリスクを抑えながら開発時の検出力を確保することである。

N+1問題を防ぐ最小構成は、非本番環境でLazy Loadingを禁止し、例外が発生した取得処理に対してEager Loadingを明示することである。自動最適化を期待する機能ではない。コードのデータ取得責務を明確にするための、検証機構である。

Related reading: Eloquentとクエリビルダの境界線:個人開発での速度と保守性の両立 and LaravelのAPI連携におけるRESTとGraphQLの長所と個人開発での最適解.

よくある質問

N+1問題とはどのような現象ですか?
親モデルを1回取得した後、関連モデルのデータにアクセスするたびにクエリが発行され、結果として合計で「1 + N」回のクエリが実行されてしまう現象です。
preventLazyLoading()はどのように設定しますか?
通常、AppServiceProviderのbootメソッド内に記述します。本番環境での障害を避けるため、! app()->isProduction()という条件を付けて非本番環境でのみ有効にするのが一般的です。
with()とload()はどのように使い分けますか?
取得時点で関連データが必要だと分かっている場合はwith()を使い、取得後に条件分岐などで必要性が決まる場合はload()を使用します。
shouldBeStrict()とは何ですか?
Lazy Loadingの制限に加え、未登録属性への代入や存在しない属性へのアクセスなど、Eloquentの暗黙的な動作をまとめて厳格化する設定です。
モデルの$withプロパティはいつ使うべきですか?
特定のモデルを取得する際に、常に同じリレーションが必要となる場合に使用します。ただし、不要な処理でも関連データが読み込まれるため、データサイズや用途を考慮する必要があります。

参考情報