Doctrine a Escala: Cómo Manejar Millones de Registros sin Romper tu Aplicación

Usar un ORM como Doctrine es una maravilla para la productividad diaria. De repente puedes pensar en objetos en lugar de escribir SQL a mano y dejar que el framework se encargue de casi toda la persistencia. Es genial… hasta que una tabla cruza la barrera de los millones de filas.

Ahí el ORM puede pasar de ser tu mejor amigo a convertirse en el principal culpable de que tu aplicación se arrastre o directamente muera con un Allowed memory size of X bytes exhausted.

El problema casi nunca está solo en la base de datos (índices, particionamiento, más RAM o SSD rápido ayudan, claro). El verdadero dolor de cabeza suele estar en cómo PHP y Doctrine gestionan la memoria y el ciclo de vida de las entidades.

Escalar con Doctrine no significa tirarlo por la ventana. Significa aprender cuándo usarlo a full y cuándo ponerle freno para que no se coma todo.

1. El enemigo silencioso: el Identity Map

En el centro de todo está el Unit of Work. Este señor registra cada entidad que cargas para poder detectar cambios cuando haces flush(). Para eso mantiene un Identity Map: básicamente un array gigante interno donde guarda referencia a cada objeto hidratado.

¿Qué pasa si intentas procesar 1 millón de usuarios?

Doctrine tratará de mantener 1 millón de objetos vivos en memoria solo para poder seguirles la pista. Resultado: memory limit agotado + rendimiento en picada mucho antes del crash, porque cada flush() o cada operación tiene que revisar ese mapa enorme buscando cambios.

Solución principal: procesar por lotes + limpiar el EntityManager periódicamente

$batchSize = 1000;
$i = 1;

$query = $em->createQuery('SELECT u FROM App\Entity\User u');

foreach ($query->toIterable() as $user) {
    // Aquí haces lo que necesites
    $user->setActive(true);

    if ($i % $batchSize === 0) {
        $em->flush();     // manda los cambios a la base
        $em->clear();     // ¡libera memoria! Borra el Identity Map
    }
    $i++;
}

// No olvides el último lote
$em->flush();
$em->clear();

Algunos detalles que salvan vidas:

  • Si el proceso es solo lectura (reportes, exports, etc.), puedes saltarte el flush() y solo hacer clear().
  • Ojo con los objetos detached: después de un clear(), la variable $user que tienes en ese momento ya no está gestionada por Doctrine. Si en la siguiente vuelta intentas usarla para crear relaciones nuevas, te va a dar problemas. En ese caso suele convenir recargar la entidad o trabajar con IDs.

2. El caro “impuesto” de hidratar objetos

Pasar de una fila cruda de SQL a un objeto PHP completo no es gratis: reflexión, instanciación, proxies, registro en el Unit of Work… todo suma. Hazlo millones de veces y el CPU y la RAM se disparan.La pregunta más importante que puedes hacerte es:

¿De verdad necesito un objeto completo en este momento?

Si estás generando un CSV, un reporte pesado, un proceso ETL o similar… casi siempre la respuesta es no. Doctrine tiene hidratadores mucho más livianos:

  • HYDRATE_ARRAY: Te devuelve arrays asociativos planos. Suele ser 30–40% más rápido y gasta una fracción de la memoria.
$query->getResult(\Doctrine\ORM\Query::HYDRATE_ARRAY);
  • HYDRATE_SCALAR: Perfecto para reportes donde solo necesitas valores sueltos sin estructura de entidad.
  • Partial objects: SELECT PARTIAL u.{id, name, email} –> cargas solo las columnas que realmente vas a usar. Imprescindible cuando la tabla tiene columnas pesadas (JSON grande, TEXT, BLOB) que no necesitas en ese flujo.

3. La clásica (y mortal) trampa del N+1… pero en escala masiva

