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問題なので本番環境でデータ数が多くなった時にパフォーマンスが落ちないように普段から意識しておきましょう。