LoginSignup
4
1

LaravelでN+1問題を解決する

Last updated at Posted at 2024-03-23

はじめに

LaravelのEloquentについて学び直しをしたいと思い、パフォーマンス問題としてよく発生しがちなN+1を題材にEloquentを使った解決方法や、Eloquentでリレーションを管理できることについてまとめました。

N+1問題とは

N+1問題は、RDBを操作する際に発生するパフォーマンスの問題です。1つのクエリでデータを取得した後、そこで取得した各レコードに対してさらにクエリを実行する必要がある場合に発生します。

N+1問題が発生する場面

あるブログアプリがあるとします。このアプリでは、複数のユーザーが投稿を行い、それぞれのユーザーは複数の投稿を持つことができます。ユーザーのリストを表示し、それぞれのユーザーが行った最初の投稿のタイトルをリストアップする機能を実装したいと考えています。

まず、ユーザーのリストを取得するために以下のようなクエリを実行します。

$users = User::all();

次に、それぞれのユーザーの最初の投稿のタイトルを取得するために、以下のように各ユーザーごとにループ処理を行い、追加のクエリを実行します。

foreach ($users as $user) {
    echo $user->posts()->first()->title;
}

このコードは、最初のUser::all()クエリに加えて、ユーザーの数だけ追加のクエリが発生します。つまり、もし10人のユーザーがいれば、最初の1つのクエリに加えて10個の追加クエリが発生し、合計で11個のクエリがデータベースに対して実行されることになります。これがN+1問題です。

以下は、実際に発行されるSQLになります。

-- 全ユーザーの取得
SELECT * FROM `users`;
-- 各ユーザーの最初の投稿の取得(ユーザーが10人いると仮定)
SELECT * FROM `posts` WHERE `user_id` = 1 ORDER BY `id` ASC LIMIT 1;
SELECT * FROM `posts` WHERE `user_id` = 2 ORDER BY `id` ASC LIMIT 1;
SELECT * FROM `posts` WHERE `user_id` = 3 ORDER BY `id` ASC LIMIT 1;
...
SELECT * FROM `posts` WHERE `user_id` = 10 ORDER BY `id` ASC LIMIT 1;

N+1問題を発生させてしまう理由

EloquentなどのORMの便利さにより、開発者はデータベース操作を簡単に抽象化できますが、これがN+1問題の一因になることがあります。ORMの使用に慣れると、生成されるSQLクエリの詳細やパフォーマンスへの影響を見落としやすくなります。開発者がクエリの効率よりもコードの書きやすさを優先する傾向があり、これがパフォーマンス問題を引き起こす可能性があるため、意識的な対策が必要だと考えます。

参照:https://qiita.com/muroya2355/items/d4eecbe722a8ddb2568b

EloquentのリレーションにおけるN+1問題

そもそもリレーションとは

リレーション(関係)とは、データベースの世界で使われる概念で、特にリレーショナルデータベース(RDB)において、テーブル間の関連付けを指します。RDBは、データをテーブルとして格納し、これらのテーブル間での関係を通じてデータの整合性を保ち、効率的なデータ操作を可能にします。

参照:https://cloud.google.com/learn/what-is-a-relational-database?hl=ja

LaravelのORMであるEloquentにはリレーションを簡単に管理し操作できるような仕組みになっています。

データベーステーブルは大抵の場合、他と関連しています。たとえばブログ投稿(ポスト)は多くのコメントを持つか、それを投稿したユーザーと関連しています。Eloquentはそうしたリレーションを簡単に管理し操作できるようにするとともに、さまざまなタイプのリレーションをサポートしています。

参照:https://readouble.com/laravel/6.x/ja/eloquent-relationships.html

上述したN+1問題が発生する具体的な例を参考に、ユーザーと投稿のリレーションを定義してみましょう。

Eloquentのリレーションの定義の方法

ユーザー(User)と投稿(Post)の間に「一対多」の関係が存在します。つまり、1人のユーザーは複数の投稿を持つことができ(一対多)、各投稿は特定の1人のユーザーに属しています(多対一)。このような関係をEloquentで扱うためには、モデル間にリレーションを定義する必要があります。

