N+1 запросов в Laravel: как один foreach превращается в 101 SELECT и как это запретить

—

от автора

Классическая ситуация: страница со списком из 100 постов локально открывается мгновенно, а на проде с реальными данными — заметно тормозит. Профайлер показывает не один тяжёлый запрос, а сотню лёгких одинаковых. Это проблема N+1, и в Laravel её очень легко создать, потому что Eloquent по умолчанию загружает связи лениво.

Откуда берётся N+1

// app/Models/Post.phpclass Post extends Model{    public function author(): BelongsTo    {        return $this->belongsTo(User::class, 'user_id');    }    public function comments(): HasMany    {        return $this->hasMany(Comment::class);    }}
$posts = Post::latest()->take(100)->get();   // 1 запросforeach ($posts as $post) {    echo $post->author->name;                 // +1 запрос на КАЖДЫЙ пост}

Обращение к $post->author — это не поле, а магическое свойство, которое при первом доступе выполняет SELECT * FROM users WHERE id = ?. Итого 1 запрос за список + N запросов за авторов.

img1-diagram

img1-diagram

В Blade это выглядит ещё невиннее — {{ $post->author->name }} внутри @foreach, и никакого «запроса» глазами не видно.

Как увидеть проблему

Самый простой способ без пакетов — посчитать запросы:

use Illuminate\Support\Facades\DB;DB::enableQueryLog();$posts = Post::latest()->take(100)->get();foreach ($posts as $post) {    $post->author->name;}dump(count(DB::getQueryLog())); // 101

Или логировать каждый запрос в dev-окружении:

// AppServiceProvider::boot()DB::listen(function ($query) {    logger()->debug($query->sql, $query->bindings);});

Удобнее всего — Laravel Debugbar или Telescope: там сразу видно количество запросов и одинаковые повторяющиеся SQL.

Решение: eager loading

$posts = Post::with('author')->latest()->take(100)->get();foreach ($posts as $post) {    echo $post->author->name;   // запросов больше нет}

Теперь Eloquent делает ровно два запроса: посты и SELECT * FROM users WHERE id IN (...), после чего сам раскладывает авторов по моделям.

img2-before-after

img2-before-after

Полезные варианты:

// Несколько связей и вложенные связиPost::with(['author', 'comments.author'])->get();// Только нужные колонки (внешний ключ id обязателен!)Post::with('author:id,name')->get();// Нужно только количество — не грузим сами комментарииPost::withCount('comments')->get();   // $post->comments_count// Коллекция уже загружена — догружаем связь одним запросом$posts->load('author');$posts->loadMissing('author');        // только если ещё не загружена
Почему withCount лучше, чем $post->comments->count()

$post->comments->count() сначала загружает все комментарии каждого поста в память (и без with — ещё и N запросов), а потом считает элементы коллекции. $post->comments()->count() со скобками делает SELECT COUNT(*) — но тоже на каждой итерации, то есть снова N+1.

withCount('comments') добавляет подзапрос в основной SELECT, и вы получаете готовое поле comments_count за один запрос.

Ловушка: N+1 внутри accessor-ов и ресурсов

Даже если в контроллере стоит with('author'), N+1 может вернуться через API Resource:

class PostResource extends JsonResource{    public function toArray($request): array    {        return [            'id'       => $this->id,            'title'    => $this->title,            // если связь не загружена — здесь будет ленивый запрос            'author'   => new UserResource($this->whenLoaded('author')),            'comments' => $this->whenCounted('comments'),        ];    }}

whenLoaded() выводит связь только если она уже загружена, а не дёргает базу молча.

Запрещаем ленивую загрузку на уровне фреймворка

Лучший способ не ловить N+1 на проде — сделать так, чтобы он падал в разработке. С Laravel 8.43 есть Model::preventLazyLoading():

img3-prevent

img3-prevent
// app/Providers/AppServiceProvider.phpuse Illuminate\Database\Eloquent\Model;public function boot(): void{    Model::preventLazyLoading(! $this->app->isProduction());}

Теперь любое обращение к незагруженной связи в dev и тестах бросит LazyLoadingViolationException. На проде исключения не будет — можно вместо этого логировать:

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

А Model::shouldBeStrict() включает сразу три проверки: запрет ленивой загрузки, ошибку при обращении к отсутствующему атрибуту и ошибку при попытке молча присвоить неразрешённое для mass assignment поле.

Тот же N+1 — не только в Eloquent

Проблема не привязана к Laravel: тот же паттерн «запрос в цикле» встречается в чистом PHP с PDO, в Django ORM и в GraphQL-резолверах. В GraphQL её обычно решают через DataLoader, который собирает id из всех резолверов и делает один батч-запрос — короткое объяснение есть в шортсе:

https://www.youtube.com/shorts/o92gtJKXgMc

Чек-лист

  • Видите $model->relation внутри foreach/@foreach — проверьте, есть ли with().

  • Нужно число — withCount(), а не ->count() по связи.

  • В ресурсах — whenLoaded() / whenCounted().

  • В AppServiceProvider — Model::preventLazyLoading(! app()->isProduction()).

  • В тестах можно проверять количество запросов через DB::getQueryLog().


Если хочется системно разобрать Eloquent, связи, очереди и остальной Laravel — есть бесплатный курс с практикой: Laravel на Субконто.Академии.

ссылка на оригинал статьи https://habr.com/ru/articles/1101990/