放浪軍師のアプリ開発局

VTuberみたいなアプリケーション夏狂乱など、自由気ままにアプリを開発したりしています。他にもいろいろやってます。尚、このブログはわからないところを頑張って解決するブログであるため、正しい保証がありません。ご注意ください。

Laravel でバックエンド 【N+1問題編】

こんにちは。ブログをさぼりにさぼっている放浪軍師です。最近業務で N+1 問題解決のタスクが発生したのでまとめてみました。

N+1 問題とは?

まずそもそも N+1 問題とは何ぞやという話ですが、似たような SQL クエリを連発するような状態の事を指します。例えば、以下で簡単に発生させることが可能。

<?php

public function test_example(): void
{
    //company 1:N user の関係
    for ($i = 0; $i < 5; $i++) {
        $company = Company::create(['name' => "company_name_$i"]);
        User::create(['company_id' => $company->id, 'name' => "user_name_$i"]);
    }
    DB::enableQueryLog(); //ここからのSQLを確認
    $companies = Company::get();
    foreach ($companies as $company) {
        $users = $company->users;
    }
    dump(DB::getQueryLog()); //ここまでのSQLを確認
}

この際、発行される SQL は以下のようになります。

"query" => "select * from `companies`"
"query" => "select * from `users` where `users`.`company_id` = 1 and `users`.`company_id` is not null"
"query" => "select * from `users` where `users`.`company_id` = 2 and `users`.`company_id` is not null"
"query" => "select * from `users` where `users`.`company_id` = 3 and `users`.`company_id` is not null"
"query" => "select * from `users` where `users`.`company_id` = 4 and `users`.`company_id` is not null"
"query" => "select * from `users` where `users`.`company_id` = 5 and `users`.`company_id` is not null"

$users = $company->users; の部分で select * from `users` where `users`.`company_id` = ? and `users`.`company_id` is not null が連打されているのがわかりますね。これが N+1 問題です。

eager loading で N+1 を回避する

さて、前述の例の場合、以下のようにすると N+1 を回避できます。

<?php

public function test_example(): void
{
    for ($i = 0; $i < 5; $i++) {
        $company = Company::create(['name' => "company_name_$i"]);
        User::create(['company_id' => $company->id, 'name' => "user_name_$i"]);
    }
    DB::enableQueryLog();
    $companies = Company::with(['users'])->get(); //← with() でリレーション先までを読み込む。
    foreach ($companies as $company) {
        $users = $company->users;
    }
    dump(DB::getQueryLog());
}

この際、発行される SQL は以下のようになります。

select * from `companies`
select * from `users` where `users`.`company_id` in (1, 2, 3, 4, 5)

with() でリレーション先まで読んでおく事によって、N+1問題を解決できます。この先読み込みを eager (イーガー) loading と言います。

まぁ、これは基本中の基本でして、色んなブログに説明が転がっていますね。Resource を用いている場合なんかは特に見落としがちなので気を付けましょう。

複数 update で N+1 を回避する

さてここからが微妙に見かけない N+1 問題になります。複数のレコードをアップデートする際は、以下のように書いていませんでしょうか?

<?php

public function test_example2(): void
{
    for ($i = 0; $i < 5; $i++) {
        $company = Company::create(['name' => "company_name_$i"]);
        User::create(['company_id' => $company->id, 'name' => "user_name_$i"]);
    }
    // PATCH API で投げ込まれたアップデートするデータ
    $updateUsers = [
        ['id' => 1, 'name' => 'ア'],
        ['id' => 2, 'name' => 'イ'],
        ['id' => 3, 'name' => 'ウ'],
        ['id' => 4, 'name' => 'エ'],
        ['id' => 5, 'name' => 'オ'],
    ];

    DB::enableQueryLog();
    foreach ($updateUsers as $updateUser) {
        $user = User::find($updateUser['id']);
        $user->update($updateUser);
    }
    dump(DB::getQueryLog());
}

この際、発行される SQL は以下のようになります。

