Doctrine y el consumo de memoria: Cómo el UnitOfWork y la hydration pueden arruinar tu rendimiento (y cómo evitarlo)

En el artículo anterior vimos cómo sobrevivir cuando tienes que procesar millones de registros con Doctrine: usando flush() por bloques, clear() y toIterable().

Pero ¿por qué funcionan realmente esas técnicas? La respuesta está en dos piezas internas de Doctrine que la mayoría de los desarrolladores subestiman o directamente desconocen: el UnitOfWork y el proceso de hydration.

Entender cómo funcionan estos componentes no solo te ayudará a optimizar procesos batch, workers o exports. Cambiará la forma en que diseñas tu código cuando trabajas con volúmenes grandes de datos.

El mito: «Doctrine solo ejecuta SQL y devuelve objetos»

Muchos desarrolladores piensan que esto:

$user = $repository->find($id);

hace básicamente tres cosas:

  1. Ejecuta un SELECT * FROM users WHERE id = ?.
  2. Crea un objeto User.
  3. Te lo devuelve.

La realidad es bastante más pesada.

Cuando Doctrine te devuelve una entidad, no solo crea un objeto simple. Construye y mantiene en memoria todo un ecosistema alrededor de él: metadatos, snapshots, proxies, el Identity Map, referencias a colecciones… Todo eso se queda vivo en memoria hasta que el EntityManager se destruye o haces clear().

Identity Map: el caché oculto (y caro) de Doctrine

Doctrine tiene una garantía muy útil: dentro del mismo EntityManager, la misma fila de la base de datos siempre será la misma instancia de PHP.

$user1 = $repository->find(10);
$user2 = $repository->find(10);
var_dump($user1 === $user2); // true

Esto lo consigue manteniendo un Identity Map interno: un mapa con todas las entidades cargadas, indexadas por su identificador.

Ventajas: evita duplicados y mantiene la consistencia automáticamente.

Desventaja: cada entidad que cargas ocupa espacio en memoria. Si haces findAll() de 10.000 usuarios, tendrás 10.000 referencias en ese mapa, aunque solo uses una.

UnitOfWork: el cerebro (y el mayor consumidor de memoria) de Doctrine

El UnitOfWork (UoW) es el verdadero director de orquesta de Doctrine. Se encarga de:

  • Rastrear todas las entidades en estado MANAGED
  • Detectar cambios (comparando con snapshots)
  • Programar las operaciones de base de datos
  • Gestionar cascadas y relaciones

Puedes verlo así:

$uow = $entityManager->getUnitOfWork();
$managedEntities = $uow->getManagedEntities(); // Cuidado, esto puede ser enorme

El problema es que cuantas más entidades tengas en estado MANAGED, más memoria consume el UnitOfWork y más lento se vuelve cada flush() (porque tiene que revisar todo).

Los cuatro estados de las entidades (y su impacto real en memoria)

Solo las entidades MANAGED pesan en el UnitOfWork. Entender esto es clave.

NEW

Entidad recién creada. Doctrine aún no la administra:

$user = new User(); // Sin costo en el UoW

MANAGED

Aquí empieza el coste real (ya sea por persist() o al cargarla desde la BD). Doctrine crea snapshot, la registra en el Identity Map y comienza a trackear cambios.

REMOVED

Marcada para borrar. Sigue ocupando memoria hasta que hagas flush().

DETACHED

Doctrine deja de trackearla. Es el estado que buscas en procesos batch para liberar memoria. clear() y detach() son las herramientas principales para pasar de MANAGED a DETACHED.

Hydration: el cuello de botella que casi nadie mide

La mayoría mide el rendimiento solo por el tiempo del SQL. Gran error.

En un proceso real de importación con 5.000 entidades y relaciones eager, medimos esto:

OperaciónTiempo
SQL50ms
Hydration600ms

La hydration es el proceso en el que Doctrine transforma filas crudas en un grafo completo de objetos: crea entidades, asigna valores con conversión de tipos, genera proxies, registra todo en el Identity Map, crea snapshots, inicializa colecciones, etc.

Es mucho más caro de lo que parece, especialmente cuando hay relaciones.

PDO vs Doctrine ORM: una comparación justa

Con PDO:

$rows = $pdo->query($sql)->fetchAll(); // Arrays planos

Bajo consumo de memoria. Rápido. Sin magia.

Con Doctrine:

$users = $query->getResult(); // Objetos + grafo completo

Tienes todo el poder del ORM… y también todo su coste.

flush(): mucho más que ejecutar SQL

Antes de tocar la base de datos, Doctrine hace:

  • computeChangeSets() en todas las entidades administradas
  • Aplicar cascadas
  • Sincronizar relaciones
  • Ejecutar listeners (preFlush, postFlush, etc.)

Con 100 entidades es imperceptible. Con 10.000 empieza a notarse. Con cientos de miles puede bloquear tu proceso.

