Degradación de Rendimiento en Doctrine

Degradación doctrine

Evitando la Degradación de Rendimiento en Procesos Masivos con Doctrine y Symfony

Procesar grandes volúmenes de datos en Symfony puede parecer sencillo… hasta que notás que tu script, que al principio volaba, ahora tarda una eternidad y consume RAM como si no hubiera un mañana 🐘.

En esta entrada te explico por qué ocurre este problema, y cómo resolverlo aplicando una estrategia de procesamiento en lotes (batch processing) con Doctrine.


El Problema

Doctrine ORM es poderoso, pero tiene un comportamiento que si no entendés, puede matar tu rendimiento en tareas pesadas.

Cuando hacés:

  • persist() para agregar entidades, o
  • find() para consultar entidades,

Doctrine los guarda internamente en el EntityManager, dentro de una estructura llamada Unit of Work. Esto le permite hacer seguimiento de los cambios y sincronizarlos al ejecutar flush().

Pero hay un gran pero

A medida que procesás más y más entidades:

  • flush() se vuelve más lento: compara cada entidad conocida para ver qué cambió.
  • Aumenta el uso de memoria: Doctrine no libera nada automáticamente.
  • Las consultas nuevas se mezclan con el estado del Unit of Work, complicando todo

Ejemplo clásico del problema

Supongamos que estás procesando 1000 líneas de un fichero, y por cada línea hacés lo siguiente:

$line = $repository->find($id);
$line->setStatus('processed');
$entityManager->flush();

Después de 1000 iteraciones, tu EntityManager tiene 1000 objetos en memoria. El rendimiento de flush() va bajando… y el uso de RAM subiendo 🚀


La Solución: Batch Processing

La estrategia ganadora es:

Guardar y limpiar en lotes pequeños, usando flush() y clear() cada N elementos.

Esto limita el tamaño del Unit of Work, reduciendo el trabajo de Doctrine y liberando memoria progresivamente.


Ejemplo de implementación

Procesar entidades en lotes de 50

$batchSize = 50;
$i = 0;

foreach ($lineIds as $lineId) {
    $line = $this->processLineRepository->findByProcessLineId($lineId);

    if (!$line || !in_array($line->getStatus(), ['pending', 'scheduled'])) {
        continue;
    }

    // Procesamiento
    $line->setStatus('processed');
    $this->entityManager->persist($line);

    // Cada 50 líneas, hacemos flush y clear
    if (++$i % $batchSize === 0) {
        $this->flushAndClear($i);
    }
}

// Final flush para el remanente
$this->flushAndClear($i);

Función flushAndClear() con monitoreo de memoria

private int $memStart;

public function __construct(EntityManagerInterface $em, LoggerInterface $logger)
{
    $this->entityManager = $em;
    $this->logger = $logger;
    $this->memStart = memory_get_usage();
}

private function flushAndClear(int $i): void
{
    $used = memory_get_usage() - $this->memStart;
    $avg = $used / $i;

    $this->logger->info(sprintf(
        '>> Memoria usada: %s | Promedio por iteración: %s',
        round($used / 1024 / 1024, 2).' MB',
        round($avg / 1024, 2).' KB'
    ));

    $this->entityManager->flush();
    $this->entityManager->clear();
    gc_collect_cycles(); // Forzamos limpieza de ciclos
}

Beneficios reales

  • flush() es mucho más rápido (procesa solo 50 entidades en lugar de 1000).
  • Liberás memoria constantemente.
  • Evitás que Doctrine realice trabajo innecesario.
  • Tu aplicación puede procesar millones de registros sin degradación.

Bonus: ¿Qué pasa si necesitás una entidad después del clear()?

Recuerda que clear() borra todas las entidades del EntityManager. Si necesitás seguir trabajando con alguna después de hacer clear(), puedes:

$lineId = $line->getId(); // antes del clear
$this->entityManager->clear();
$line = $this->processLineRepository->findByProcessLineId($lineId); // recargar

Recursos recomendados


Si estás usando Symfony y Doctrine para procesar grandes volúmenes de datos, flush() y clear() por lotes es obligatorio. No solo mejora el rendimiento, sino que evita problemas de memoria y cuelgues silenciosos.