Doctrine в Symfony: N+1, DTO и партиалы на живом проекте
Список из 50 заказов делает 51 запрос к базе, а выгрузка в CSV съедает всю память. Разбираем, чем платить за ленивую загрузку Doctrine и как её приручить в Symfony.

Список из 50 заказов на странице отрисовался за 900 мс, и в Symfony Profiler вы видите 153 SQL-запроса. Причина не в базе и не в железе — это Doctrine честно догружает связанные сущности по одной, ровно тогда, когда вы к ним обращаетесь в шаблоне. Разберём три рабочих приёма против самых частых проблем ORM в Symfony: N+1, лишние поля в SELECT и раздувание памяти на больших выборках. Примеры проверены на Symfony 7.x и Doctrine ORM 3.x, для 2.x синтаксис в показанных местах совпадает.
Проблема, из-за которой всё начинается: ленивая загрузка
По умолчанию Doctrine грузит связи лениво (fetch="LAZY"). Вы запросили заказы — получили 50 объектов Order. Как только в цикле обращаетесь к $order->getCustomer()->getName(), ORM делает отдельный SELECT на каждого покупателя. Пятьдесят заказов — плюс пятьдесят запросов. Это и есть N+1.
Проблема не в лени как таковой — она полезна, когда связь нужна не всегда. Проблема в том, что в списках и отчётах она нужна почти всегда, а платите вы за неё round-trip-ами к базе.
Ленивая загрузка не ошибка проектирования Doctrine. Ошибка — оставить её по умолчанию там, где вы заведомо перебираете связь в цикле.
Приём 1: JOIN FETCH против N+1
Самый прямой способ — сказать Doctrine достать связанные сущности одним запросом. Делается это через addSelect + join в QueryBuilder или через JOIN в DQL. Ключевой момент: недостаточно просто сделать JOIN — нужен именно addSelect присоединённой сущности, иначе Doctrine её гидрирует лениво заново.
// src/Repository/OrderRepository.php
// Symfony 7.x, Doctrine ORM 3.x
public function findRecentWithCustomer(int $limit = 50): array
{
return $this->createQueryBuilder('o')
// без addSelect('c') покупатель догрузится лениво — N+1 останется
->addSelect('c')
->innerJoin('o.customer', 'c')
->orderBy('o.createdAt', 'DESC')
->setMaxResults($limit)
->getQuery()
->getResult();
}
Теперь 50 заказов и их покупатели приходят одним запросом. В Profiler вместо 51 строки будет одна.
Осторожно с fetch-join и коллекциями
Если джойните коллекцию (OneToMany, например позиции заказа), setMaxResults перестаёт работать так, как вы ожидаете: лимит применяется к строкам результата после джойна, а не к заказам. Один заказ с тремя позициями даёт три строки. Doctrine это умеет обрабатывать через Paginator с включённым fetch-join, но помнить о ловушке нужно.
use Doctrine\ORM\Tools\Pagination\Paginator;
$query = $this->createQueryBuilder('o')
->addSelect('i')
->leftJoin('o.items', 'i')
->setMaxResults(20)
->getQuery();
// второй аргумент true — fetchJoinCollection, чтобы лимит считался по заказам
$paginator = new Paginator($query, true);
Приём 2: не тащите всю сущность, если нужны три поля
Для списка вам часто не нужен полный объект Order со всеми полями и связями — нужны id, номер, сумма, имя покупателя. Гидрация полной сущности стоит памяти и времени: Doctrine создаёт объект, кладёт его в UnitOfWork и следит за изменениями. Для read-only выборки это лишняя работа.
Два подхода: DTO через конструктор в DQL или партиальная гидрация. DTO предпочтительнее — вы получаете чистый объект без привязки к EntityManager.
// src/Dto/OrderListItem.php
final class OrderListItem
{
public function __construct(
public readonly int $id,
public readonly string $number,
public readonly string $customerName,
public readonly string $total,
) {}
}
// в репозитории
public function findListItems(int $limit = 50): array
{
return $this->createQueryBuilder('o')
->select('NEW App\\Dto\\OrderListItem(o.id, o.number, c.name, o.total)')
->innerJoin('o.customer', 'c')
->setMaxResults($limit)
->getQuery()
->getResult();
}
На выходе — массив OrderListItem, а не отслеживаемых сущностей. Изменить и сохранить их через flush() нельзя, и это правильно: список никто не редактирует.
Сравнение подходов к выборке
| Подход | Что грузит | Можно менять и flush | Когда брать |
|---|---|---|---|
| Полная сущность + JOIN FETCH | все поля, связи под контролем | да | нужна правка или бизнес-логика на объекте |
| DTO через NEW | только указанные поля | нет | списки, отчёты, API-ответы |
| Массив (getArrayResult) | сырые скаляры | нет | экспорт, когда объекты не нужны вовсе |
Приём 3: большие выборки без взрыва памяти
Выгрузка 200 000 заказов в CSV через getResult() положит процесс по памяти: все объекты окажутся в UnitOfWork одновременно. Здесь нужна потоковая обработка через toIterable() плюс ручная очистка EntityManager.
use Doctrine\ORM\EntityManagerInterface;
public function exportAll(EntityManagerInterface $em): void
{
$query = $em->createQuery('SELECT o FROM App\\Entity\\Order o');
$batchSize = 500;
$i = 0;
foreach ($query->toIterable() as $order) {
// ... запись строки в CSV ...
if (++$i % $batchSize === 0) {
// отцепляем обработанные объекты, чтобы память не росла
$em->clear();
}
}
$em->clear();
}
После clear() все ранее загруженные сущности становятся отсоединёнными. Если внутри цикла вам нужны связи, помните: после очистки старые ссылки на них уже нельзя догрузить лениво — вы получите исключение. Для экспорта либо тащите нужные поля через DTO/массив сразу в запросе, либо не обращайтесь к ленивым связям после clear().
Когда что выбирать
- Список на странице, связи перебираются в цикле — JOIN FETCH с
addSelect. Критерий: если в шаблоне есть{{ item.customer.name }}внутри цикла, связь нужно джойнить. - Список только для чтения, полей нужно мало — DTO через
NEW. Критерий: страницу нельзя редактировать, объект дальше не участвует в бизнес-логике. - Выборка от десятков тысяч строк —
toIterable()с батчевымclear(). Критерий: результат заведомо не помещается в комфортный лимит памяти процесса. - Одна сущность для редактирования — обычный
find(), ничего оптимизировать не нужно.
Что ломается на практике
- JOIN без addSelect. Запрос джойнит, но Doctrine всё равно догружает связь лениво — N+1 не уходит. Проверяйте по Profiler, а не на глаз.
- setMaxResults на коллекции без Paginator. Лимит режет строки после джойна, вы получаете меньше заказов, чем просили.
- Обращение к ленивой связи после clear().
EntityNotFoundExceptionили тихо неполные данные — зависит от версии и настроек прокси. - DTO с попыткой flush. DTO не сущность, EntityManager его не отслеживает. Правки просто не сохранятся, без ошибки.
Что настроить и мерить дальше
Включите Symfony Profiler в dev и смотрите на панель Doctrine после каждой новой страницы со списком: число запросов и суммарное время. Резкий рост числа запросов при добавлении строк в список — верный признак N+1. На проде подключите логирование медленных запросов на стороне СУБД и следите за пиковой памятью процессов-воркеров, которые делают экспорт. Инвалидация и настройка второго уровня кеша Doctrine — отдельная большая тема, здесь мы её не трогали.
IT-архитектор / SQL-оптимизатор: Поиск N+1, оптимизация запросов, code review, поиск багов.
Спросить за 20 ₽Источники: Doctrine ORM — Improving Performance (fetch, hydration, iteration), Symfony — Databases and the Doctrine ORM
Частые вопросы
У меня Doctrine ORM 2.x, а не 3.x — примеры подойдут?
Я сделал JOIN, но в Profiler всё равно куча запросов — почему?
Почему после clear() падает исключение при обращении к связи?
DTO нельзя сохранить обратно — это ограничение или я что-то делаю не так?
Как понять, что выборку пора переводить на toIterable?
Партиальная гидрация (PARTIAL) — почему её нет в примерах?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.