Ваш код на Laravel работает отлично, но ваш API тормозит. Вот одна из распространенных причин этого и способы ее устранения.
Вы создаете приложение на Laravel.
На локальном компьютере все работает идеально. Ваши контроллеры выглядят аккуратно, связи Eloquent работают правильно, а API возвращает ожидаемый ответ.
Затем ваше приложение запускается в продакшене.
Внезапно один из эндпоинтов API начинает отвечать несколько секунд. Страница выполняет сотни запросов к базе данных, и производительность падает по мере роста объема данных.
Вы изучаете контроллер и находите код, который выглядит абсолютно нормально:
$posts = Post::all();
foreach ($posts as $post) {
echo $post->user->name;
}Ничего подозрительного.
Но «под капотом» Laravel может выполнять гораздо больше запросов к базе данных, чем вы ожидaете.
Добро пожаловать в проблему N+1 запросов.
Что такое проблема N+1 запросов?
Представьте, что вы создаете блог. У вас есть 100 постов, и каждый пост принадлежит какому-то пользователю.
Вы хотите вывести каждый пост вместе с именем его автора.
Ваш код получает посты и обращается к связанному пользователю:
$posts = Post::all();
foreach ($posts as $post) {
echo $post->user->name;
}Проблема в том, что по умолчанию Laravel использует ленивую загрузку (lazy loading). Когда вы обращаетесь к $post->user, Eloquent может выполнить еще один запрос для получения этой связи.
Для 100 постов запросы к базе данных будут выглядеть примерно так:
SELECT * FROM posts; SELECT * FROM users WHERE id = 1; SELECT * FROM users WHERE id = 2; SELECT * FROM users WHERE id = 3; -- And many more...
Результат?
- 1 запрос для получения постов.
- 100 дополнительных запросов для получения их пользователей.
Итого: 101 запрос.
Это и есть проблема N+1 запросов.
Здесь N обозначает количество постов, полученных первым запросом.
Точное количество запросов зависит от ваших данных и связей, но суть остается прежней: многократное запросы связанных данных вместо их эффективной загрузки.
Решение: жадная загрузка (Eager Loading)
Laravel предлагает простое решение под названием жадная загрузка.
Вместо того чтобы получать связи по одной, вы можете указать Eloquent загрузить их заранее.
Замените это:
$posts = Post::all();
На это:
$posts = Post::with('user')->get();
foreach ($posts as $post) {
echo $post->user->name;
}Вот и все.
В этом простом примере Laravel обычно выполняет два запроса вместо 101:
SELECT * FROM posts; SELECT * FROM users WHERE id IN (1, 2, 3, ...);
Сначала Laravel получает посты, затем загружает связанных с ними пользователей отдельным запросом и сопоставляет результаты с постами.
Вместо того чтобы делать запрос к базе данных для каждого поста, Eloquent может получить связанных пользователей пакетом.
Одно небольшое изменение может кардинально сократить количество лишних запросов к базе данных.
Как работает жадная загрузка?
Давайте посмотрим на типичную связь в Eloquent.
Наша модель Post принадлежит пользователю:
class Post extends Model
{
public function user()
{
return $this->belongsTo(User::class);
}
}Без жадной загрузки:
$posts = Post::all();
Каждый раз, когда вы обращаетесь к $post->user, Laravel может запрашивать у базы данных информацию об этом пользователе.
С жадной загрузкой:
$posts = Post::with('user')->get();Laravel загружает связь до того, как к ней обратится ваш цикл.
Именно поэтому понимание связей Eloquent так важно — не только для написания чистого кода, но и для создания производительных приложений.
Что делать, если нужно несколько связей?
В реальных приложениях на Laravel редко бывает всего одна связь.
Представьте себе интернет-магазин, где вам нужно отобразить заказы, клиентов и товары.
Вы можете подгрузить несколько связей одновременно следующим образом:
$orders = Order::with([
'user',
'products',
])->get();Теперь вы можете обращаться к связанным данным:
foreach ($orders as $order) {
echo $order->user->name;
foreach ($order->products as $product) {
echo $product->name;
}
}Eloquent может получить эти связи пачками, а не отправлять новый запрос для каждого заказа.
Вы также можете загружать вложенные связи:
$posts = Post::with([
'user',
'comments.user',
])->get();Это загрузит посты, их авторов, комментарии и пользователей, связанных с этими комментариями.
Вложенная жадная загрузка особенно полезна при создании API, возвращающих структурированные JSON-ответы.
Не запрашивайте больше данных, чем вам нужно
Жадная загрузка помогает сократить количество запросов, но загрузка каждой связанной записи не всегда является лучшим подходом.
Допустим, вы хотите вывести список постов с количеством комментариев к каждому посту.
Вам не нужно вытягивать каждый комментарий только для того, чтобы их посчитать.
Вместо этого:
$posts = Post::with('comments')->get();Используйте:
$posts = Post::withCount('comments')->get();Теперь вы можете получить доступ к количеству комментариев:
foreach ($posts as $post) {
echo $post->comments_count;
}Laravel добавляет атрибут comments_count, не загружая все модели комментариев в память.
Это особенно полезно, когда у поста есть тысячи комментариев.
Цель состоит не просто в том, чтобы уменьшить количество запросов. Цель — извлекать только те данные, которые действительно нужны вашему приложению.
Совмещайте жадную загрузку с пагинацией
Рассмотрим такой запрос:
$posts = Post::with('user')->get();Он позволяет избежать проблемы N+1, но что произойдет, когда в вашей базе данных окажется 100 000 постов?
Вы все равно получите огромную коллекцию записей.
Сама по себе жадная загрузка эту проблему не решает.
Для API или страницы со списками пагинация обычно является лучшим выбором:
$posts = Post::with('user')
->latest()
->paginate(20);Теперь Laravel получает одну страницу постов и загружает связанных с ними пользователей.
Это помогает контролировать использование памяти и избегать обработки лишних записей.
Для очень больших наборов данных Laravel также поддерживает постраничную навигацию по курсору (cursor pagination) через cursorPaginate(), что может быть полезно при навигации по записям в стабильном порядке.
Выбирайте стратегию пагинации в зависимости от требований вашего приложения.
Выбирайте только те колонки, которые вам нужны
Еще одна полезная оптимизация — ограничение полей, выбираемых из базы данных.
Например:
$posts = Post::select([
'id',
'user_id',
'title',
])
->with('user:id,name')
->latest()
->paginate(20);Здесь мы выбираем только те поля, которые необходимы для отображения заголовка поста и имени автора.
Обратите внимание, что мы включаем user_id в запрос поста и id в запрос пользователя. Эти ключи важны, так как Eloquent использует их для сопоставления связанных записей.
Этот подход особенно полезен при работе с большими таблицами или ответами API, которым не нужны все столбцы подряд.
Как обнаружить N+1 запросы?
Вместо того чтобы гадать, почему ваше приложение работает медленно, проверьте реальные запросы к базе данных.
Вот три практических варианта.
1. Laravel Debugbar
Laravel Debugbar помогает отслеживать запросы, выполняемые при загрузке страницы. Ищите повторяющиеся запросы, которые отличаются только ID связанной записи.
2. Laravel Telescope
Telescope помогает исследовать запросы и обращения к базе данных во время разработки, упрощая обнаружение паттернов повторяющихся запросов.
3. Логирование запросов к базе данных
В Laravel есть слушатель запросов, который помогает исследовать производительность базы данных:
use Illuminate\Database\Events\QueryExecuted;
use Illuminate\Support\Facades\DB;
DB::listen(function (QueryExecuted $query) {
logger()->debug('Database query', [
'sql' => $query->sql,
'time_ms' => $query->time,
]);
});Используйте логирование запросов в продакшене с осторожностью. Логирование каждого SQL-запроса может создавать серьезную нагрузку, к тому же логи не должны раскрывать конфиденциальную информацию.
Главный урок прост: сначала измеряйте, потом оптимизируйте.
Предотвращение ленивой загрузки во время разработки
В Laravel также есть функция, помогающая разработчикам выявлять непредвиденную ленивую загрузку.
Вы можете включить ее в своем AppServiceProvider:
use Illuminate\Database\Eloquent\Model;
public function boot(): void
{
Model::preventLazyLoading(
! $this->app->isProduction()
);
}Эта конфигурация помогает выявлять связи, к которым обращаются без явной предварительной загрузки в непроизводственных средах.
Например, обращение к $post->user без жадной загрузки может вызвать исключение нарушения ленивой загрузки (lazy-loading violation).
Это особенно полезно при работе над крупными проектами на Laravel, где безобидное на первый взгляд обращение к связи может породить лишние запросы.
Проверьте это поведение на версии Laravel вашего приложения и в вашей среде разработки, прежде чем полагаться на него в ежедневной работе.
Всегда ли жадная загрузка улучшает производительность?
Не обязательно.
Рассмотрим такой запрос:
$posts = Post::with([
'user',
'comments',
'likes',
'categories',
'tags',
])->get();Что, если на вашей странице выводятся только заголовок поста и имя автора?
Загрузка комментариев, лайков, категорий и тегов приведет к вытягиванию ненужных данных.
Лучший подход — загружать только то, что требуется для текущей страницы:
$posts = Post::with('user:id,name')
->select([
'id',
'user_id',
'title',
])
->latest()
->paginate(20);Хорошая оптимизация запросов — это баланс.
Вы хотите сократить количество лишних вызовов базы данных, но при этом не подгружать столько связанных данных, чтобы использование памяти и время отклика ухудшились.
Практический чек-лист для разработчиков на Laravel
Прежде чем выкатить приложение на Laravel в продакшен, задайте себе вопросы:
- Обращаюсь ли я к связям Eloquent внутри циклов?
- Стоит ли мне использовать with() или вложенную жадную загрузку?
- Могу ли я использовать withCount() вместо вытягивания каждой связанной записи?
- Не выбираю ли я лишние колонки из базы данных?
- Нужно ли разбивать результаты на страницы?
- Проверял ли я реальные SQL-запросы?
- Может ли предотвращение ленивой загрузки помочь выявить ошибки на этапе разработки?
Эти небольшие проверки помогут не допустить попадания проблем с производительностью в продакшен.
Заключение
Проблема N+1 запросов — это одна из тех проблем производительности в Laravel, которые могут прятаться на самом видном месте.
Ваш код может быть читаемым. Ваши связи Eloquent могут работать правильно. Ваш API может возвращать именно то, что вы ожидаете.
И тем не менее, ваше приложение может выполнять сотни ненужных запросов к базе данных.
К счастью, решение часто начинается с понимания того, как Eloquent загружает связи.
Запомните эти три концепции:
Ленивая загрузка (Lazy Loading) — извлекает связь в момент обращения к ней.
Жадная загрузка (Eager Loading) — извлекает связи заранее с помощью таких методов, как with().
Оптимизация запросов (Query Optimization) — извлекает только те данные, которые нужны вашему приложению, используя такие техники, как withCount(), выборку конкретных колонок и пагинацию.
В следующий раз, когда ваш Laravel API начнет тормозить, не спешите винить сервер или увеличивать его ресурсы.
Сначала проверьте запросы к базе данных.
Иногда обычного with() достаточно, чтобы избавиться от неэффективного паттерна запросов.
Пишите чистый код. Измеряйте производительность. Оптимизируйте на основе фактов.
С какими еще проблемами производительности Laravel вы сталкивались в продакшене? Поделитесь своим опытом в комментариях.
Успешного кодинга! 🚀
Комментарии (0)
Пока нет комментариев — будьте первым.