{"id":203,"date":"2026-03-12T19:06:55","date_gmt":"2026-03-12T23:06:55","guid":{"rendered":"https:\/\/juredev.com\/blog\/?p=203"},"modified":"2026-03-12T19:06:55","modified_gmt":"2026-03-12T23:06:55","slug":"doctrine-a-escala-como-manejar-millones-de-registros","status":"publish","type":"post","link":"https:\/\/juredev.com\/blog\/2026\/03\/doctrine-a-escala-como-manejar-millones-de-registros\/","title":{"rendered":"Doctrine a Escala: C\u00f3mo Manejar Millones de Registros sin Romper tu Aplicaci\u00f3n"},"content":{"rendered":"\n<p>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\u2026 hasta que una tabla cruza la barrera de los millones de filas.<\/p>\n\n\n\n<p>Ah\u00ed el ORM puede pasar de ser tu mejor amigo a convertirse en el principal culpable de que tu aplicaci\u00f3n se arrastre o directamente muera con un <code>Allowed memory size of X bytes exhausted<\/code>.<\/p>\n\n\n\n<p>El problema casi nunca est\u00e1 solo en la base de datos (\u00edndices, particionamiento, m\u00e1s RAM o SSD r\u00e1pido ayudan, claro). El verdadero dolor de cabeza suele estar en <strong>c\u00f3mo PHP y Doctrine gestionan la memoria<\/strong> y el ciclo de vida de las entidades.<\/p>\n\n\n\n<p>Escalar con Doctrine no significa tirarlo por la ventana. Significa aprender cu\u00e1ndo usarlo a full y cu\u00e1ndo ponerle freno para que no se coma todo.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">1. El enemigo silencioso: el Identity Map<\/h2>\n\n\n\n<p>En el centro de todo est\u00e1 el <strong>Unit of Work<\/strong>. Este se\u00f1or registra cada entidad que cargas para poder detectar cambios cuando haces <code>flush()<\/code>. Para eso mantiene un <strong>Identity Map<\/strong>: b\u00e1sicamente un array gigante interno donde guarda referencia a cada objeto hidratado.<\/p>\n\n\n\n<p><strong>\u00bfQu\u00e9 pasa si intentas procesar 1 mill\u00f3n de usuarios?<\/strong><\/p>\n\n\n\n<p>Doctrine tratar\u00e1 de mantener <strong>1 mill\u00f3n de objetos vivos en memoria<\/strong> solo para poder seguirles la pista. Resultado: memory limit agotado + rendimiento en picada mucho antes del crash, porque cada <strong>flush()<\/strong> o cada operaci\u00f3n tiene que revisar ese mapa enorme buscando cambios.<\/p>\n\n\n\n<p><strong>Soluci\u00f3n principal: procesar por lotes + limpiar el EntityManager peri\u00f3dicamente<\/strong><\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>$batchSize = 1000;\n$i = 1;\n\n$query = $em->createQuery('SELECT u FROM App\\Entity\\User u');\n\nforeach ($query->toIterable() as $user) {\n    \/\/ Aqu\u00ed haces lo que necesites\n    $user->setActive(true);\n\n    if ($i % $batchSize === 0) {\n        $em->flush();     \/\/ manda los cambios a la base\n        $em->clear();     \/\/ \u00a1libera memoria! Borra el Identity Map\n    }\n    $i++;\n}\n\n\/\/ No olvides el \u00faltimo lote\n$em->flush();\n$em->clear();<\/code><\/pre>\n\n\n\n<p><strong>Algunos detalles que salvan vidas:<\/strong><\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li>Si el proceso es solo lectura (reportes, exports, etc.), puedes saltarte el <code>flush()<\/code> y solo hacer <code>clear()<\/code>.<\/li>\n\n\n\n<li>Ojo con los objetos detached: despu\u00e9s de un <code>clear()<\/code>, la variable <code>$user<\/code> que tienes en ese momento ya no est\u00e1 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.<\/li>\n<\/ul>\n\n\n\n<h2 class=\"wp-block-heading\">2. El caro \u201cimpuesto\u201d de hidratar objetos<\/h2>\n\n\n\n<p>Pasar de una fila cruda de SQL a un objeto PHP completo no es gratis: reflexi\u00f3n, instanciaci\u00f3n, proxies, registro en el Unit of Work\u2026 todo suma. Hazlo millones de veces y el CPU y la RAM se disparan.La pregunta m\u00e1s importante que puedes hacerte es:<\/p>\n\n\n\n<p><strong>\u00bfDe verdad necesito un objeto completo en este momento?<\/strong><\/p>\n\n\n\n<p>Si est\u00e1s generando un CSV, un reporte pesado, un proceso ETL o similar\u2026 casi siempre la respuesta es no. Doctrine tiene hidratadores mucho m\u00e1s livianos:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li>HYDRATE_ARRAY: Te devuelve arrays asociativos planos. Suele ser 30\u201340% m\u00e1s r\u00e1pido y gasta una fracci\u00f3n de la memoria.<\/li>\n<\/ul>\n\n\n\n<pre class=\"wp-block-code\"><code>$query->getResult(\\Doctrine\\ORM\\Query::HYDRATE_ARRAY);<\/code><\/pre>\n\n\n\n<ul class=\"wp-block-list\">\n<li>HYDRATE_SCALAR: Perfecto para reportes donde solo necesitas valores sueltos sin estructura de entidad.<\/li>\n<\/ul>\n\n\n\n<ul class=\"wp-block-list\">\n<li>Partial objects: <code>SELECT PARTIAL u.{id, name, email}<\/code> &#8211;> 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.<\/li>\n<\/ul>\n\n\n\n<h2 class=\"wp-block-heading\">3. La cl\u00e1sica (y mortal) trampa del N+1\u2026 pero en escala masiva<\/h2>\n\n\n\n<p>El N+1 es el de siempre: lazy loading dispara una consulta extra por cada entidad cuando tocas una relaci\u00f3n. Con 10 registros es molesto. Con 1 mill\u00f3n es un <strong>aut\u00e9ntico ataque DDoS contra tu propio servidor<\/strong>.<\/p>\n\n\n\n<p><strong>Soluci\u00f3n principal: eager loading controlado con JOIN FETCH<\/strong><\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>SELECT i, c FROM App\\Entity\\Invoice i JOIN FETCH i.customer c<\/code><\/pre>\n\n\n\n<p><strong>Pero cuidado con un detalle t\u00e9cnico importante:<\/strong><\/p>\n\n\n\n<p>Cuando haces <code>JOIN FETCH <\/code>sobre colecciones <strong>OneToMany<\/strong> o <strong>ManyToMany<\/strong>, el resultado SQL devuelve filas duplicadas (una por cada elemento de la colecci\u00f3n). Esto <strong>rompe el streaming<\/strong> de <code>toIterable()<\/code>, porque Doctrine no puede saber cu\u00e1ndo termina de hidratar una entidad hasta que ve la siguiente fila\u2026 y se carga todo de golpe en memoria.<\/p>\n\n\n\n<p><strong>Regla pr\u00e1ctica r\u00e1pida:<\/strong><\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li>Usa <code>toIterable()<\/code> solo con relaciones <strong>ManyToOne<\/strong> \/ <strong>OneToOne<\/strong> o consultas planas sin colecciones.<\/li>\n\n\n\n<li>Para colecciones grandes \u2192 considera marcarlas como <strong>EXTRA_LAZY<\/strong> en la entidad (te permite <code>count()<\/code>, <code>contains()<\/code>, <code>slice()<\/code> sin cargar todo).<\/li>\n\n\n\n<li>O directamente evita cargar la colecci\u00f3n en ese proceso batch.<\/li>\n<\/ul>\n\n\n\n<h2 class=\"wp-block-heading\">4. Paginaci\u00f3n: di adi\u00f3s al OFFSET lo antes posible<\/h2>\n\n\n\n<p>El paginador cl\u00e1sico <code>LIMIT 100 OFFSET 5000000<\/code> 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.<\/p>\n\n\n\n<p><strong>Mejor alternativa (casi siempre): Keyset Pagination o \u201cseek method\u201d<\/strong><\/p>\n\n\n\n<p>Usas el \u00faltimo valor visto (normalmente un ID autoincremental o un timestamp con \u00edndice) para \u201csaltar\u201d directamente:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>$lastId = $request->get('last_id', 0);\n\n$query = $em->createQuery(\n    'SELECT u FROM App\\Entity\\User u \n     WHERE u.id > :lastId \n     ORDER BY u.id ASC'\n)\n->setParameter('lastId', $lastId)\n->setMaxResults(100);<\/code><\/pre>\n\n\n\n<p><strong>Ventajas enormes:<\/strong><\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li>Aprovecha \u00edndices &#8211;> tiempo casi constante independientemente de la p\u00e1gina.<\/li>\n\n\n\n<li>Mucho m\u00e1s escalable y predecible.<\/li>\n\n\n\n<li>Evitas el drama de offsets millonarios.<\/li>\n<\/ul>\n\n\n\n<h2 class=\"wp-block-heading\">5. Cu\u00e1ndo es mejor que el ORM se haga a un lado<\/h2>\n\n\n\n<p>El momento de madurez real llega cuando entiendes que <strong>no todo tiene que pasar por entidades<\/strong>.<\/p>\n\n\n\n<p>Si necesitas subir el sueldo un 5% a 2 millones de empleados\u2026 hidratar 2M objetos, modificarlos y hacer flush es un error conceptual (y probablemente un suicidio de memoria y tiempo).<\/p>\n\n\n\n<p><strong>Alternativas potentes para operaciones masivas:<\/strong><\/p>\n\n\n\n<h3 class=\"wp-block-heading\">1. <strong>DQL Bulk Update \/ Delete<\/strong><\/h3>\n\n\n\n<p>Genera UPDATE\/DELETE directo sin ciclo de vida de entidades.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>$qb->update(User::class, 'u')\n   ->set('u.salary', 'u.salary * 1.05')\n   ->getQuery()\n   ->execute();<\/code><\/pre>\n\n\n\n<h3 class=\"wp-block-heading\">2. Doctrine DBAL<\/h3>\n\n\n\n<p>Accedes a la conexi\u00f3n pura y ejecutas inserts\/updates crudos con prepared statements.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">3. SQL nativo<\/h3>\n\n\n\n<p>Para los casos extremos (LOAD DATA INFILE, INSERT \u2026 ON DUPLICATE KEY, etc.) nada supera al SQL directo.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Consejo final<\/h2>\n\n\n\n<p>Manejar millones de registros con Doctrine no se trata de odiar el ORM ni de abandonarlo. Se trata de <strong>dominarlo y saber ponerle l\u00edmites.<\/strong><\/p>\n\n\n\n<p>Un desarrollador que ya lleva tiempo con Symfony\/Doctrine aprende a usar un enfoque <strong>h\u00edbrido inteligente<\/strong>:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li>Entidades y el ORM completo &#8211;> para la l\u00f3gica de negocio rica, transacciones complejas, operaciones unitarias o peque\u00f1as listas.<\/li>\n\n\n\n<li>Arrays + hidratadores livianos + <code>toIterable()<\/code> + <code>clear()<\/code> &#8211;> para procesos batch, reportes, migraciones.<\/li>\n\n\n\n<li>Keyset pagination + EXTRA_LAZY cuando toca &#8211;> para listas grandes que el usuario va a recorrer.<\/li>\n\n\n\n<li>DBAL o SQL crudo &#8211;> cuando el volumen amenaza con romper todo.<\/li>\n<\/ul>\n\n\n\n<p>La clave est\u00e1 en no forzar al <strong>ORM a hacer lo que no est\u00e1 dise\u00f1ado para hacer a gran escala<\/strong>. P\u00eddele objetos cuando los necesites de verdad\u2026 y p\u00eddele amablemente <strong>que se aparte del camino<\/strong> cuando lo que importa es velocidad y estabilidad.<\/p>\n\n\n\n<p>\u00a1Espero que esta gu\u00eda te ahorre varios fines de semana de debugging! Sigamos codifcando.<\/p>\n","protected":false},"excerpt":{"rendered":"<p>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\u2026 hasta que una tabla cruza la barrera de los millones de filas. Ah\u00ed el ORM puede [&hellip;]<\/p>\n","protected":false},"author":1,"featured_media":0,"comment_status":"open","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"footnotes":""},"categories":[7],"tags":[15,20],"class_list":["post-203","post","type-post","status-publish","format-standard","hentry","category-desarrollo","tag-php","tag-sql"],"_links":{"self":[{"href":"https:\/\/juredev.com\/blog\/wp-json\/wp\/v2\/posts\/203","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/juredev.com\/blog\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/juredev.com\/blog\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/juredev.com\/blog\/wp-json\/wp\/v2\/users\/1"}],"replies":[{"embeddable":true,"href":"https:\/\/juredev.com\/blog\/wp-json\/wp\/v2\/comments?post=203"}],"version-history":[{"count":0,"href":"https:\/\/juredev.com\/blog\/wp-json\/wp\/v2\/posts\/203\/revisions"}],"wp:attachment":[{"href":"https:\/\/juredev.com\/blog\/wp-json\/wp\/v2\/media?parent=203"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/juredev.com\/blog\/wp-json\/wp\/v2\/categories?post=203"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/juredev.com\/blog\/wp-json\/wp\/v2\/tags?post=203"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}