• Время чтения ~7 мин
  • 17.06.2022

Высокая производительность обычно является основной причиной медленных проектов Laravel. Большая часть этого — так называемая «проблема запроса N+1». В этой статье я покажу несколько разных примеров того, на что следует обратить внимание, включая случаи, когда проблема «спрятана» в неожиданных местах кода.

В чем проблема с запросом N+1

Короче говоря, это когда код Laravel выполняет слишком много запросов к базе данных. Это происходит потому, что Eloquent позволяет разработчикам писать удобочитаемый синтаксис с моделями, не вникая глубже в то, что «магия» происходит под капотом.

Это проблема не только Eloquent или даже Laravel: она хорошо известна в индустрии разработки. Почему он называется «N+1»?Потому что в случае Eloquent он запрашивает ОДНУ строку из базы данных, а затем выполняет еще один запрос для КАЖДОЙ связанной записи. Итак, N запросов плюс сама запись, всего N+1.

Чтобы решить эту проблему, нам нужно предварительно запросить связанные записи, и Eloquent позволяет нам сделать это легко с помощью так называемого нетерпеливая загрузка.Но прежде чем мы перейдем к решениям, давайте обсудим проблемы. Я покажу вам 4 разных случая.


Случай 1. "Обычный" запрос N+1.

Это можно взять прямо из официальной документации Laravel:

// app/Models/Book.php:
class Book extends Model
{
    public function author()
    {
        return $this->belongsTo(Author::class);
    }
}
 
// Then, in some Controller:
$books = Book::all();
 
foreach ($books as $book) {
    echo $book->author->name;
}

Что здесь происходит? Часть $book->author будет выполнять один дополнительный запрос к БД для каждой книги, чтобы получить ее автора.

Я создал небольшой демонстрационный проект, чтобы имитировать это, и добавил 20 поддельных книг с их авторов. Посмотрите на количество запросов.

Пример запроса N+1 1 — без жадной загрузки

Как видите, для 20 книг есть 21 запрос, ровно N+1, где N = 20.

И да, вы правильно поняли: если у вас есть 100 книг в списке, у вас будет 101 запрос к БД. Ужасная производительность, хотя код казался «невинным», верно.

Исправление состоит в том, чтобы загружать отношения заранее, сразу в контроллере, с нетерпеливой загрузкой, о которой я упоминал ранее:

// Instead of:
$books = Book::all();
 
// You should do:
$books = Book::with('author')->get();

Результат намного лучше - всего 2 запроса:

Пример запроса N+1 1 — быстрая загрузка

Когда вы используете активную загрузку, Eloquent получает все записи в массив и запускает ОДИН запрос к соответствующей таблице БД, передавая эти идентификаторы из этого массива. И затем, всякий раз, когда вы вызываете $book->author, он загружает результат из переменной, которая уже находится в памяти, и нет необходимости снова запрашивать базу данных.

Подождите, вам интересно, что это за инструмент для отображения запросов?

Всегда использовать панель отладки. И посеять поддельные данные.

Эта нижняя панель представляет собой пакет Отладочная панель Laravel. Все, что вам нужно сделать, чтобы использовать его, это установить его:

composer require barryvdh/laravel-debugbar --dev

И все, нижняя панель будет отображаться на всех страницах. Вам просто нужно включить отладку с помощью .env переменная APP_DEBUG=true, которая является значением по умолчанию для локальных сред.

Примечание о безопасности: убедитесь, что когда ваш проект будет запущен, у вас есть APP_DEBUG=false на этом сервере, иначе обычные пользователи вашего веб-сайта увидят панель отладки и ваш запросы к базе данных, что является огромной проблемой безопасности.

Конечно, я советую вам использовать Laravel Debugbar во всех ваших проектах. Но этот инструмент сам по себе не покажет очевидных проблем, пока у вас не будет больше данных о страницах. Таким образом, использование Debugbar — это только часть совета.