El patrón que destroza la memoria (y que todos hemos escrito)

foreach ($rows as $row) {
    $entity = transform($row);
    $entityManager->persist($entity); // ¡Acumulando todo!
}
$entityManager->flush(); // Demasiado tarde

Cada persist() añade la entidad, su snapshot y referencias al UnitOfWork. Al final tienes cientos de miles de objetos en memoria.

La solución que sí funciona: bloques + clear()

$batchSize = 1000;

foreach ($rows as $i => $row) {
    $entity = transform($row);
    $entityManager->persist($entity);

    if (($i % $batchSize) === 0) {
        $entityManager->flush();
        $entityManager->clear();   // ← Esto es clave
    }
}

$entityManager->flush(); // Últimos registros

clear() libera el UnitOfWork e Identity Map completo, pasando todas las entidades a DETACHED. Es la forma más efectiva de controlar la memoria en procesos largos.

Consejo: Empieza con un batch de 500-2000 y ajústalo según tu entorno.

toIterable(): la forma correcta de procesar grandes volúmenes

En lugar de:

$users = $query->getResult(); // Todo en memoria

Usa:

foreach ($query->toIterable() as $user) {
    process($user);

    if (($i % $batchSize) === 0) {
        $entityManager->clear();
    }
}

Hidrata de forma progresiva y mantiene un consumo de memoria estable.

El costo oculto de las relaciones

Las relaciones complejas amplifican el problema. Por ejemplo:

User → Orders → Items → Products

Un query aparentemente pequeño puede terminar hidratando:

  • Miles de objetos.
  • Proxies.
  • Colecciones.
  • Asociaciones internas.

El costo de hydration crece de forma explosiva, especialmente con:

  • Relaciones EAGER.
  • Joins enormes.
  • Serialización automática (API Platform, normalizers).

¿Cuándo NO deberías usar Doctrine?

Doctrine es excelente para:

  • Modelos de dominio ricos
  • Lógica de negocio compleja
  • Operaciones transaccionales críticas

Pero evítalo en:

Caso de usoAlternativa recomendada
Analytics / ReportingSQL directo + DTOs
ETL / Procesos masivosDBAL o herramientas especializadas
Exports grandestoIterable() + streaming
Dashboards / Lecturas intensasRead Models (CQRS) + SQL optimizado

Regla de oro: Si solo estás leyendo datos y no necesitas el poder del ORM, no uses Doctrine.

Doctrine es poderoso, pero caro

Doctrine no consume memoria por capricho. Lo hace porque mantiene un sistema sofisticado de tracking, consistencia y relaciones. El problema surge cuando usamos ese poder en contextos donde no lo necesitamos.

Las técnicas clave para no sufrir son:

  1. Procesar en bloques con flush() + clear()
  2. Usar toIterable() en consultas grandes
  3. Evitar findAll() y relaciones EAGER en operaciones masivas
  4. Elegir la herramienta correcta según el caso de uso (no todo es un clavo para el martillo Doctrine)

Doctrine sigue siendo una de las mejores herramientas ORM del ecosistema PHP cuando se usa con criterio. El secreto está en conocer sus costos reales y saber cuándo desactivar su magia.

Preguntas frecuentes

¿detach() o clear()?

  • clear(): Limpia todo el EntityManager. Ideal para procesamiento por bloques.
  • detach($entity): Solo libera una entidad específica. Útil cuando tienes entidades de referencia que quieres mantener.

Usa clear() cuando procesas en bloques y no necesitas retener nada entre iteraciones. Usa detach() si tienes entidades de referencia (como configuración o catálogos) que quieres mantener activas entre bloques.

¿Cómo afecta el lazy loading a la memoria?

El lazy loading retarda la hidratación de relaciones hasta que se acceden. Esto puede:

  • Reducir memoria inicial (si no accedes a las relaciones).
  • Aumentar memoria inesperadamente (si accedes a muchas relaciones después).

Solución: Usa EAGER solo cuando sea necesario, o carga relaciones manualmente con JOIN FETCH.

¿toIterable() o iterate()?

Ambos permiten procesar filas de forma progresiva, pero toIterable() es la opción recomendada porque hidrata entidades una por una y es compatible con clear() dentro del bucle. iterate(), la API más antigua, tiene limitaciones con algunas configuraciones de Doctrine y está considerada deprecada en versiones recientes. A menos que estés en una versión muy antigua del ORM, usa siempre toIterable().

Que vas a hacer?

¿Has tenido problemas graves de memoria con Doctrine? ¿Cuánto mejoró tu flush() después de implementar bloques y clear()? Cuéntame en los comentarios. Especialmente me interesan los casos extremos donde estas técnicas no fueron suficientes.

Tema Relacionado:

Deja una respuesta

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *

Este sitio usa Akismet para reducir el spam. Aprende cómo se procesan los datos de tus comentarios.