PHP/Laravel プログラミング

Laravelのwithメソッド、loadメソッドでN+1問題を解決する

投稿日:

Laravelでリレーション定義を行ったモデルを使用することで、N+1問題が発生します。

規模が小さく、データがそれほど多くないWebシステムではN+1問題がシステムのパフォーマンスに影響を及ぼすことはそれほど大きくはありませんが、データが多くなればN+1問題によってWebシステムのパフォーマンスに悪影響を及ぼすことになります。

ですので、N+1問題はプロジェクトの初期段階で対策することを徹底しておくべきだと考えています。

この記事では、N+1問題とは何ぞやと言うところから解説し、N+1問題を解決する方法についても紹介していきたいと思います。

また、Laravelでリレーションを定義する方法については下記の記事で紹介しているので、ぜひ参考にしてみて下さい。

N+1問題とは

N+1問題とはSQLを発行する回数がN+1回になることを言います。

どう言うことかというと、例えばユーザーがいて、そのユーザーが複数の記事を投稿しているようなWebシステムを想定します。

ユーザーデータを複数件取得して、そのユーザー毎に紐づく投稿記事のデータを取得する処理を実装します。

このような処理を実装した際に発行されるSQLは下記のようになります。

  • ユーザーデータN件を取得するのに発行するSQLが1回
  • ユーザーデータN件に紐づく投稿記事データを取得する際に発行するSQLがN回

このようにN+1回のSQLを発行すると言うことはNの部分のデータ数が多くなれば多くなるほどSQLの発行回数が増えてしまいます。

SQLの発行回数が多くなれば、それだけ処理に時間を要しレスポンスが悪くなるためWebシステムのパフォーマンスに悪影響を及ぼすことになってしまうのです。

N+1問題が発生するとき

それでは実際にN+1問題は発生させて見ましょう。

まずはuserテーブルとpostsテーブルのマイグレーションを作成します。

$ php artisan make:migration create_users_table --create=users
$ php artisan make:migration create_posts_table --create=posts
<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

class CreateUsersTable extends Migration
{
    /**
     * Run the migrations.
     *
     * @return void
     */
    public function up()
    {
        Schema::create('users', function (Blueprint $table) {
            $table->id();
            $table->string('name');
            $table->timestamps();
        });
    }

    /**
     * Reverse the migrations.
     *
     * @return void
     */
    public function down()
    {
        Schema::dropIfExists('users');
    }
}
<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

class CreatePostsTable extends Migration
{
    /**
     * Run the migrations.
     *
     * @return void
     */
    public function up()
    {
        Schema::create('posts', function (Blueprint $table) {
            $table->id();
            $table->string('title');
            $table->string('body');
            $table->unsignedBigInteger('user_id');
            $table->timestamps();

            $table->foreign('user_id')->references('id')->on('users');
        });
    }

    /**
     * Reverse the migrations.
     *
     * @return void
     */
    public function down()
    {
        Schema::dropIfExists('posts');
    }
}

マイグレーションを実行し、テーブルを作成できたら、下記のようなデータを用意します。

mysql> select * from users;
+----+-------+---------------------+---------------------+
| id | name  | created_at          | updated_at          |
+----+-------+---------------------+---------------------+
|  1 | user1 | 2022-01-30 23:42:24 | 2022-01-30 23:42:24 |
|  2 | user2 | 2022-01-30 23:42:24 | 2022-01-30 23:42:24 |
|  3 | user3 | 2022-01-30 23:42:24 | 2022-01-30 23:42:24 |
+----+-------+---------------------+---------------------+

mysql> select * from posts;
+----+--------+-------+---------+---------------------+---------------------+
| id | title  | body  | user_id | created_at          | updated_at          |
+----+--------+-------+---------+---------------------+---------------------+
|  1 | title1 | body1 |       1 | 2022-01-30 23:42:24 | 2022-01-30 23:42:24 |
|  2 | title2 | body2 |       1 | 2022-01-30 23:42:24 | 2022-01-30 23:42:24 |
|  3 | title3 | body3 |       2 | 2022-01-30 23:42:24 | 2022-01-30 23:42:24 |
+----+--------+-------+---------+---------------------+---------------------+

次にモデルを作成し、Userモデルではリレーションを定義しましょう。

$ php artisan make:model Models/User
$ php artisan make:model Models/Post
<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;

class User extends Model
{
    public function posts()
    {
        return $this->hasMany('App\Models\Post');
    }
}

これで下準備は完了です。それでは早速コントローラーでN+1問題を発生させて見ましょう。

<?php

namespace App\Http\Controllers;

use App\Models\User;

class UserController extends Controller
{
    public function index()
    {
        $users = User::all();

        foreach ($users as $user) {
            foreach ($user->posts as $post) {
                dump($post->title);
            }
        }
    }
}

ユーザーデータを全件取得して、postsメソッドで全ユーザーに紐づく全投稿記事をそれぞれ取得しています。

この時に発行されているSQL文をLaravelのデバッグバーで確認してみましょう。

また、Laravelのデバッグバーついては下記の記事で紹介しているので、ぜひ参考にしてみてください。