Кроме того, я также рекомендую иметь классы сеялки, которые будут генерировать поддельные данные.Желательно, чтобы было много данных, чтобы вы могли увидеть, как работает ваш проект «в реальной жизни», если вы представляете, как он будет успешно развиваться в ближайшие месяцы или годы.

Используйте классы Factory, а затем создайте более 10 000 записей для книг/авторов и других моделей:

class BookSeeder extends Seeder
{
    public function run()
    {
        Book::factory(10000)->create();
    }
}

Затем просмотрите веб-сайт и посмотрите, что показывает Debugbar.

Есть и другие альтернативы Laravel Debugbar:


Случай 2. Два важных символа.

Допустим, у вас одинаковые отношения hasMany между авторами и книгами, и вам нужно перечислить авторов с количеством книг для каждого из них.

Код контроллера может быть:

public function index()
{
    $authors = Author::with('books')->get();
 
    return view('authors.index', compact('authors'));
}

Затем в файле Blade вы выполняете цикл foreach для таблицы:

@foreach($authors as $author)
    <tr>
        <td>{{ $author->name }}</td>
        <td>{{ $author->books()->count() }}</td>
    </tr>
@endforeach

Выглядит законно, верно? И это работает. Но взгляните на данные панели отладки ниже.

Пример запроса N+1 2 — плохая производительность

Но подождите, вы скажете, что мы используем нетерпеливую загрузку, Author::with('books'), так почему же происходит так много запросов?

Потому что в Blade $author->books()->count() на самом деле не загружает это отношение из памяти.

  • $author->books() means the METHOD of relation
  • $author->books means the DATA eager loaded into memory

Таким образом, метод отношения будет запрашивать базу данных для каждого автора. Но если вы загрузите данные без символов (), они будут успешно использовать загруженные данные:

Пример запроса N+1 2 — хорошая производительность

Итак, следите за тем, что именно вы используете — метод связи или данные.

Обратите внимание, что в этом конкретном примере есть еще лучшее решение. Если вам нужны только рассчитанные агрегированные данные отношения без полных моделей, вам следует загрузить только агрегированные данные, например withCount:

// Controller:
$authors = Author::withCount('books')->get();
 
// Blade:
{{ $author->books_count }}

Пример запроса N+1 2 – лучшая производительность

В результате будет только ОДИН запрос к базе данных, а не два запроса. А также память не будет "загрязняться" данными о взаимоотношениях, так что немного оперативной памяти тоже будет сэкономлено.


Случай 3.«Скрытая» связь в Accessor.

Возьмем аналогичный пример: список авторов со столбцом, активен ли автор: "Да" или "Нет". Эта активность определяется тем, есть ли у автора хотя бы одна книга, и рассчитывается как accessor внутри модели Author.

Код контроллера может быть:

public function index()
{
    $authors = Author::all();
 
    return view('authors.index', compact('authors'));
}

Блейд-файл:

@foreach($authors as $author)
    <tr>
        <td>{{ $author->name }}</td>
        <td>{{ $author->is_active ? 'Yes' : 'No' }}</td>
    </tr>
@endforeach

Этот параметр is_active определен в модели Eloquent:

use Illuminate\Database\Eloquent\Casts\Attribute;
 
class Author extends Model
{
    public function isActive(): Attribute
    {
        return Attribute::make(
            get: fn () => $this->books->count() > 0,
        );
    }
}

Примечание: это новый синтаксис средств доступа Laravel, принятый в Laravel 9. Вы также можете использовать "старый" синтаксис определения метода getIsActiveAttribute(), он также будет работать в последней версии Laravel.

Итак, у нас загружен список авторов, и снова посмотрите, что показывает Debugbar:

Пример запроса N+1 3 — плохая производительность

Да, мы можем решить эту проблему, загружая книги в контроллер. Но в этом случае мой общий совет: избегайте использования отношений в методах доступа.Поскольку метод доступа обычно используется при отображении данных, и в будущем кто-то другой может использовать этот метод доступа в каком-либо другом файле Blade, и вы не сможете контролировать, как выглядит этот контроллер.

