Про N+1 в Laravel обычно пишут три строки про with() и расходятся. Я собрал небольшой стенд на Laravel 13 и SQLite, воспроизвёл проблему, посчитал запросы для каждого способа лечения и проверил, что «не ловится» режимом запрета ленивой загрузки. Отдельно разобрал места, где eager loading не помогает или сам создаёт нагрузку. Все числа в статье получены на этом стенде на искусственных данных.
Стенд и условия
Принцип N+1 объясняется за минуту: один запрос за списком и по одному запросу на каждую строку за связанной моделью. Сложнее другое: в разработке и в тестах с одной записью проблема, как правило, не видна, а в slow log не попадает, потому что каждый запрос по первичному ключу быстрый. Мало знать про with(), нужны стенд, на котором видно число запросов каждого приёма, и проверки, срабатывающие без человека. Я собрал такой стенд и прогнал на нём примеры, по которым приведены числа. Условия, чтобы числа можно было сопоставить со своими:
-
Laravel 13.39.0, PHP 8.4.27, SQLite 3.46.1, файловая база;
-
20 пользователей, 100 постов, 246 комментариев (у постов от нуля до пяти комментариев), 12 тегов, по два тега на пост;
-
данные сгенерированы фабриками: число запросов в примерах точное для этого набора, а время выполнения иллюстративное и зависит от железа и СУБД.
Модели минимальные: у поста есть автор, комментарии и теги, у комментария есть автор и пост.
// app/Models/Post.php
class Post extends Model
{
public function author(): BelongsTo
{
return $this->belongsTo(User::class, 'user_id');
}
public function comments(): HasMany
{
return $this->hasMany(Comment::class);
}
public function tags(): BelongsToMany
{
return $this->belongsToMany(Tag::class);
}
}
Воспроизведение: что именно делает ленивая загрузка
Возьмём типичную страницу списка: сто последних постов, у каждого выводится имя автора и число комментариев.
$posts = Post::latest()->limit(100)->get();
foreach ($posts as $post) {
echo $post->author->name;
echo $post->comments->count();
}
Обращение $post->author выглядит как чтение свойства, но у модели такого свойства нет. Eloquent видит, что связь не загружена, и выполняет запрос в этот момент, а результат кладёт в модель, так что повторное обращение к тому же посту запроса не делает. Но постов сто, и загрузка происходит для каждого. На стенде это 201 запрос: один за постами, сто за авторами и сто за комментариями. Первые из них (в SQLite идентификаторы в двойных кавычках, значения передаются параметрами):
select * from "posts" order by "created_at" desc limit 100;
select * from "users" where "users"."id" = ? limit 1; -- значение 5
select * from "comments" where "comments"."post_id" = ?; -- значение 100
select * from "users" where "users"."id" = ? limit 1; -- значение 20
...
Если оставить только автора, получается 101 запрос. Это классическая форма: 1 запрос за списком плюс N за связями, где N равно числу строк. Число запросов растёт вместе с размером выборки и с количеством связей в шаблоне: добавите в карточку вывод тегов, и к странице прибавится ещё N запросов.
Теперь о времени. Здесь всё работает в одном процессе с файловой SQLite, сетевой задержки нет, но разница видна и так. Медиана по 30 запускам: около 26,5 мс с ленивой загрузкой, около 5,3 мс с with() и около 3,2 мс, когда комментарии заменены на withCount(). Это иллюстрация на искусственных данных, а не бенчмарк. На СУБД на отдельном хосте к каждому из 201 запроса добавится сетевой обмен, и соотношение будет другим; какое именно, покажет только измерение на вашей инфраструктуре.
В разработке это не заметно по трём причинам: в локальной базе мало строк, ленивая загрузка устроена как фича и не выдаёт предупреждений, а каждый запрос по ключу быстрый, поэтому в журнале медленных запросов такие обращения, как правило, не появятся. Тормозит их количество, а не отдельный запрос.
Как увидеть проблему: от журнала запросов до своего детектора
Проще всего начать с журнала запросов соединения. Для подписки на каждый запрос в документации описан DB::listen, к нему мы ещё вернёмся:
DB::enableQueryLog();
$posts = Post::latest()->limit(100)->get();
foreach ($posts as $post) {
$post->author->name;
}
dump(count(DB::getQueryLog())); // 101 на моём стенде
Журнал удобен в тинкере и тестах, но вручную на каждой странице в него никто не смотрит. Автоматический вариант: слушатель DB::listen получает QueryExecuted для каждого запроса, тексты складываются в массив, а после обработки запроса в лог пишутся те, что повторились много раз. Значения параметров хранятся отдельно, поэтому запросы «по id = 5» и «по id = 20» считаются одним и тем же. Это и есть признак N+1:
// app/Providers/AppServiceProvider.php
use IlluminateDatabaseEventsQueryExecuted;
use IlluminateSupportFacadesDB;
public function boot(): void
{
if ($this->app->isProduction()) {
return;
}
$seen = [];
DB::listen(function (QueryExecuted $query) use (&$seen) {
$seen[$query->sql] = ($seen[$query->sql] ?? 0) + 1;
});
$this->app->terminating(function () use (&$seen) {
foreach ($seen as $sql => $times) {
if ($times >= 5) {
logger()->warning("Запрос выполнен {$times} раз за один запрос приложения", ['sql' => $sql]);
}
}
$seen = [];
});
}
Я проверил этот код на странице, которая отдаёт 50 постов через ресурс с обращением к автору. В логе появилась одна строка: «Запрос выполнен 50 раз за один запрос приложения» с текстом select * from "users" where "users"."id" = ? limit 1. Порог в пять повторов произвольный, подгоните его под проект. Код отключён на продакшене, а массив сбрасывается в конце, чтобы не накапливаться в долгоживущих процессах.
Есть и готовые инструменты. laravel-debugbar показывает запросы страницы с параметрами и временем; по README он включается при APP_DEBUG=true вне окружений production и testing, и ставить его на продакшен не рекомендуется. Telescope записывает SQL, параметры и время и помечает запросы дольше 100 мс как медленные; он годится там, где панели нет, например для API и очередей. laravel-query-detector ставится как dev-зависимость и сообщает о признаках N+1. Но всё это работает, пока человек смотрит на результат, поэтому дальше о способах, которые срабатывают сами.
Лечение: инструменты eager loading и их цена в запросах
with(): связи вместе с основным запросом
Главный инструмент — eager loading. Те же сто постов с автором и комментариями:
$posts = Post::with(['author', 'comments'])
->latest()
->limit(100)
->get();
Запросов три, и их число не зависит от того, сколько постов в выборке. Eloquent берёт ключи из первого результата и подгружает каждую связь одним запросом WHERE ... IN, а потом раскладывает строки по моделям в памяти:
select * from "posts" order by "created_at" desc limit 100;
select * from "users" where "users"."id" in (1, 2, 3, ..., 20);
select * from "comments" where "comments"."post_id" in (1, 2, 3, ..., 100);
Деталь из журнала: для целочисленных первичных ключей значения подставляются прямо в текст запроса, а не передаются параметрами (в исходниках это метод whereInMethod, выбирающий whereIntegerInRaw или обычный whereIn). На целых ключах предел числа параметров не грозит, но текст запроса растёт с выборкой, а для UUID сработает ограничение конкретной СУБД.
Выбор колонок и ловушка с внешним ключом
Колонки связи можно перечислить после двоеточия: with('author:id,name'). Документация требует всегда включать в список id и внешние ключи связи. Проверим, что будет, если забыть:
$posts = Post::with('comments:id,body')->limit(10)->get();
// комментариев загружено: 0
$posts = Post::with('comments:id,post_id,body')->limit(10)->get();
// комментариев загружено: 21
Первый вариант не бросает исключение и не пишет предупреждение, он просто возвращает пустые коллекции: без post_id Eloquent не может сопоставить комментарий с постом. Такую ошибку легко принять за «у постов нет комментариев».
Ограничения внутри eager loading и лимит на родителя
Внутрь with() можно передать замыкание с условиями: fn ($query) => $query->where('approved', true). А как загрузить «последние три комментария каждого поста»? Начиная с Laravel 11 limit(3) внутри замыкания ограничивает каждого родителя: поддержку добавил pull request 49695, перенёсший в ядро код пакета staudenmeir/eloquent-eager-limit. Для Laravel 10 и старше нужен этот пакет.
$posts = Post::with([
'comments' => fn ($query) => $query->latest()->limit(3),
])->latest()->limit(100)->get();
На стенде получилось два запроса, и ни у одного поста не оказалось больше трёх комментариев. Второй запрос построен на оконной функции row_number() over (partition by post_id order by created_at desc) с отбором строк, номер которых не больше трёх.
Оконные функции нужны СУБД: в SQLite они появились в версии 3.25. Судя по исходникам, для MySQL старше 8.0.11 (не MariaDB) Laravel использует обходной вариант на пользовательских переменных.
withCount, withExists и другие агрегаты
В первом примере комментарии грузились целиком ради одного числа. Если нужно количество или факт наличия, данные в PHP переносить не нужно:
$posts = Post::withCount('comments')
->withExists(['comments as has_unread' => fn ($query) => $query->whereNull('read_at')])
->limit(100)
->get();
// $post->comments_count, $post->has_unread
Это один запрос: подсчёт и проверка наличия выполняются подзапросами в списке колонок. Так же работают withSum, withAvg, withMin и withMax, результат лежит в атрибуте {связь}_{функция}_{колонка}.
load, loadMissing и loadCount: когда запрос уже выполнен
Если коллекция пришла из репозитория или сервиса и добавить with() в исходный запрос нельзя, eager loading можно выполнить для готовых моделей:
$posts = Post::latest()->limit(100)->get(); // 1 запрос
$posts->load('author'); // +1 запрос
$posts->loadMissing('author'); // +0, связь уже есть
$posts->loadMissing('tags'); // +1 запрос
$posts->loadCount('comments'); // +1 запрос
Всего на стенде четыре запроса. load() перезагружает связь всегда, а loadMissing() трогает только модели, у которых её ещё нет, поэтому в сервисе, который вызывают и с загруженными, и с незагруженными связями, безопаснее он.
Автоматическая подгрузка и chaperone
Две возможности свежих версий закрывают отдельные классы N+1 без правки каждого запроса.
Первая — chaperone(), она появилась в Laravel 11.22 (релиз). Вы загрузили посты с комментариями, а в цикле по комментариям читаете $comment->post. Eloquent не знает, что пост уже в памяти, и идёт в базу за каждым комментарием: на стенде 248 запросов (один за постами, один за комментариями и 246 за родителями). С chaperone() запросов два:
$posts = Post::with([
'comments' => fn ($comments) => $comments->chaperone(),
])->limit(100)->get();
foreach ($posts as $post) {
foreach ($post->comments as $comment) {
echo $comment->post->title; // берётся из памяти
}
}
То же можно указать прямо при объявлении связи: $this->hasMany(Comment::class)->chaperone().
Вторая возможность — автоматическая подгрузка связей. Её добавили в Laravel 12.8 (pull request 53655, релиз), и описана она в документации. Достаточно одной строки в boot() провайдера:
Model::automaticallyEagerLoadRelationships();
Обращение к незагруженной связи у одной модели коллекции загружает её сразу для всех моделей. Цикл по ста постам с обращением к автору и комментариям сделал 3 запроса вместо 201. Для одной коллекции режим включается вызовом $posts->withRelationshipAutoloading(): на стенде цикл по автору дал два запроса.
У режима есть цена. Он скрывает проблему, а не сообщает о ней, и грузит связь для всей коллекции, даже если нужна одной модели. Кроме того, я проверил его вместе с запретом ленивой загрузки, о котором ниже: при включённых обоих режимах исключение не бросается, потому что автоподгрузка срабатывает раньше проверки. Придётся выбирать между «узнавать об N+1» и «молча лечить N+1». Для разработки и тестов я выбрал бы первое, для страховки на продакшене допустимо второе.
Запрет ленивой загрузки и что он реально ловит
preventLazyLoading и обработчик нарушений
Возможность появилась в Laravel 8.43 (релиз) и описана в разделе документации Preventing Lazy Loading. Включается в boot() провайдера, обычно только вне продакшена:
use IlluminateDatabaseEloquentModel;
public function boot(): void
{
Model::preventLazyLoading($this->app->isProduction() === false);
}
Теперь обращение к незагруженной связи бросает IlluminateDatabaseLazyLoadingViolationException: «Attempted to lazy load [author] on model [AppModelsPost] but lazy loading is disabled». Если исключение нежелательно, поведение можно подменить, например писать нарушения в лог:
Model::preventLazyLoading();
Model::handleLazyLoadingViolationUsing(function (Model $model, string $relation) {
logger()->warning('Lazy loading', [
'model' => $model::class,
'relation' => $relation,
]);
});
Цикл по пяти постам с обращением к автору вызвал обработчик пять раз. С собственным обработчиком исключение не бросается совсем, даже в разработке, так что если нужно и логировать, и падать локально, учитывайте окружение внутри обработчика.
Чего запрет не замечает
Я прочитал исходники и проверил поведение на стенде. Флаг запрета выставляется только моделям, полученным выборкой из нескольких строк: в Builder::hydrate() стоит условие count($items) > 1. Модели, только что созданные в этом же запросе, исключения тоже не вызывают. Поэтому Post::find(1)->author не бросает исключения, а выборка, случайно вернувшая одну строку, ничего не заметит. Тест с одной записью от фабрики не поймает N+1 вообще: я проверил это HTTP-тестом, где с тремя постами эндпоинт отвечает 500 из-за исключения, а с одним постом отвечает 200.
Второй пробел: запрет срабатывает на ленивой загрузке через динамическое свойство, а явный запрос через метод связи нарушением не считается. Вот аксессор, который выглядит безобидно:
protected function isPopular(): Attribute
{
return Attribute::get(fn () => $this->comments()->count() > 3);
}
Скобки после comments дают построитель запроса, а не коллекцию, поэтому запрос уходит в базу всегда, даже если комментарии загружены через with(). На стенде цикл по 50 постам сделал 51 запрос, а с предзагруженными комментариями 52. С включённым запретом исключения нет: на 100 постах 102 запроса и ни одного предупреждения. Исправление — читать агрегат, загруженный заранее:
protected function isPopular(): Attribute
{
return Attribute::get(fn () => ($this->comments_count ?? 0) > 3);
}
Вместе с withCount('comments') это один запрос на всю страницу. Если comments_count не загружен, аксессор молча вернёт false, и это стоит учитывать.
shouldBeStrict: что входит в набор
Model::shouldBeStrict() по исходникам включает сразу три режима: preventLazyLoading, preventSilentlyDiscardingAttributes (при fill() атрибута вне разрешённых бросается MassAssignmentException) и preventAccessingMissingAttributes (чтение атрибута, не попавшего в выборку, бросает MissingAttributeException):
$post = Post::select('id')->first();
$post->title;
// MissingAttributeException: The attribute [title] either does not exist
// or was not retrieved for model [AppModelsPost].
Последний режим страхует от обращения к полю, которого нет в with('author:id,name'). В существующем проекте все три режима сразу найдут и проблемы, не связанные с N+1, поэтому внедрять их можно по одному.
Места, где N+1 прячется за красивым кодом
API Resources
Ресурс не знает, как получена модель, и обращение к связи в toArray() даёт запрос на каждый элемент. Помогают whenLoaded() и whenCounted() из документации по ресурсам: связь попадает в ответ, только если её загрузил вызывающий код.
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'title' => $this->title,
'author' => $this->whenLoaded('author', fn () => $this->author->only(['id', 'name'])),
'comments_count' => $this->whenCounted('comments'),
];
}
Контроллер сам решает, что грузить: Post::with('author')->withCount('comments'). Этот ресурс я использовал в тесте ниже. Обратная сторона: если контроллер забудет with(), поле author просто исчезнет из ответа, и ошибку увидит только тот, кто читает JSON. Ещё одна причина покрывать такие эндпоинты тестами.
Политики и проверки прав в цикле
Проверку «автор поста равен текущему пользователю» часто пишут через связь: $post->author->id === $user->id. В цикле по ста постам это 101 запрос, а сравнение $post->user_id === $user->id не делает ни одного дополнительного, потому что ключ уже лежит в модели. Перед обращением к связи в политике или в Blade-шаблоне проверьте, нет ли нужного значения в самой строке.
Очереди и повторно извлекаемые модели
По документации по очередям, в очередь кладётся идентификатор модели, а при выполнении модель достаётся из базы заново вместе с загруженными связями. Ограничения, наложенные на связь до постановки в очередь, при восстановлении не применяются, связи берутся целиком. Поэтому если джобе нужна особая выборка, уточняйте её в handle(), а если связи не нужны, вызывайте withoutRelations(), чтобы не раздувать полезную нагрузку.
Автоматическая проверка в тестах и CI
Запрет ленивой загрузки в тестах — первая линия защиты, но он слеп к спискам из одной записи. Вторая линия — проверка числа запросов относительно размера данных: не угадывать допустимое количество, а убедиться, что оно не растёт.
use AppModelsPost;
use IlluminateFoundationTestingRefreshDatabase;
use IlluminateSupportFacadesDB;
use TestsTestCase;
class PostsIndexQueryCountTest extends TestCase
{
use RefreshDatabase;
private function countQueries(callable $action): int
{
DB::flushQueryLog();
DB::enableQueryLog();
$action();
$count = count(DB::getQueryLog());
DB::disableQueryLog();
return $count;
}
public function test_number_of_queries_does_not_depend_on_data_size(): void
{
Post::factory()->count(3)->create();
$small = $this->countQueries(fn () => $this->getJson('/api/posts')->assertOk());
Post::factory()->count(27)->create();
$large = $this->countQueries(fn () => $this->getJson('/api/posts')->assertOk());
$this->assertSame($small, $large);
}
}
Сначала создаются три поста и замеряется число запросов, затем добавляется ещё 27 и замер повторяется. Фабрика создаёт каждому посту отдельного автора. Если связи загружены правильно, числа совпадают: на стенде эндпоинт с with('author') и withCount('comments') делает два запроса и на трёх постах, и на тридцати. Для «наивного» эндпоинта без eager loading получилось 4 и 31. Обратную проверку я тоже прогнал: тест должен уметь падать, иначе он ничего не доказывает.
Запрет ленивой загрузки в тестах удобнее включать для всего набора сразу, в базовом классе или в провайдере для окружения testing. С ним наивный эндпоинт на трёх постах отвечает 500 вместо 200, и assertOk() падает. На одном посте тест всё равно пройдёт, поэтому два механизма дополняют друг друга. В CI ничего специального добавлять не нужно: если число запросов выросло, сборка краснеет, как от любого упавшего теста. Держите такие проверки на эндпоинтах, которые отдают списки.
Когда eager loading сам становится проблемой
Исправление N+1 легко довести до другой крайности.
Связь слишком большая
Загружать все комментарии ради count() расточительно. Для ориентира: 246 моделей комментариев с короткими полями заняли на стенде около 305 КиБ, порядка 1,2 КиБ на модель. Память растёт линейно с числом моделей и длиной полей, и на больших таблицах это упирается в memory_limit. Агрегат нужно считать в базе: withCount, withSum, withExists.
Если связь нужна целиком, а родителей много, обрабатывайте данные порциями. chunkById() и lazyById() описаны в документации, eager loading работает внутри каждой порции:
Post::with('author:id,name')->chunkById(30, function ($posts) {
foreach ($posts as $post) {
// обработка
}
});
На ста постах с порцией 30 получилось 8 запросов: четыре порции по два запроса. Запросов больше, зато память ограничена размером порции.
Сортировка по полю связи
Eager loading грузит связь отдельным запросом после основного, поэтому отсортировать по имени автора с его помощью нельзя. Нужен подзапрос или соединение, а with() остаётся ради вывода данных:
$posts = Post::query()
->orderBy(User::select('name')->whereColumn('users.id', 'posts.user_id'))
->with('author:id,name')
->paginate(20);
На стенде это три запроса: счётчик для пагинации, страница постов и авторы для двадцати постов.
Связи по умолчанию в модели
Свойство $with заставляет модель всегда подгружать указанные связи: удобно на страницах и вредно в фоновых задачах, где связь не нужна. Отключить её можно методами without() и withOnly(), но об этом нужно помнить, поэтому я предпочитаю явный with() рядом с кодом, который связь использует.
Индекс по внешнему ключу: последний шаг
После исправления N+1 остаётся один where post_id in (...) по таблице комментариев, и его скорость зависит от индекса по post_id. СУБД относятся к внешним ключам по-разному:
-
MySQL с InnoDB требует индекс на колонках внешнего ключа и создаёт его автоматически, если его нет;
-
PostgreSQL, как сказано в документации, объявление внешнего ключа индекс на ссылающихся колонках не создаёт, а в тексте прямо сказано, что индекс по таким колонкам часто стоит добавить самостоятельно.
На SQLite я посмотрел настоящий план. Миграция foreignId('post_id')->constrained() индекса на post_id не создала:
explain query plan select * from comments where post_id in (1, 2, 3);
-- SCAN comments
После $table->index('post_id') тот же запрос выглядит иначе:
-- SEARCH comments USING INDEX comments_post_id_index (post_id=?)
Индекс добавляется отдельной миграцией:
Schema::table('comments', function (Blueprint $table) {
$table->index('post_id');
});
У построителя запросов есть метод explain(), но на SQLite он возвращает инструкции виртуальной машины, а не план, поэтому там я использовал explain query plan через DB::select().
Вместо заключения
N+1 в Eloquent — не дефект фреймворка, а следствие того, что обращение к связи выглядит как чтение свойства. Вылечить его просто, труднее не допустить возврата. На моём стенде надёжнее всего оказалось сочетание трёх вещей: запрет ленивой загрузки в разработке и тестах, тест на неизменность числа запросов и понимание того, чего запрет не замечает. Числа в статье получены на описанном стенде; на другой версии Laravel число запросов в простых случаях будет тем же, а время и планы запросов нужно измерять у себя. Какие нестандартные источники N+1 встречались в ваших проектах, например в пакетах, сериализаторах или событиях моделей? Буду рад обсудить в комментариях.
Автор: nikita-practice


