Урок 01

N+1 проблема: диагностика и решение

Что такое N+1

N+1 — паттерн, когда вместо одного запроса делается N + 1.

Пример:

  • 1 запрос — список постов.
  • N запросов — авторы каждого поста.

Что даёт: 100 постов → 101 SQL.

Правило: N+1 — главная причина медленного Eloquent.

Как выглядит в коде

Плохо:

$posts = Post::limit(100)->get();

foreach ($posts as $post) {
    echo $post->author->name;   // +1 SQL на итерацию
}

Что происходит:

  1. SELECT * FROM posts LIMIT 100.
  2. SELECT * FROM users WHERE id = 1.
  3. SELECT * FROM users WHERE id = 2.
  4. ... до 100.

Итого: 101 SQL.

Как выглядит в логах

Laravel log:

SELECT * FROM posts LIMIT 100;
SELECT * FROM users WHERE id = 1;
SELECT * FROM users WHERE id = 2;
SELECT * FROM users WHERE id = 3;
... (ещё 97)

Правило: одинаковые запросы с разными ID — точно N+1.

Как найти

1. Telescope:

  • Queries вкладка.
  • Смотри дубли SQL с разными bindings.

2. Pulse:

  • Slow queries.
  • Queries by SQL.

3. Логи:

DB::listen(function ($query) {
    if (str_contains($query->sql, 'where "id" = ?')) {
        Log::warning('Possible N+1', ['sql' => $query->sql]);
    }
});

Правило: любая связь без with() — кандидат.

Как решить: with()

Eager loading:

$posts = Post::with('author')->limit(100)->get();

foreach ($posts as $post) {
    echo $post->author->name;   // 0 SQL
}

Что происходит:

  1. SELECT * FROM posts LIMIT 100.
  2. SELECT * FROM users WHERE id IN (1, 2, 3, ..., 100).

Итого: 2 SQL.

Правило: with() — первое решение N+1.

Несколько связей

$posts = Post::with(['author', 'comments', 'tags'])->get();

Что даёт: по одному запросу на связь.

Итого: 1 + 3 = 4 SQL.

Вложенные связи

$posts = Post::with('comments.author')->get();

Что даёт:

  1. posts.
  2. comments.
  3. users (авторы комментариев).

Правило: точка — путь вложенности.

withCount()

Проблема: $post->comments()->count() — N+1.

Решение:

$posts = Post::withCount('comments')->get();

foreach ($posts as $post) {
    echo $post->comments_count;   // 0 SQL
}

Что даёт: один подзапрос вместо N.

Правило: счётчики — только через withCount.

withSum, withAvg

$posts = Post::withSum('views', 'count')
             ->withAvg('ratings', 'value')
             ->get();

$post->views_sum;
$post->ratings_avg_value;

Что даёт: агрегаты без N+1.

load()

Когда: модель уже получена.

$post = Post::find(1);

if ($needAuthor) {
    $post->load('author');
}

Правило: with — при выборке, load — после.

loadMissing()

Умный вариант:

$post->loadMissing('author');   // если не загружено

Что даёт: не грузит дважды.

Правило: loadMissing — безопаснее.

Условный eager

$posts = Post::query()
    ->when($includeComments, fn ($q) => $q->with('comments'))
    ->get();

Правило: грузить только нужное.

Eager в моделях

protected $with:

class Post extends Model
{
    protected $with = ['author'];
}

⚠️ Грузит всегда, даже если не нужно.

Правило: $with — редко оправдан.

preventLazyLoading()

Главный инструмент:

// app/Providers/AppServiceProvider.php
use Illuminate\Database\Eloquent\Model;

public function boot(): void
{
    Model::preventLazyLoading(!app()->isProduction());
}

Что даёт: бросает исключение при lazy loading.

Правило: в dev — обязательно включить.

preventLazyLoading в проде

Логировать, не бросать:

Model::preventLazyLoading();
Model::handleLazyLoadingViolationUsing(function ($model, $relation) {
    Log::warning('Lazy loading', [
        'model' => get_class($model),
        'id' => $model->id,
        'relation' => $relation,
    ]);
});

Правило: в проде — логировать, не падать.

preventSilentlyDiscardingAttributes()

Соседний по смыслу:

Model::preventSilentlyDiscardingAttributes(!app()->isProduction());

Что даёт: падает при попытке заполнить не в $fillable.

Правило: экономит часы отладки.

Инструменты

Кто ловит N+1:

  • Telescope — Queries.
  • Pulse — Slow queries.
  • Laravel Debugbar — Queries tab.
  • Clockwork — DevTools.
  • beyondcode/laravel-query-detector — пакет.

laravel-query-detector

Установка:

composer require --dev beyondcode/laravel-query-detector

Что даёт: логирует N+1 автоматически.

Правило: для dev — удобно.

Сложные случаи

1. Полиморфные связи:

$posts = Post::with('comments.commentable')->get();

2. Морф-многие-ко-многим:

$posts = Post::with('tags')->get();

3. Has-many-through:

$countries = Country::with('posts.comments')->get();

Правило: dot-нотация работает на любую глубину.

Частичный eager

$posts = Post::with(['author:id,name'])->get();

Что даёт: только нужные поля.

⚠️ Всегда включать primary key.

Правило: селект в with — всегда с id.

Кэширование результата

Если данные статичные:

$posts = Cache::remember('posts:with-authors', 3600, function () {
    return Post::with('author')->limit(100)->get();
});

Что даёт: 0 SQL после прогрева.

Правило: N+1 + кэш = двойной win.

Замер

До:

DB::enableQueryLog();
// код
$queries = DB::getQueryLog();
// 101 запрос, 2.3 сек

После:

2 запроса, 45 мс

Правило: N+1 фикс — 50× ускорение на больших коллекциях.

Практика

1. Включите preventLazyLoading в dev.

2. Пройдите по сайту — где падает?

3. Откройте Telescope → Queries.

4. Найдите N+1.

5. Добавьте with().

6. Замените count() на withCount().

7. Проверьте после.

8. Прогоните тесты.

9. Включите логирование в проде.

10. Сделайте чек в CI.

Частые ошибки

1. $with на модель:

Грузит всегда

Решение: убрать, грузить явно.

2. Забыли вложенную связь:

Частичный N+1

Решение: a.b.c.

3. withCount вместо count():

N+1 на счётчиках

Решение: withCount.

4. preventLazyLoading в проде:

500-е

Решение: только логировать.

5. Селект без id:

Связь пустая

Решение: id в select.

6. load вместо with:

Лишний запрос

Решение: with до.

7. Игнорировать dev-исключения:

N+1 в проде

Решение: чинить в dev.

8. $with на всех моделях:

Лишние запросы

Решение: явно.

Итог

  • N+1 — главная проблема Eloquent.
  • 1 + N запросов.
  • with() — основное решение.
  • withCount() — счётчики.
  • loadMissing() — безопасный.
  • preventLazyLoading() — в dev.
  • В проде — логировать.
  • Telescope / Pulse — поиск.
  • Dot-нотация — вложенные.
  • Фикс — 50× быстрее.

Дальше — eager loading: with(), load().