Другими словами, Accessor должен быть повторно используемым методом форматирования данных, поэтому вы не можете контролировать, когда и как они будут использоваться повторно.


20 книг, 21 запрос к базе данных. Точно N+1 снова.

Значит, пакет плохо справляется с производительностью? Ну нет, потому что в официальной документации написано, как получить медиафайлы для одного конкретного объекта модели, для одной книги, а не для списка. Эту часть списка вам нужно выяснить самостоятельно.

Если мы копнем немного глубже, в trait InteractsWithMedia пакета мы обнаружим эту связь, которая автоматически включается во все модели:

Итак, если мы хотим, чтобы все медиафайлы загружались вместе с книгами, нам нужно добавить with() в наш контроллер:

Это визуальный результат, всего 2 запроса.

use Spatie\MediaLibrary\HasMedia;
use Spatie\MediaLibrary\InteractsWithMedia;
 
class Book extends Model implements HasMedia
{
    use HasFactory, InteractsWithMedia;
 
    // ...
}

Пример запроса N+1 4 – хорошая производительность

public function index()
{
    $books = Book::all();
 
    return view('books.index', compact('books'));
}

Опять же, это пример не для того, чтобы показать этот пакет как плохой, а с советом, что вам нужно всегда проверять запросы к БД, независимо от того, исходят ли они из вашего кода или из внешнего пакета.

@foreach($books as $book)
    <tr>
        <td>
            {{ $book->title }}
        </td>
        <td>
            <img src="{{ $book->getFirstMediaUrl() }}" />
        </td>
    </tr>
@endforeach

Встроенное решение против запроса N+1

Теперь, когда мы рассмотрели все 4 примера, я дам вам последний совет: начиная с Laravel 8.43, фреймворк имеет встроенный детектор запросов N+1!

Помимо Laravel Debugbar для проверки, вы можете добавить код для предотвращения этой проблемы.

Вам нужно добавить две строки кода в app/Providers/AppServiceProvider.php:

Теперь, если вы запустите любую страницу, содержащую проблему с запросом N+1, вы увидите страницу с ошибкой, что-то вроде этого:

Запрос N+1 — Предотвращение ленивой загрузки

public function media(): MorphMany
{
    return $this->morphMany(config('media-library.media_model'), 'model');
}

Это покажет вам точный "опасный" код, который вы, возможно, захотите исправить и оптимизировать.

// Instead of:
$books = Book::all();
 
// You should do:
$books = Book::with('media')->get();

Обратите внимание, что этот код должен выполняться только на вашем локальном компьютере или тестовых/промежуточных серверах, действующие пользователи рабочих серверов не должны видеть это сообщение, так как это может быть проблемой безопасности.Вот почему вам нужно добавить такое условие, как ! app()->isProduction(), что означает, что ваше значение APP_ENV в файле .env не является "производственным".

Интересно, что эта профилактика не сработала, когда я пытался использовать последний пример библиотеки мультимедиа. Не уверен, что это из-за внешнего пакета или из-за полиморфных отношений.


use Illuminate\Database\Eloquent\Model;
 
class AppServiceProvider extends ServiceProvider
{
    public function boot()
    {
        Model::preventLazyLoading(! app()->isProduction());
    }
}

Comments

No comments yet
Yurij Finiv

Yurij Finiv

Full stack

Про мене

Professional Fullstack Developer with extensive experience in website and desktop application development. Proficient in a wide range of tools and technologies, including Bootstrap, Tailwind, HTML5, CSS3, PUG, JavaScript, Alpine.js, jQuery, PHP, MODX, and Node.js. Skilled in website development using Symfony, MODX, and Laravel. Experience: Contributed to the development and translation of MODX3 i...

Об авторе CrazyBoy49z
WORK EXPERIENCE
Контакты
Ukraine, Lutsk
+380979856297