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 на итерацию
}
Что происходит:
- SELECT * FROM posts LIMIT 100.
- SELECT * FROM users WHERE id = 1.
- SELECT * FROM users WHERE id = 2.
- ... до 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
}
Что происходит:
- SELECT * FROM posts LIMIT 100.
- 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();
Что даёт:
- posts.
- comments.
- 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().