• Час читання ~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, він завантажує результат зі змінної, яка вже є в пам’яті, не потрібно знову запитувати базу даних.

Зачекайте, вам цікаво, що це за інструмент для показу запитів?

Завжди використовуйте панель налагодження. І Seed Fake Data.

Ця нижня панель є пакетом Laravel Debugbar. Все, що вам потрібно зробити, щоб використовувати його, це встановити його:

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.

Візьмемо подібний приклад: список авторів із стовпцем, чи активний автор: «Так» чи «Ні». Ця активність визначається тим, чи є у автора принаймні одна книга, і обчислюється як засіб доступу всередині моделі 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 - погана продуктивність

Так, ми можемо вирішити проблему, завантаживши книги в Controller. Але в цьому випадку моя загальна порада: уникайте використання зв’язків у методах доступу.Оскільки засіб доступу зазвичай використовується під час відображення даних, і в майбутньому хтось інший може використовувати його в іншому файлі 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 для перевірки, ви можете додати код для запобігання цій проблемі.

Вам потрібно додати два рядки коду до 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