select * from `users` where `users`.`id` = 1 limit 1
update `users` set `name` = `ア`, `users`.`updated_at` = ? where `id` = 1
select * from `users` where `users`.`id` = 2 limit 1
update `users` set `name` = `イ`, `users`.`updated_at` = ? where `id` = 2
select * from `users` where `users`.`id` = 3 limit 1
update `users` set `name` = `ウ`, `users`.`updated_at` = ? where `id` = 3
select * from `users` where `users`.`id` = 4 limit 1
update `users` set `name` = `エ`, `users`.`updated_at` = ? where `id` = 4
select * from `users` where `users`.`id` = 5 limit 1
update `users` set `name` = `オ`, `users`.`updated_at` = ? where `id` = 5

select * from `users` where `users`.`id` = ? limit 1 が N+1 してますね。まぁ明らかに User::find($updateUser['id']) がループしてますからね。でも意外と見逃しがちだったりしますので注意が必要です(戒め)。この場合、以下のように書くべきです。

<?php

public function test_example2(): void
{
    for ($i = 0; $i < 5; $i++) {
        $company = Company::create(['name' => "company_name_$i"]);
        User::create(['company_id' => $company->id, 'name' => "user_name_$i"]);
    }
    // PATCH API で投げ込まれたアップデートするデータ
    $updateUsers = [
        ['id' => 1, 'name' => 'ア'],
        ['id' => 2, 'name' => 'イ'],
        ['id' => 3, 'name' => 'ウ'],
        ['id' => 4, 'name' => 'エ'],
        ['id' => 5, 'name' => 'オ'],
    ];

    DB::enableQueryLog();
    $users = User::whereIn('id', collect($updateUsers)->pluck('id')->toArray())->get()->keyBy('id');
    foreach ($updateUsers as $updateUser) {
        $users[$updateUser['id']]->update($updateUser);
    }
    dump(DB::getQueryLog());
}

発行される SQL は以下のようになります。

select * from `users` where `id` in (1, 2, 3, 4, 5)
update `users` set `name` = `ア`, `users`.`updated_at` = ? where `id` = 1
update `users` set `name` = `イ`, `users`.`updated_at` = ? where `id` = 2
update `users` set `name` = `ウ`, `users`.`updated_at` = ? where `id` = 3
update `users` set `name` = `エ`, `users`.`updated_at` = ? where `id` = 4
update `users` set `name` = `オ`, `users`.`updated_at` = ? where `id` = 5

先に whereIn() を使って更新する User をすべて取得しておく訳ですね。

ちなみに、keyBy('id') をしている理由ですが、foreach 内で簡単に id で取得できるようにするためです。これがない場合は以下のようになります。

<?php

$users = User::whereIn('id', collect($updateUsers)->pluck('id')->toArray())->get();
foreach ($updateUsers as $updateUser) {
    $users->firstWhere('id', $updateUser['id'])->update($updateUser);
}

ただ、この書き方だと、$users->firstWhere()$users を全舐めするので、実質 foreach in foreach となってしまい高負荷です。こういう小技は後々効いてきますので積極的に採用しましょう。

FormRequest で N+1 を回避する

正直これを書きたくて今回記事を書きました。FormRequest は API で受け取ったデータを便利にバリデーションしてくれる素敵な機能です。例えば以下のように書きます。

<?php

namespace App\Http\Requests\Api;

use Illuminate\Foundation\Http\FormRequest;

class UserUpdateRequest extends FormRequest
{

    public function authorize(): bool
    {
        return true;
    }

    public function rules(): array
    {
        return [
            'user_type_master_id' => ['required', 'exists:user_type_masters,id']
        ];
    }
}

ユーザーのタイプを変更できる api のバリデーションでこういうのを書くことがあると思います。以下でこの api のテストをしてみます。

<?php

