Doctrine в Symfony под нагрузкой: где течёт память и растут запросы
Под нагрузкой ORM превращается в узкое место: N+1, распухший UnitOfWork и hydration съедают память и время. Разбираем, что ломается первым и как это мерить.

Импорт 200 000 строк в цикле foreach с persist() и одним flush() в конце кладёт PHP-процесс по memory_limit где-то на 40–60 тысячах объектов — точный порог зависит от размера сущности и версии PHP. Тот же код на тесте из ста записей отрабатывает за миллисекунды, поэтому проблему находят только в проде. Doctrine ORM (речь про 2.x и 3.x в связке с Symfony 6.4/7.x) под реальной нагрузкой ведёт себя не так, как на демо-данных: узкие места — не в SQL, а в том, что ORM делает вокруг SQL.
Разбираем три механизма, которые деградируют первыми под нагрузкой: рост UnitOfWork при батчевых операциях, взрыв числа запросов на выборках (N+1) и стоимость гидрации. Инвалидация кеша второго уровня и настройка репликации — отдельные темы, здесь их не касаемся.
UnitOfWork: почему память течёт при импорте
EntityManager хранит все управляемые сущности в UnitOfWork — внутренней карте identity map. Каждый persist() добавляет объект в эту карту, и до flush() плюс clear() он оттуда не уходит. При импорте в одном цикле без очистки вы держите в памяти разом все сущности, их оригинальные снимки для расчёта изменений (changeset) и связанные прокси.
Расчёт изменений тоже не бесплатный: при flush() Doctrine сравнивает текущее состояние каждой управляемой сущности с исходным снимком. Чем больше объектов в UnitOfWork, тем дольше каждый последующий flush() — время растёт нелинейно, если флашить всё в конце.
Рабочий паттерн — батчами с очисткой:
// Symfony 7.x, Doctrine ORM 3.x
// $entityManager получен через DI (EntityManagerInterface)
$batchSize = 500;
$i = 0;
foreach ($rows as $row) {
$product = new Product();
$product->setSku($row['sku']);
$product->setPrice($row['price']);
$entityManager->persist($product);
// Флашим и полностью очищаем UnitOfWork каждые $batchSize
if (($i % $batchSize) === 0) {
$entityManager->flush();
$entityManager->clear(); // отвязывает все сущности от EM
}
++$i;
}
$entityManager->flush(); // остаток
$entityManager->clear();
После clear() все прежние сущности становятся отсоединёнными (detached). Если вы держите ссылку на объект, полученный до очистки, и вызываете на нём persist() — получите EntityNotFoundException или неожиданный INSERT вместо UPDATE. Поэтому в батче не тяните связи на объекты из прошлых итераций.
Правило импорта простое: EntityManager — не буфер на весь датасет. Он держит в памяти ровно то, что вы в него положили и не очистили. Батч + clear превращают O(n) память в O(batch).
SQL-логгер — тихий пожиратель памяти
В окружении dev Doctrine пишет каждый выполненный запрос в память для профайлера. При импорте на сотни тысяч строк этот лог сам по себе съедает память и маскирует реальную причину падения. Отключайте логирование SQL для батчевых команд или запускайте их с APP_ENV=prod. В проде профайлер и так выключен, но проверьте, что у вас не подключён сторонний middleware, который копит запросы.
N+1: главный источник запросов под нагрузкой
Классика: вы выбрали 100 заказов одним запросом, а потом в шаблоне обращаетесь к $order->getCustomer()->getName(). Doctrine лениво подгружает каждого клиента отдельным запросом — итого 1 + 100. На выборке в 100 строк это незаметно, на 5000 в API-эндпоинте это 5001 запрос и таймаут.
Обнаружить N+1 проще всего в профайлере Symfony (панель Doctrine показывает число запросов и одинаковые запросы) либо включив логирование SQL на тестовом стенде и посчитав повторы.
Как гасить N+1
- JOIN в DQL с fetch join — тянет связь одним запросом. Нужно добавить
addSelect(), иначе связь всё равно останется ленивой:
// Doctrine ORM 3.x, DQL через QueryBuilder
$orders = $entityManager->createQueryBuilder()
->select('o', 'c') // c обязательно, иначе fetch join не сработает
->from(Order::class, 'o')
->leftJoin('o.customer', 'c')
->getQuery()
->getResult();
- EAGER на уровне маппинга — связь грузится всегда. Удобно, но опасно: тянет данные даже там, где они не нужны. Ставьте только для связей, которые нужны почти в каждом сценарии.
- Ручной прогрев коллекций — сначала выбрать корневые сущности, затем одним
WHERE INподтянуть связанные и дать Doctrine собрать их в identity map. Помогает, когда fetch join раздувает результат за счёт декартова произведения по нескольким коллекциям.
Fetch join нескольких коллекций сразу (o.items и o.payments одновременно) даёт декартово произведение строк — 10 позиций × 3 платежа вернут 30 строк на один заказ. Гидрация их схлопнет, но по сети и через БД пройдёт лишнее. В этом случае грузите коллекции отдельными запросами.
Гидрация: за что вы платите на больших выборках
Гидрация — превращение строк результата в объекты PHP. Это самая дорогая часть чтения на больших наборах: Doctrine создаёт объект на каждую строку, регистрирует его в UnitOfWork, ставит снимок для отслеживания изменений. Если вам нужны данные только на чтение и вы их не будете менять, платить за всё это незачем.
| Режим гидрации | Что возвращает | Когда брать |
|---|---|---|
| Object (по умолчанию) | Управляемые сущности | Данные будут изменяться и сохраняться |
Array (getArrayResult()) | Вложенные массивы | Только чтение, нужна структура связей |
Scalar (getScalarResult()) | Плоский массив скаляров | Отчёты, экспорт, агрегаты |
| Read-only режим | Сущности без отслеживания изменений | Чтение сущностей без последующего flush |
Для крупных выборок на чтение массив вместо объектов заметно снижает и память, и время — вы пропускаете создание объектов и регистрацию в UnitOfWork. Для управляемых сущностей, которые точно не будете менять, есть режим read-only: он отключает отслеживание изменений, поэтому flush() их проигнорирует. Точный способ пометить запрос или сущность как read-only сверяйте в разделе документации Doctrine про hydration и query hints для вашей версии — API между 2.x и 3.x отличается.
Итерация вместо getResult на гигантских наборах
Когда нужно обработать миллион строк, getResult() загрузит их все в память разом. Doctrine даёт потоковую обработку через toIterable() — она отдаёт объекты по одному. Но identity map всё равно копит их, поэтому периодический clear() внутри цикла обязателен, иначе выигрыша по памяти не будет.
Что мерить и с чего начать
Не оптимизируйте вслепую. Порядок действий:
- Откройте профайлер Symfony на медленном эндпоинте, посмотрите панель Doctrine: сколько всего запросов, сколько дублей, суммарное время в БД.
- Если дублей много и они однотипные — это N+1, гасите fetch join или прогревом.
- Если запрос один, но эндпоинт медленный и жрёт память — смотрите на объём выборки и режим гидрации, пробуйте array/scalar.
- Для команд импорта следите за
memory_get_peak_usage()и подбирайте размер батча — 200–1000 в зависимости от размера сущности.
Дальше стоит посмотреть в сторону кеша второго уровня Doctrine для редко меняющихся справочников и на чтение с реплики через отдельное соединение — но это уже другие статьи. Базовый выигрыш почти всегда даёт устранение N+1 и правильный режим гидрации, а не тонкая настройка кеша.
IT-архитектор / SQL-оптимизатор: Поиск N+1, оптимизация запросов, code review, поиск багов.
Спросить за 20 ₽Источники: Doctrine ORM — Batch Processing, Symfony — Databases and the Doctrine ORM
Частые вопросы
У меня Doctrine ORM 2.x, а примеры под 3.x — они подойдут?
Я добавил clear() в цикл импорта, а теперь получаю EntityNotFoundException. Что не так?
Fetch join не помог — запросов всё равно много. Почему?
Импорт всё равно падает по памяти, хотя я флашу батчами.
Стоит ли ставить EAGER на связи, чтобы раз и навсегда убрать N+1?
Array hydration быстрее, но мне иногда нужно менять эти данные. Как быть?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.