Doctrine под нагрузкой: identity map и утечки памяти
Batch-скрипт на Doctrine ORM съедает гигабайты и падает с Allowed memory size exhausted на десятках тысяч сущностей. Разбираем, почему EntityManager держит всё в памяти и как это лечить.

Скрипт импорта на Symfony Messenger обрабатывает 200 000 строк из CSV, создаёт сущности Order и вызывает persist() в цикле. На пятидесятитысячной записи процесс падает: PHP Fatal error: Allowed memory size of 536870912 bytes exhausted. Память растёт линейно и не освобождается. Виноват не PHP и не сборщик мусора — виноват identity map внутри Doctrine ORM. Разбираем, как он устроен, почему течёт под нагрузкой и что с этим делать. Речь про Doctrine ORM 2.14+ и 3.x, поведение identity map между этими ветками не менялось.
Что такое identity map и почему она есть у всех
Identity map — это внутренний кэш EntityManager, который гарантирует, что одна строка в базе соответствует ровно одному объекту в памяти в пределах текущего запроса. Достали одну и ту же сущность двумя разными find() — получите один и тот же PHP-объект, а не две копии. Это удобно и предсказуемо, но у механизма есть цена: EntityManager хранит ссылку на каждую управляемую (managed) сущность, которую вы загрузили или передали в persist().
Пока ссылка живёт в identity map, сборщик мусора PHP не может освободить объект. В обычном HTTP-запросе это не проблема: запрос обрабатывает несколько десятков сущностей и завершается, память возвращается ОС. Проблема начинается там, где один процесс живёт долго и прокачивает через себя тысячи объектов: импорты, экспорты, обработчики очередей, cron-задачи, консольные команды.
Doctrine не течёт из-за бага. Она делает ровно то, для чего создана: держит всё загруженное под контролем. Утечка возникает, когда вы применяете инструмент для веб-запроса к задаче на полмиллиона записей и не сбрасываете состояние.
Как выглядит утечка на замерах
Проверить проще всего вручную, поставив вывод потребления памяти в цикл. Условия: PHP 8.2, Doctrine ORM 3.1, PostgreSQL 16, сущность Order с восемью скалярными полями и одной связью ManyToOne.
<?php
// Наивный импорт — так делать нельзя
foreach ($rows as $i => $row) {
$order = new Order();
$order->setExternalId($row['id']);
$order->setTotal($row['total']);
$entityManager->persist($order);
if ($i % 1000 === 0) {
$entityManager->flush();
// память печатаем, чтобы увидеть рост
printf("%d rows, %.1f MB\n", $i, memory_get_usage(true) / 1048576);
}
}
$entityManager->flush();
Даже после flush() все 1000 сущностей остаются в identity map как managed. На выводе вы увидите монотонный рост: строка на 50 000 записей покажет в разы больше мегабайт, чем на 5 000. flush() синхронизирует состояние с базой, но не выкидывает сущности из карты. Это ключевое недопонимание, из-за которого падают импорты.
Три способа удержать память
1. clear() после каждого батча
Самый прямой метод: после flush() вызвать $entityManager->clear(). Он отсоединяет (detach) все сущности от EntityManager, обнуляет identity map, и объекты становятся доступны сборщику мусора.
<?php
$batchSize = 1000;
foreach ($rows as $i => $row) {
$order = new Order();
$order->setExternalId($row['id']);
$order->setTotal($row['total']);
$entityManager->persist($order);
if (($i % $batchSize) === 0) {
$entityManager->flush();
$entityManager->clear(); // сбрасываем identity map целиком
}
}
$entityManager->flush();
$entityManager->clear();
Цена: clear() отсоединяет все сущности без разбора. Если у вас в памяти висит объект ImportBatch, к которому вы ещё обращаетесь после цикла, после clear() он тоже станет detached, и попытка сохранить его без повторного merge()/загрузки приведёт к неожиданному поведению. Раньше существовал clear($entityName) для очистки по классу — в современных версиях он объявлен устаревшим, полагаться на него не стоит.
2. detach() точечно
Если нужно сбросить только импортируемые сущности, а часть состояния сохранить, отсоединяйте конкретные объекты через $entityManager->detach($order). Это дороже по коду, но не сносит всё окружение. На практике для чистого импорта в отдельном процессе проще использовать clear(), а detach() держать для случаев со смешанным состоянием.
3. Обойти ORM: DBAL или batch INSERT
Если задача — тупо залить миллион строк без бизнес-логики на уровне сущностей, identity map вам вообще не нужна. Doctrine DBAL даёт доступ к соединению без ORM-накладных расходов:
<?php
$connection = $entityManager->getConnection();
$connection->beginTransaction();
try {
foreach ($rows as $row) {
$connection->insert('orders', [
'external_id' => $row['id'],
'total' => $row['total'],
]);
}
$connection->commit();
} catch (\Throwable $e) {
$connection->rollBack();
throw $e;
}
Никаких сущностей, никакой карты, память стабильна. Платите тем, что теряете lifecycle-события, каскады и валидацию на уровне ORM — всё это придётся обеспечить руками. Для чистой загрузки данных это честный размен.
Отдельная ловушка: SQL-логгер
Даже с корректным clear() память может течь из-за того, о чём легко забыть. В dev-окружении Doctrine по умолчанию накапливает все выполненные запросы в памяти для профилировщика. На импорте в 200 000 запросов это отдельный источник роста, никак не связанный с identity map. В batch-скриптах логирование SQL нужно отключать. В Doctrine DBAL 3 middleware-логгер конфигурируется явно — если вы запускаете команду в окружении с включённым профайлером, либо запускайте её с продакшн-конфигом, либо отключайте сбор запросов. Точное имя настройки сверьте в документации вашей версии DBAL, потому что механизм логирования между DBAL 2 и 3 переписан.
Когда что выбирать
| Ситуация | Решение | Чем платите |
|---|---|---|
| Импорт с бизнес-логикой сущностей, каскадами, событиями | persist + flush + clear по батчам | Нужно следить за detached-объектами вне цикла |
| Смешанное состояние: часть сущностей нужна после цикла | Точечный detach импортируемых | Больше кода, легко забыть объект |
| Массовая заливка без логики | DBAL insert в транзакции | Нет событий, каскадов, валидации ORM |
| Чтение и обработка большого объёма | toIterable() + clear по батчам | Нельзя менять сортировку выборки на лету |
Чтение больших выборок: toIterable
Утечка бывает не только на записи. Загрузили $repository->findAll() на 300 000 строк — гидрировали 300 000 объектов в память разом. Для потоковой обработки Query отдаёт итератор через toIterable(), который гидрирует по одной сущности за шаг. Но каждая пройденная сущность всё равно оседает в identity map, поэтому clear() по батчам тут так же обязателен.
<?php
$query = $entityManager->createQuery('SELECT o FROM App\Entity\Order o');
$i = 0;
foreach ($query->toIterable() as $order) {
// обработка одной сущности
processOrder($order);
if ((++$i % 1000) === 0) {
$entityManager->flush();
$entityManager->clear();
}
}
Важное ограничение: toIterable() не работает с fetch-join коллекций — при JOIN на связь один-ко-многим потоковая гидрация даст неверный результат, потому что одна сущность размазана по нескольким строкам результата. Грузите такие связи отдельно или через WHERE IN.
Если не получилось
- Память всё равно растёт после clear() — проверьте, не держите ли вы ссылки на сущности сами: в массиве-аккумуляторе, в замыкании, в свойстве сервиса. Detach отсоединяет объект от EntityManager, но не удаляет вашу собственную ссылку.
- Detached entity passed to persist — после
clear()вы пытаетесь работать со старой ссылкой, которую карта уже забыла. Перезагрузите сущность черезfind()или не сбрасывайте её. - Growth в dev, но не в prod — почти наверняка виноват SQL-профайлер, накапливающий запросы. Запускайте команду в prod-окружении.
- Allowed memory size даже с батчами по 100 — посмотрите на связи сущности. Каскадная загрузка ManyToMany может тянуть в память куда больше объектов, чем вы явно persist-ите.
Что мерить и настроить дальше
Поставьте memory_get_usage(true) в цикл на каждый батч и убедитесь, что график плоский, а не растущий — это единственный надёжный признак, что утечка закрыта. Подберите размер батча под вашу сущность: 1000 — разумная точка старта, но тяжёлая сущность с коллекциями потребует меньше. Инвалидация кэша второго уровня и настройка UnitOfWork — отдельные темы, здесь мы разбирали только identity map. Дальше смотрите в сторону запуска импортов через Symfony Messenger с ограничением по памяти на воркер, чтобы процесс перезапускался до того, как упрётся в лимит.
Тест и проверочная: Быстрые ответы с объяснением, почему остальные варианты неверны
Спросить за 10 ₽Источники: Doctrine ORM — Batch Processing, Doctrine ORM — Working with Objects (Identity Map)
Частые вопросы
У меня Doctrine ORM 2.x, а не 3.x — советы применимы?
flush() же сохраняет данные — почему память не освобождается?
clear() безопасно вызывать в цикле, если у меня есть связи?
А если clear() не помог и память всё равно течёт в проде?
Стоит ли вообще использовать ORM для массовых импортов?
toIterable() полностью решает проблему памяти при чтении?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.