Userモデルにおけるリレーションの定義

Userモデル内で、postsというメソッドを定義して、ユーザーが持つ投稿を表すリレーションを作成します。このメソッドは、ユーザーが複数の投稿を持つことを示すhasManyリレーションを返します。

use Illuminate\Database\Eloquent\Model;

class User extends Model
{
    public function posts()
    {
        return $this->hasMany(Post::class);
    }
}

Postモデルにおけるリレーションの定義

逆に、Postモデル内では、各投稿がどのユーザーに属しているかを示すために、userというメソッドを定義してbelongsToリレーションを使います。

phpCopy code
use Illuminate\Database\Eloquent\Model;

class Post extends Model
{
    public function user()
    {
        return $this->belongsTo(User::class);
    }
}

このようなリレーション定義することで、上述した例の通り、ユーザーのリストとそれぞれのユーザーが行った最初の投稿のタイトルを表示する機能を実装することができます。

$users = User::all();
foreach ($users as $user) {
    echo $user->posts()->first()->title;
}
// ※ここではまだN+1問題を解決していません

N+1問題の解決策

この問題を解決するために、Eloquentでは「Eager Loading」を用いたリレーションデータの事前ロードが提供されています。Eager Loadingを使うことで、最初のクエリ実行時に関連データを一度にロードすることができ、後続のクエリ発行を防ぐことができます。上記の例でEager Loadingを使用すると、以下のようになります。

$users = User::with('posts')->get();

foreach ($users as $user) {
    echo $user->posts->first()->title;
}

この場合、User::with('posts')->get();のクエリでユーザーとその投稿を一度に取得し、それ以降のループ内で追加のクエリを発行する必要がありません。これにより、N+1問題が解決されます。

参考:https://qiita.com/shosho/items/abf6423283f761703d01

Eager Loadingの動作について詳しく知る

User::with('posts')->get();というクエリでは、「posts」というリレーション名をwithメソッドに指定しています。これにより、Eloquentは次の2つのステップでデータを取得します。

  1. 全ユーザーの取得:
    最初に、usersテーブルから全ユーザーのデータを取得するSQLクエリが実行されます。

    SELECT * FROM `users`;
    
  2. 関連する投稿の取得:
    次に、取得した全ユーザーのIDを使用して、それらに関連する投稿をpostsテーブルから一度に取得するSQLクエリが実行されます。このステップでは、IN句を使用して、一回のクエリで複数のユーザーに関連する投稿を取得します。

    SELECT * FROM `posts` WHERE `user_id` IN (1, 2, 3, ..., N);
    

    ここで、Nは取得したユーザーの総数です。

付録:条件付きEager Loading

すべての場面で全ての関連データをロードする必要はなく、条件に応じてロードするデータを変更することが望ましい場合があります。

$users = User::with(['posts' => function ($query) {
    $query->where('status', 'published');
}])->get();

この例では、postsリレーションをロードする際に、公開済み(statuspublished)の投稿のみをロードしています。これにより、特定の条件を満たすデータのみを取得し、不要なデータのロードを避けることができます。

※実行されるSQL

SELECT * FROM `users`;
SELECT * FROM `posts` WHERE `user_id` IN (1, 2, 3, ..., N) AND `status` = 'published';

参照:https://readouble.com/laravel/6.x/ja/eloquent-relationships.html#constraining-eager-loads

N+1を予防するための対策

クエリの理解と最適化

自身が書いたコードが実際にどのようなSQLクエリを生成するのかを理解するために、Laravelのクエリログやデバッグバーを利用して、実際のクエリを定期的に確認します。これにより、不要なクエリの削減や、必要に応じてクエリの最適化を行うことができます。

パフォーマンステストの実施

開発の早い段階からパフォーマンステストを行い、アプリケーションのパフォーマンスに影響を与える可能性のある問題点を早期に特定します。特に、大量のデータを扱う場合や、複雑なデータ構造を持つアプリケーションでは、このようなテストがさらに重要になる場合があります。

参考

4
1
1

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
4
1