public function test_example3(): void
{
    //user_type_masters テーブルに値を入れる
    $master = UserTypeMaster::create(['name' => "master"]);
    $powerUser = UserTypeMaster::create(['name' => "power_user"]);
    $general = UserTypeMaster::create(['name' => "general"]);
    //会社作る
    $company = Company::create(['name' => "company_name"]);
    //その会社にユーザーを所属させる。ユーザータイプは master
    $user = User::create(['company_id' => $company->id, 'user_type_master_id' => $master->id, 'name' => "user_name"]);

    DB::enableQueryLog();
    //ユーザーのタイプを power_user に変更する
    $body = [
        'user_type_master_id' => $powerUser->id
    ];
    $response = $this->patch("/api/users/$user->id", $body);
    dump(DB::getQueryLog());
}

バリデーション時に発行される SQL は以下のようになります。

select count(*) as aggregate from `user_type_masters` where `id` = 2

user_type_masters テーブルに id = 2 が存在することを確認しているのがわかります。ここまでは問題ありませんね。では、以下のようなパターンはどうでしょう?

<?php

class UserBatchUpdateRequest extends FormRequest
{
    public function authorize(): bool
    {
        return true;
    }

    public function rules(): array
    {
        return [
            'users.*.user_type_master_id' => ['required', 'exists:user_type_masters,id']
        ];
    }
}

複数名のユーザーのタイプを変更できる api のバリデーションだとこう書くかなと思いますよね。以下でこの api のテストをしてみます。

<?php

public function test_example4(): void
{
    //user_type_masters テーブルに値を入れる
    $master = UserTypeMaster::create(['name' => "master"]);
    $powerUser = UserTypeMaster::create(['name' => "power_user"]);
    $general = UserTypeMaster::create(['name' => "general"]);

    //会社作る
    $company = Company::create(['name' => "company_name"]);
    //その会社にユーザーを複数所属させる。ユーザータイプは全員 master。
    for ($i = 0; $i < 5; $i++) {
        $users[] = User::create(['company_id' => $company->id, 'user_type_master_id' => $master->id, 'name' => "user_name_$i"]);
    }

    DB::enableQueryLog();
    //ユーザー全員のタイプを power_user に変更する
    $body = [
        'users' => collect($users)->map(fn ($user) => [
            'id' => $user->id,
            'user_type_master_id' => $powerUser->id
        ])->toArray()
    ];
    $response = $this->patch("/api/users", $body);
    dump(DB::getQueryLog());
}

バリデーション時に発行される SQL は以下のようになります。

select count(*) as aggregate from `user_type_masters` where `id` = 2
select count(*) as aggregate from `user_type_masters` where `id` = 2
select count(*) as aggregate from `user_type_masters` where `id` = 2
select count(*) as aggregate from `user_type_masters` where `id` = 2
select count(*) as aggregate from `user_type_masters` where `id` = 2

はい、N+1 ですね。ご丁寧にも受け取った user_type_master_id 一つ一つに対して確認を取っています。/(^o^)\ナンテコッタイ!正直な話、これは laravel がよしなにやってよと思いますが、仕方ないので以下のように書き直しましょう。

<?php

class UserBatchUpdateRequest extends FormRequest
{
    public function authorize(): bool
    {
        return true;
    }

    public function rules(): array
    {
        $userTypeMasters = UserTypeMaster::get(); //ここでレコードを取得しておく
        return [
            'users.*.user_type_master_id' => ['required', Rule::in($userTypeMasters->pluck('id'))] //Rule::in() を使って id のいずれかと一致しているかを確認。
        ];
    }
}

こうすれば各 user_type_master_id のバリデーションでは SQL を発行しませんので、

select * from `user_type_masters

だけで済みます。

いやぁ、これ私は今の今まで完全に見落としていましたよ。なお、同様に配列で受け取った値に対してカスタムバリデーションを行う場合も似たような工夫が必要になるので注意してください。

まとめ

ということで、N+1 問題編でした。laravel で発生する N+1 問題はだいたいこの 3 パターンなんじゃないかなぁと思います。みんなも確認してみてね!約束だよ!