0
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 1 year has passed since last update.

Eloquent のN+1問題には Eeger ロード

Last updated at Posted at 2022-06-26

前回のあらすじ

ユーザーが所属する会社を横断したプロジェクトに属している場合に、ログインユーザーと関わりのあるプロジェクトの会社名すべて取得するリレーションを 紹介した

$user = User::find(1);
$names = $user->projects->pluck('users')
    ->flatten()->pluck('company')
    ->pluck('name')->unique();

しかし、このままではループの中でクエリが発行される「N+1問題」が生じ、クエリ数がとんでもないことになってしまう。
このことについて、実際にデータを作成して検証してみる。

テスト用のデータ作成はこちらを参照

tinker でリレーションを試す

今回はクエリを確認するために tinker を起動したらすぐに DB::enableQueryLog() を実行する。

$ php artisan tinker
Psy Shell v0.11.4 (PHP 7.4.11  cli) by Justin Hileman
>>> DB::enableQueryLog()
=> null

これ以降の操作で発生したクエリは DB::getQueryLog() で確認することができる。ユーザーを1件指定して、リレーションで紐づく関連会社の名前を取得してみる。

>>> $user = User::find(1);
[!] Aliasing 'User' to 'App\Models\User' for this Tinker session.
=> App\Models\User {#3588
     id: 1,
     name: "鈴木 修平",
     email: "cyamada@example.com",
     company_id: 1,
     email_verified_at: "2022-06-19 05:12:17",
     #password: "$2y$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi",
     #remember_token: "14rSAboFzn",
     created_at: "2022-06-19 05:12:17",
     updated_at: "2022-06-19 05:12:17",
   }

>>> $names = $user->projects->pluck('users')->flatten()->pluck('company')->pluck('name')->unique();
=> Illuminate\Support\Collection {#3605
     all: [
       0 => "有限会社 喜嶋",
       3 => "有限会社 渚",
       5 => "有限会社 藤本",
       10 => "株式会社 伊藤",
       13 => "株式会社 吉本",
       15 => "有限会社 村山",
       21 => "有限会社 佐々木",
       27 => "有限会社 山岸",
       30 => "株式会社 山田",
     ],
   }

>>> count(DB::getQueryLog())
=> 234

>>> collect(DB::getQueryLog())->sum('time')
=> 41.26

N+1が発生してるのが明らかなクエリ数だ。レコードの件数が増えれば、クエリ数も実行時間も比例して増大していくだろう。
どんなクエリが発行されたかログを確認してみる。