El N+1 es el de siempre: lazy loading dispara una consulta extra por cada entidad cuando tocas una relación. Con 10 registros es molesto. Con 1 millón es un auténtico ataque DDoS contra tu propio servidor.

Solución principal: eager loading controlado con JOIN FETCH

SELECT i, c FROM App\Entity\Invoice i JOIN FETCH i.customer c

Pero cuidado con un detalle técnico importante:

Cuando haces JOIN FETCH sobre colecciones OneToMany o ManyToMany, el resultado SQL devuelve filas duplicadas (una por cada elemento de la colección). Esto rompe el streaming de toIterable(), porque Doctrine no puede saber cuándo termina de hidratar una entidad hasta que ve la siguiente fila… y se carga todo de golpe en memoria.

Regla práctica rápida:

  • Usa toIterable() solo con relaciones ManyToOne / OneToOne o consultas planas sin colecciones.
  • Para colecciones grandes → considera marcarlas como EXTRA_LAZY en la entidad (te permite count(), contains(), slice() sin cargar todo).
  • O directamente evita cargar la colección en ese proceso batch.

4. Paginación: di adiós al OFFSET lo antes posible

El paginador clásico LIMIT 100 OFFSET 5000000 es una bomba de tiempo en tablas grandes. La base de datos tiene que leer y descartar millones de filas antes de darte las 100 que pediste.

Mejor alternativa (casi siempre): Keyset Pagination o “seek method”

Usas el último valor visto (normalmente un ID autoincremental o un timestamp con índice) para “saltar” directamente:

$lastId = $request->get('last_id', 0);

$query = $em->createQuery(
    'SELECT u FROM App\Entity\User u 
     WHERE u.id > :lastId 
     ORDER BY u.id ASC'
)
->setParameter('lastId', $lastId)
->setMaxResults(100);

Ventajas enormes:

  • Aprovecha índices –> tiempo casi constante independientemente de la página.
  • Mucho más escalable y predecible.
  • Evitas el drama de offsets millonarios.

5. Cuándo es mejor que el ORM se haga a un lado

El momento de madurez real llega cuando entiendes que no todo tiene que pasar por entidades.

Si necesitas subir el sueldo un 5% a 2 millones de empleados… hidratar 2M objetos, modificarlos y hacer flush es un error conceptual (y probablemente un suicidio de memoria y tiempo).

Alternativas potentes para operaciones masivas:

1. DQL Bulk Update / Delete

Genera UPDATE/DELETE directo sin ciclo de vida de entidades.

$qb->update(User::class, 'u')
   ->set('u.salary', 'u.salary * 1.05')
   ->getQuery()
   ->execute();

2. Doctrine DBAL

Accedes a la conexión pura y ejecutas inserts/updates crudos con prepared statements.

3. SQL nativo

Para los casos extremos (LOAD DATA INFILE, INSERT … ON DUPLICATE KEY, etc.) nada supera al SQL directo.

Consejo final

Manejar millones de registros con Doctrine no se trata de odiar el ORM ni de abandonarlo. Se trata de dominarlo y saber ponerle límites.

Un desarrollador que ya lleva tiempo con Symfony/Doctrine aprende a usar un enfoque híbrido inteligente:

  • Entidades y el ORM completo –> para la lógica de negocio rica, transacciones complejas, operaciones unitarias o pequeñas listas.
  • Arrays + hidratadores livianos + toIterable() + clear() –> para procesos batch, reportes, migraciones.
  • Keyset pagination + EXTRA_LAZY cuando toca –> para listas grandes que el usuario va a recorrer.
  • DBAL o SQL crudo –> cuando el volumen amenaza con romper todo.

La clave está en no forzar al ORM a hacer lo que no está diseñado para hacer a gran escala. Pídele objetos cuando los necesites de verdad… y pídele amablemente que se aparte del camino cuando lo que importa es velocidad y estabilidad.

¡Espero que esta guía te ahorre varios fines de semana de debugging! Sigamos codifcando.

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.