「ユーザー全件を取得するSQLが1回」と「ユーザーに紐づく全投稿記事を取得するSQLが全ユーザー分の計3回」の合計4回のSQLが発行されていることが分かります。

これくらいデータの少ない場合であればSQLが4回だけなので、パフォーマンスに影響は与えません。

しかし、データ数が増えると増えた分だけSQLの発行回数が増えてしまうことが問題で、後々にパフォーマンスに悪影響を及ぼしかねないのが一番の問題点です。

N+1問題の解決方法

では、N+1問題を解決するにはどうすればいいのでしょうか?

その解決方法が「Eagerロード」です。

Eagerロードとは、日本語に訳すると「熱心にロードする」になりますが、これでは意味がわかりませんね。

Eagerロードを行うことで取得したModelインスタンスのリレーション先を事前に取得することをできるのです。

LaravelではEagerロードを行うのに、withメソッドloadメソッドを使うことができます。

それぞれのメソッドの使い方を紹介していきましょう。どちらも処理内容自体はほぼ同じでN+1問題を解決することができます。

withメソッド

先ほど書いたUserController.phpでwithメソッドを使ってみましょう。

<?php

namespace App\Http\Controllers;

use App\Models\User;

class UserController extends Controller
{
    public function index()
    {
        $users = User::with('posts')->get();

        foreach ($users as $user) {
            foreach ($user->posts as $post) {
                dump($post->title);
            }
        }
    }
}

withメソッドの引数にはUserモデルで定義したリレーションのメソッド名を文字列で指定します。

たったこれだけでEagerロードが可能になります。また、withメソッドを使用する際はallメソッドが使用できないので、getメソッドでモデルインスタンスのコレクションを取得します。

それではデバッグバーで発行されたSQLを確認してみましょう。

usersテーブルのデータを取得するSQL1回とusersテーブルのリレーション先であるpostsテーブルの全データを取得するSQL1回の計2回しかSQLを発行されていないことが確認できます。

これはusersテーブルのデータの数が増えたとしてもSQLは2回しか発行されません。

Eagerロードを行うと、事前にリレーション先のデータを取得することでN+1問題を解決していたんですね。

loadメソッド

次にloadメソッドを使用してみましょう。

<?php

namespace App\Http\Controllers;

use App\Models\User;

class UserController extends Controller
{
    public function index()
    {
        $users = User::all()->load('posts');

        foreach ($users as $user) {
            foreach ($user->posts as $post) {
                dump($post->title);
            }
        }
    }
}

loadメソッドの引数にリレーション先であるpostsメソッド名の文字列を指定する点はwithメソッドの時と同様ですが、Eagerロードを行うタイミングが異なります。

withメソッドはモデルインスタンスのコレクションを取得する前に使用しますが、loadメソッドは取得した後に使用することができます。

ネストしたリレーションの場合

最後にネストしたリレーションの場合でもEagerロードを行い、N+1問題を発生させないようにしてみましょう。

投稿記事に複数のコメントが付く場合を想定して、postsテーブルに紐づくcommentsテーブルのマイグレーションを作成します。

$ php artisan make:migration create_comments_table --create=comments
<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

class CreateCommentsTable extends Migration
{
    /**
     * Run the migrations.
     *
     * @return void
     */
    public function up()
    {
        Schema::create('comments', function (Blueprint $table) {
            $table->id();
            $table->string('comment');
            $table->unsignedBigInteger('post_id');
            $table->timestamps();

            $table->foreign('post_id')->references('id')->on('posts');
        });
    }

    /**
     * Reverse the migrations.
     *
     * @return void
     */
    public function down()
    {
        Schema::dropIfExists('comments');
    }
}

postsテーブルとの外部キーであるpost_idカラムを用意し、外部キー制約を定義しています。

次にCommentモデルを作成し、Postモデルにcommentsテーブルとのリレーションを定義します。

$ php artisan make:model Models/Comment
<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;

class Post extends Model
{
    public function comments()
    {
        return $this->hasMany('App\Models\Comment');
    }
}

それではネストしたリレーションでwithメソッドを使用してみましょう。

<?php

namespace App\Http\Controllers;

use App\Models\User;

class UserController extends Controller
{
    public function index()
    {
        $users = User::with('posts.comments')->get();

        foreach ($users as $user) {
            foreach ($user->posts as $post) {
                foreach ($post->comments as $comment)
                    dump($comment->comment);
            }
        }
    }
}

ネストしたリレーションではwithメソッドの引数にリレーションをドット記法で繋ぐだけでEagerロードを行うことができます。

実際に発行されたSQLを確認しましょう。

ネストしたリレーションでもしっかりとEagerロードできていることが確認できました。

まとめ

N+1問題の解説とその解決する方法を紹介してきました。

プロダクトのデータ数が少ないローカル開発環境の内は顕在化してこないのが、N+1問題なので本番環境でデータ数が多くなった時にパフォーマンスが落ちないように普段から意識しておきましょう。

-PHP/Laravel, プログラミング

Copyright© みぎさんドットコム , 2022 All Rights Reserved Powered by STINGER.