>>> DB::getQueryLog()
=> [
     [
       "query" => "select * from `users` where `users`.`id` = ? limit 1",
       "bindings" => [
         1,
       ],
       "time" => 1.55,
     ],
     [
       "query" => "select `projects`.*, `project_user`.`user_id` as `pivot_user_id`, `project_user`.`project_id` as `pivot_project_id` from `projects` inne
r join `project_user` on `projects`.`id` = `project_user`.`project_id` where `project_user`.`user_id` = ?",
       "bindings" => [
         1,
       ],
       "time" => 0.79,
     ],
     [
       "query" => "select `users`.*, `project_user`.`project_id` as `pivot_project_id`, `project_user`.`user_id` as `pivot_user_id` from `users` inner join
 `project_user` on `users`.`id` = `project_user`.`user_id` where `project_user`.`project_id` = ?",
       "bindings" => [
         2,
       ],
       "time" => 0.51,
     ],
     [
       "query" => "select `users`.*, `project_user`.`project_id` as `pivot_project_id`, `project_user`.`user_id` as `pivot_user_id` from `users` inner join
 `project_user` on `users`.`id` = `project_user`.`user_id` where `project_user`.`project_id` = ?",
       "bindings" => [
         4,
       ],
       "time" => 0.34,
     ],
      ~~(省略)~~
     [
       "query" => "select `users`.*, `project_user`.`project_id` as `pivot_project_id`, `project_user`.`user_id` as `pivot_user_id` from `users` inner join
 `project_user` on `users`.`id` = `project_user`.`user_id` where `project_user`.`project_id` = ?",
       "bindings" => [
         9,
       ],
       "time" => 0.31,
     ],
     [
       "query" => "select * from `companies` where `companies`.`id` = ? limit 1",
       "bindings" => [
         1,
       ],
       "time" => 0.27,
     ],
     [
       "query" => "select * from `companies` where `companies`.`id` = ? limit 1",
       "bindings" => [
         1,
       ],
       "time" => 0.16,
     ],
      ~~(以下略)~~

いくつかのクリエがくり返されているが、ログを from で検索 してみると理解しやすくなる。順に……

  1. from users が1回
  2. from projects が1回
  3. from users がいくつか
  4. from companies が延々と続く

そこで、ユーザーを起点して会社までのリレーション名(テーブル名ではない)をドット記法でつなげた projects.users.company を load メソッドで指定することにする(Eager ロード)。

クエリログを初期化するために tinker を立ち上げなおす。

$ php artisan tinker
Psy Shell v0.11.4 (PHP 7.4.11  cli) by Justin Hileman
>>> DB::enableQueryLog()
=> null

>>> $user = User::find(1);
[!] Aliasing 'User' to 'App\Models\User' for this Tinker session.
=> App\Models\User {#3590
     ~~(省略)~~
   }

>>> $user->load('projects.users.company')

load コマンドの実行結果がズラズラ表示されるがここでは省略する。
スクロールが止まったら、いよいよリレーションを取得。

>>> $names = $user->projects->pluck('users')->flatten()->pluck('company')->pluck('name')->unique();
=> Illuminate\Support\Collection {#4358
     all: [
       0 => "有限会社 喜嶋",
       3 => "有限会社 渚",
       5 => "有限会社 藤本",
       10 => "株式会社 伊藤",
       13 => "株式会社 吉本",
       15 => "有限会社 村山",
       21 => "有限会社 佐々木",
       27 => "有限会社 山岸",
       30 => "株式会社 山田",
     ],
   }

>>> count(DB::getQueryLog())
=> 4

>>> collect(DB::getQueryLog())->sum('time')
=> 3.67

結果は同じなのに、クエリ数はたったの4つ!!
この4つとは、先に調べたように from したテーブルの数だ。クエリの内容を確認みると、必要な値を先に取得して一度ずつ取得しているのがわかる。

>>> DB::getQueryLog()
=> [
     [
       "query" => "select * from `users` where `users`.`id` = ? limit 1",
       "bindings" => [
         1,
       ],
       "time" => 1.53,
     ],
     [
       "query" => "select `projects`.*, `project_user`.`user_id` as `pivot_user_id`, `project_user`.`project_id` as `pivot_project_id` from `projects` inner join `project_user` on `projects`.`id` = `project_user`.`project_id` where `project_user`.`user_id` in (1)",
       "bindings" => [],
       "time" => 0.72,
     ],
     [
       "query" => "select `users`.*, `project_user`.`project_id` as `pivot_project_id`, `project_user`.`user_id` as `pivot_user_id` from `users` inner join `project_user` on `users`.`id` = `project_user`.`user_id` where `project_user`.`project_id` in (2, 4, 5, 6, 7, 8, 9)",
       "bindings" => [],
       "time" => 0.96,
     ],
     [
       "query" => "select * from `companies` where `companies`.`id` in (1, 2, 3, 4, 5, 6, 7, 8, 9, 10)",
       "bindings" => [],
       "time" => 0.46,
     ],
   ]

レコード数が増えてもクエリ数は変わらなくなった。

Eager ロードは load と with

今回はユーザーを1件取得後に Eager ロードするため load を使用したが、ユーザー一覧的に取得する場合には with を用いる(load のほうが「遅延 Eager ロード」と呼ばれる)。
複数のリレーションを Eager ロードしなければならないときには、load も with も配列で指定できる。次の例では「所属会社」のところでもN+1が起きるので、配列で指定を追加してる。

Controller

$users  = User::with(['company', 'projects.users.company'])->get();

Blade テンプレート

<tr>
  <th>ID</th>
  <th>氏名</th>
  <th>所属会社</th>
  <th>関連会社</th>
</tr>
@foreach ($users as $user)
  <tr>
     <td>{{ $user->id }}</td>
     <td>{{ $user->name }}</td>
     <td>{{ $user->company->name }}</td>
     <td>{{ $user->projects->pluck('users')->flatten()->pluck('company')->pluck('name')->unique()->join('、') }}</td>
  </tr>
@endofreach

with 指定で思考停止しない

モデルに記述されたリレーションをなんでもかんでも with 指定すればよい……とか、なんだかよくわからないけど with は指定しなければならない……等と思考停止はしない。with は書かなくても動く のであり、使いもしないリレーションを指定すればかえって無駄なクエリが実行されるものだ。

  • 最初は with を(無駄に)つけず にプログラムを書き、出力結果の正しさを優先する。
  • 画面表示(または API 出力)が確定したら Lravel Telescope でクエリログを調べる。
  • クエリ数を確認しながら with に指定するリレーションを追加する。
0
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
0
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?