🔥

【Laravel】EagerロードでN+1問題の解決と多段階リレーションの取得

2024/10/30に公開

はじめに

こんにちは、hiro です。
Laravel には Eloquent という ORM が搭載されていて、SQL を書かなくても DB からデータを簡単に取得できます。
その便利さがゆえに何も意識することなくコードを書いてしまうと、思わぬ部分でパフォーマンスの低下を招いてしまいます。

そこで今回は比較的簡単に取り組むことができる「N+1 問題」の解決を行っていきたいと思います。

環境

  • php: 8.2
  • Laravel: 11.9

前準備

公式サイトの Eager ロードの章を参考に話を進めます。

コードを省略している箇所がありますのでご了承ください。

  • Books モデル: 本に関するモデルで、著者に関するリレーションを持っている。

    Book.php
    <?php
    
    namespace App\Models;
    
    use Illuminate\Database\Eloquent\Model;
    use Illuminate\Database\Eloquent\Relations\BelongsTo;
    
    class Book extends Model
    {
        /**
        * その本を書いた著者を取得
        */
        public function author(): BelongsTo
        {
            return $this->belongsTo(Author::class);
        }
    }
    
  • Author モデル: 本の著者のモデル。

    Author.php
    <?php
    
    namespace App\Models;
    
    use Illuminate\Database\Eloquent\Model;
    use Illuminate\Database\Eloquent\Relations\HasMany;
    use Illuminate\Database\Eloquent\Relations\HasOne;
    
    class Author extends Model
    {
        /**
        * この著者が書いた本を取得
        */
        public function books(): HasMany
        {
            return $this->hasMany(Book::class);
        }
    
        /**
        * 著者の連絡先を取得
        */
        public function contacts(): HasOne
        {
            return $this->hasOne(Contact::class);
        }
    }
    
  • Contact モデル: 著者の連絡先のモデル。

    Contact.php
    <?php
    
    namespace App\Models;
    
    use Illuminate\Database\Eloquent\Model;
    use Illuminate\Database\Eloquent\Relations\BelongsTo;
    
    class Contact extends Model
    {
        /**
        * 連絡先が所属する著者を取得
        */
        public function author(): BelongsTo
        {
            return $this->belongsTo(Author::class);
        }
    }
    

検証

Eager ロードを使わない場合と使った場合の 2 パターンを比較します。

Book モデル経由で本の著者を取得するという想定です。

Eager ロードを使用しない場合

BookController.php
<?php

namespace App\Http\Controllers;

use App\Models\Book;

class BookController extends Controller
{
    public function __invoke()
    {
        \DB::enableQueryLog();

        $books = Book::all();

        foreach ($books as $book) {
            echo $book->author->name . "<br>";
        }

        dd(\DB::getQueryLog());
    }
}

dd()で吐き出されている結果から分かるように、合計で 101 件のクエリが実行されていました。

1 回目のクエリで本のデータを全件取得、それ以降はその本の著者を取得するために実行されています。

# 1回目
select * from `books`

# 2回目以降
select * from `authors` where `authors`.`id` = 1 limit 1
select * from `authors` where `authors`.`id` = 2 limit 1
select * from `authors` where `authors`.`id` = 3 limit 1
・
・
・
select * from `authors` where `authors`.`id` = 100 limit 1

見事に 100 件のデータを取得するために、101 回のクエリが実行される「N+1 問題」が発生しています。

Eager ロードを使用した場合

BookController.php
<?php

namespace App\Http\Controllers;

use App\Models\Book;

class BookController extends Controller
{
    public function __invoke()
    {
        \DB::enableQueryLog();

        $books = Book::with('author')->get();

        foreach ($books as $book) {
            echo $book->author->name . "<br>";
        }

        dd(\DB::getQueryLog());
    }
}

Eager ロードを使用しない場合は 101 件クエリが実行されていたのに対して、2 件のクエリの実行で済んでいます。

1 回目のクエリで本を全件取得し、2 回目のクエリでwhere ○○ in △△を使用して、著者の情報を一括で取得しています。
Eager ロードを使用しない場合だと、foreachを用いて都度クエリを発行して著者情報を取得していましたが、Eager ロードを使用する場合はwith()を用いて一括で取得しています。
その取得したデータをforeachでループしてデータを処理するイメージです。

応用

更に応用編として、本の著者の連絡先を取得する例を実装します。

Book モデルに紐づく Author モデルに紐づいた Contact モデルを取得します。
流れとしては
Book->Author->Contact
というイメージです。

Eager ロードを使用しない場合

BookController.php
<?php

namespace App\Http\Controllers;

use App\Models\Book;

class BookController extends Controller
{
    public function __invoke()
    {
        \DB::enableQueryLog();

        $books = Book::all();

        foreach ($books as $book) {
            echo "書籍: {$book->title}\n";
            echo "著者: {$book->author->name}\n";

            foreach ($book->author->contacts as $contact) {
                echo "連絡先: {$contact->email}\n";
            }
            echo "-------------------\n";
        }

        dd(\DB::getQueryLog());
    }
}

本を全件取得するクエリ、紐づく著者を取得するクエリ、更に紐づく連絡先を取得するクエリ、合計で 201 回のクエリが実行されています。

# 1回目
select * from `books`

# 2回目以降
select * from "authors" where "authors"."id" = ? limit 1
select * from "contacts" where "contacts"."author_id" = ? and "contacts"."author_id" is not null
・
・
・

データがそれぞれ 100 件ずつなので 201 回で済んでいますが、もっとデータがあったらと考えると恐ろしいです 🤯

Eager ロードを使用した場合

ネストしたリレーションを取得するには、with('foo'.'bar')の様に、リレーション名を.(ドット)で繋ぐ必要があります。

BookController.php
<?php

namespace App\Http\Controllers;

use App\Models\Book;

class BookController extends Controller
{
    public function __invoke()
    {
        \DB::enableQueryLog();

        $books = Book::with('author.contacts')->get();

        foreach ($books as $book) {
            echo "書籍: {$book->title}\n";
            echo "著者: {$book->author->name}\n";

            foreach ($book->author->contacts as $contact) {
                echo "連絡先: {$contact->email}\n";
            }
            echo "-------------------\n";
        }

        dd(\DB::getQueryLog());
    }
}

先ほど同様、クエリの実行回数を大幅に減らすことができました。

終わりに

SQL は発行するクエリが少なければ少ないほどパフォーマンスが向上します。
Laravel に限らず ORM は、実行されている SQL をあまり意識することなく DB の操作が出来てしまいます。
便利な反面思わぬ部分でパフォーマンスの低下を招いてしまう恐れがあるのでぜひ意識してみて下さい。

参考文献

https://readouble.com/laravel/11.x/ja/eloquent-relationships.html

Discussion