Un endpoint que tarda 180 milisegundos en tu portátil y 8,4 segundos en el servidor de staging no tiene un problema de infraestructura. Tiene consultas N+1. Y lo sabemos porque en los últimos dos años hemos auditado 31 backends construidos a toda velocidad (muchos de ellos con asistencia de IA) y en 24 de esos 31 el cuello de botella principal era exactamente el mismo: un ORM lanzando cientos de sentencias donde debería lanzar dos.
Lo curioso es que casi nadie lo ve venir. El equipo mira métricas de CPU, sube la instancia de la base de datos, mete Redis delante, y el problema sigue ahí porque nunca era un problema de recursos. Era un problema de conteo.
Este artículo va de eso: de cómo detectar consultas N+1 en tu aplicación antes de que el cliente las detecte por ti. De los síntomas que confundes con otra cosa, de las herramientas concretas por stack, y de convertir el hallazgo en un test que rompa la build antes de que llegue a un cliente. También de cuándo dejarlo estar, que es la parte que ninguna guía se atreve a escribir.
¿Por qué este problema casi nunca se ve en tu entorno local?
Porque en tu máquina la base de datos está a 0,3 milisegundos de distancia. En producción está a 1,8 o a 4. Multiplica esa diferencia por 340 sentencias y ya tienes tu regresión.
En términos simples, el patrón consiste en esto: una consulta inicial devuelve N registros y, al acceder a la relación de cada uno, el ORM lanza un round-trip más por registro. Una petición que parecía pedir dos cosas termina pidiendo N+1. La causa habitual es el lazy loading activado por defecto en frameworks de persistencia tipo Hibernate o JPA, y en cualquier modelo de dominio donde las relaciones se resuelven al leerlas.
La segunda razón de la invisibilidad es el volumen. Tu semilla de desarrollo tiene 12 pedidos. El cliente que más factura tiene 1.540. El bucle que recorría doce filas ahora recorre mil quinientas, y cada iteración abre su propio viaje de ida y vuelta al motor.
Los síntomas que confundes con «la base de datos va lenta»
Hay una firma bastante reconocible. La latencia crece de forma lineal con el número de elementos devueltos, mientras el tiempo medio por petición se mantiene plano en 3 o 4 milisegundos. Si tu gráfica de p95 sube y tu gráfica de query duration no se mueve, el problema es la cantidad, no la calidad.
Estas son las siete señales que revisamos antes de instalar nada:
- Latencia que crece de forma lineal con los elementos devueltos
- Tiempo medio por consulta plano en 3 o 4 milisegundos
- Página de 100 elementos cinco veces más lenta que la de 20
- Pool de conexiones agotado con pocos usuarios concurrentes
- Misma plantilla SQL repetida con el identificador cambiando
- Trazas con decenas de spans idénticos apilados en escalera
- Contador de sentencias por petición por encima de 25
La cuarta es la más engañosa, y nos costó entenderla: el pool se agota con 30 usuarios concurrentes en un servicio que debería aguantar 800. Cada petición estaba retenida esperando su turno para lanzar la operación contra la base de datos número 212.
Nos equivocamos de lleno con esto en un proyecto de febrero de 2024. Creíamos que el pool estaba mal dimensionado, lo subimos de 10 a 40 conexiones y durante dos días pareció arreglado. (Spoiler: nunca fue el pool). Luego descubrimos que solo habíamos multiplicado por cuatro la capacidad de hacer daño: el motor pasó a recibir 13.000 sentencias por minuto en lugar de 3.200. Cambiamos el enfoque, eliminamos el bucle y bajamos a 9 conexiones sin tocar nada más.
De 200 ms en desarrollo a ocho segundos con datos reales
Imagina un listado de proyectos donde cada fila muestra el nombre del propietario. Una operación para traer los 150 proyectos, y después 150 solicitudes individuales para resolver cada propietario. Con 1,4 ms por viaje en local son 210 ms y nadie se queja. Con 12 ms de latencia real en una instancia gestionada, son 1,8 segundos solo en ese fragmento.
Si además ese propietario arrastra su organización, y la organización su plan de suscripción, la cuenta ya no es 1+N sino 1+N+N+N. Ahí es donde aparecen los ocho segundos, y ahí es donde el equipo empieza a hablar de migrar a otro motor cuando lo único que hacía falta era un select_related.
Porque la cura, dicho de una vez, está documentada hasta el aburrimiento: precarga explícita (includes en Rails, select_related y prefetch_related en Django), fetch join en JPA, batch fetching cuando el join resulta demasiado ancho, y agrupación por claves en GraphQL. Lo difícil nunca fue arreglarlo. Lo difícil es verlo.
Dónde mirar primero: el contador de peticiones a la base por request
El patrón se detecta buscando una misma plantilla de consulta repetida N veces, con el parámetro cambiando, dentro de una sola petición HTTP. La métrica que lo revela es una sola: número de sentencias SQL ejecutadas por petición. Si esa cifra crece cuando crecen los datos, hay bucle.
Nuestro umbral de trabajo, por convención interna y no por norma universal, es este: por encima de 25 sentencias en una ruta de lectura merece investigación; por encima de 100 no hace falta investigar nada, ya sabes qué es.
Logs del ORM: identificar el patrón repetido con parámetro variable
Activa el log de sentencias y pide una sola petición. Nada más. Lo que buscas es una plantilla idéntica repitiéndose con el identificador cambiando:
SELECT * FROM users WHERE id = 41
SELECT * FROM users WHERE id = 42
SELECT * FROM users WHERE id = 43
...
Total, que si canalizas ese log por grep, normalizas los literales y ordenas por frecuencia, el culpable salta en diez segundos. En Django basta con poner el logger django.db.backends en DEBUG. En Rails, el log de desarrollo ya lo escupe por defecto y solo hay que saber leerlo. En Node con Prisma, la opción log: ['query'] hace el mismo trabajo.
Conviene saber por qué estas consultas se escapan del slow query log: cada una es individualmente rapidísima, así que ninguna supera el umbral configurado. El daño está en la suma, y la suma no aparece en ese registro.
El detalle que cambia las reglas del juego: haz esto con la semilla grande, no con la pequeña. Nosotros mantenemos un dump anonimizado de 2,4 millones de filas precisamente para esto, y lo cargamos una vez por sprint. Sin datos realistas, el log te miente por omisión.
Trazas distribuidas y APM: leer la cascada de spans en escalera
Aquí es donde el diagnóstico se vuelve visual. En Datadog, New Relic, Sentry Performance u OpenTelemetry con Jaeger, una traza sana muestra dos o tres spans de base ancha. Una traza enferma muestra una escalera: decenas de spans finos, idénticos, apilados uno tras otro en diagonal.
¿Qué miras exactamente? Tres números. Primero, el conteo de spans con el mismo nombre de operación dentro de una misma traza. Segundo, la suma de su duración frente al total del request: si 18 spans acumulan el 71 % del tiempo, ya tienes al responsable. Tercero, si están secuenciados en serie o solapados, porque un bucle síncrono se dibuja como peldaños y una carga concurrente se dibuja como un bloque.
Casi todos los APM permiten una alerta directa sobre esto. Nosotros configuramos umbrales del estilo «más de 30 spans de tipo db.query en una traza» y dejamos de mirar dashboards. La herramienta te avisa a ti.
Vamos, que si estás heredando un código que no escribiste y necesitas un mapa completo antes de tocarlo, este tipo de instrumentación es lo primero que abordamos en nuestra auditoría técnica de software cuando el cliente nos dice que «la app va lenta pero no sabemos por qué». Suele bastar con dos días de trazas para tener un orden de prioridades honesto.

Herramientas de diagnóstico según tu stack
Cada ecosistema tiene su instrumento afilado. No hace falta escribir nada propio: alguien ya resolvió esto y lo publicó hace años.
Este es el resumen de lo que de verdad usamos en cliente, con la señal concreta que aporta cada pieza y el entorno donde tiene sentido activarla:
| Stack | Herramienta | Señal que muestra | Entorno |
|---|---|---|---|
| Rails | bullet | Asociación sin precargar | Desarrollo y test |
| Django | django-silk | Recuento de sentencias por petición | Desarrollo |
| Laravel | laravel-query-detector | Relaciones no precargadas | Desarrollo |
| Hibernate / JPA | generate_statistics + datasource-proxy | Sentencias preparadas y ejecutadas por hilo | Test y preproducción |
| Entity Framework Core | Logging de Database.Command | SQL real y avisos de include múltiple | Todos |
| GraphQL | Plugins de trazado de Apollo | Resoluciones por campo frente a nodos | Todos |
| Producción | APM u OpenTelemetry | Spans idénticos en escalera | Producción |
Django, Rails y Laravel: bullet, django-silk y Debugbar
En Rails, la gema bullet es el estándar. Detecta consultas N+1 en tiempo de ejecución y te lanza un aviso indicando qué asociación deberías precargar con includes. Se puede configurar para que en el entorno de test lance una excepción, lo cual la convierte en algo más que una ayuda de desarrollo.
En Django, django-silk registra cada petición con su recuento exacto de sentencias y su desglose temporal; la Debug Toolbar hace algo parecido pero es más incómoda de usar en peticiones de API. Para Laravel, barryvdh/laravel-debugbar muestra el contador en el panel inferior y existe beyondcode/laravel-query-detector, que avisa explícitamente cuando encuentra relaciones no precargadas.
Un aviso honesto: estas herramientas producen falsos positivos. Un bucle que hace tres viajes sobre una tabla de 40 filas no es un problema, y bullet te lo marcará igual. Aprende a silenciar rutas concretas o acabarás ignorando todas las alertas (que es exactamente lo que hemos visto pasar en dos de cada tres equipos).
Hibernate, JPA y Entity Framework Core: estadísticas y logging estructurado
En el mundo JVM, activa hibernate.generate_statistics y consulta SessionStatistics al cerrar la unidad de trabajo: te da el número de sentencias preparadas y ejecutadas sin ambigüedad. Combínalo con org.hibernate.SQL en DEBUG y con la librería datasource-proxy, que permite envolver el DataSource y contar por hilo.
Cuidado con el atajo habitual: cambiar la relación a FetchType.EAGER no resuelve nada por sí solo. Sigue siendo perfectamente capaz de generar una operación por entidad en lugar del único join que esperabas, así que el contador no baja y encima pierdes control sobre cuándo se carga cada cosa.
Para Entity Framework Core, la pieza clave es el logging de Microsoft.EntityFrameworkCore.Database.Command y el evento MultipleCollectionIncludeWarning. Desde la versión 5 también puedes forzar que el lazy loading lance una excepción en lugar de resolver silenciosamente, y eso convierte el diagnóstico en algo determinista en vez de detectivesco.
GraphQL: el resolver que multiplica lecturas sin avisar
¿Y si el bucle no está en tu código sino en la forma de la petición que envía el cliente? Eso es GraphQL, y es la razón por la que aquí el problema resulta tan frecuente: quien decide la profundidad del árbol es el consumidor de la API, no quien la escribió. Un query con tres niveles de anidamiento sobre una lista de 50 elementos puede disparar miles de resoluciones individuales sin que exista un solo for escrito por nadie.
La detección aquí pasa por instrumentar el propio ejecutor: cuenta resoluciones por campo y compáralas con el número de nodos devueltos. Apollo Server expone esto a través de plugins de trazado, y Apollo Studio dibuja el árbol de resolvers con su coste. Si un campo se resuelve 50 veces para una lista de 50 elementos, no está batcheado.
La solución estructural se llama DataLoader, agrupa las claves solicitadas en un mismo ciclo del event loop y lanza una sola instrucción SQL, pero antes de implementarla necesitas el número. Sin el número no sabes si has mejorado o has movido el problema de sitio.

Convertir el diagnóstico en un test que rompe la build
Encontrarlo una vez no vale de nada. Un mes después alguien añade un campo a un serializador y el bucle vuelve. La única defensa que ha funcionado en nuestros proyectos es la aserción automática en integración continua.
Aserciones sobre el número de lecturas por endpoint
Django lo trae de fábrica: assertNumQueries(4) dentro de un test de cliente falla si la ruta ejecuta cinco. Rails tiene assert_queries y la gema n_plus_one_control, que es más lista: ejecuta lo mismo con 2, 4 y 8 registros y falla si el conteo crece con el volumen. Ese enfoque nos gusta más porque no ata el test a una cifra mágica.
Para .NET y JVM la receta es equivalente: un interceptor en el DataSource o en el DbContext que acumule el contador durante el test y lo compare al final.
Aquí va la trampa en la que caímos. Los primeros tests que escribimos fijaban números exactos (seis sentencias, ni una más) y empezaron a fallar cada vez que alguien tocaba una caché o añadía una comprobación de permisos. El equipo los marcó como flaky y los desactivó en tres semanas. Ahora escribimos aserciones de invariancia: el conteo con 40 registros debe ser igual al conteo con 4. Esa afirmación no se rompe por ruido, solo se rompe cuando aparece un bucle de verdad.
Cúbrelos con criterio. No intentes proteger 120 rutas: elige las 6 u 8 que concentran el tráfico y las que devuelven colecciones anidadas. En el último SaaS que acompañamos, 8 tests de este tipo bastaron para detener 4 regresiones en cinco meses.
Presupuestos de rendimiento y alertas en preproducción
Si no quieres bloquear la build, al menos pon un umbral en el entorno previo. Un presupuesto del estilo «ninguna ruta de lectura supera 30 sentencias ni 600 ms de p95 con la semilla grande» convierte una discusión subjetiva en un número que cualquiera puede comprobar.
Esta disciplina es, por cierto, una de las diferencias más claras entre un prototipo y un producto: cuando el objetivo es escalar un MVP hecho con IA, los presupuestos de rendimiento son lo que evita que el primer cliente con datos reales te tire el servicio abajo un viernes por la tarde.

¿Cuándo el patrón que has detectado no merece arreglarse?
Más veces de lo que la literatura técnica admite. Hay tres escenarios en los que hemos decidido no tocar nada y no nos arrepentimos.
El primero: la cardinalidad está acotada de forma estructural. Si el bucle recorre los estados de un flujo y hay exactamente siete, siempre serán siete. Siete consultas de 0,4 ms son 2,8 ms. Reescribirlo con una precarga añade complejidad al modelo y ahorra menos de lo que cuesta el parsing del JSON de respuesta. Lo mismo aplica cuando la relación viaja desde una caché caliente y nunca llega a golpear el motor.
El segundo: la ruta se ejecuta once veces al mes desde un panel interno (once, las contamos). Optimizar eso es entretenimiento, no ingeniería. El tercero, y el más incómodo, es cuando la alternativa es peor: una precarga agresiva puede arrastrar decenas de miles de filas a memoria para usar cuarenta, y hemos visto instancias reventar por consumo de RAM tras «arreglar» un bucle que funcionaba razonablemente.
¿El lazy loading siempre está mal?
No. El lazy loading es una estrategia legítima cuando la relación se accede en pocas ocasiones y el coste de traerla siempre sería mayor que el de traerla a veces. El error no es la carga diferida: es la carga diferida dentro de un bucle sobre una colección de tamaño desconocido. Por eso los frameworks que permiten hacerla explícita, o lanzar excepción cuando se dispara sin querer, resultan tan útiles.
Al final el criterio es siempre el mismo y es aritmético, no ideológico: mide el coste real en milisegundos, multiplícalo por la frecuencia mensual, y compáralo con las horas que cuesta la refactorización más su riesgo de regresión. Si el resultado no te da un orden de magnitud de ventaja, apúntalo en la deuda técnica documentada y sigue con lo siguiente. Lo que nunca es opcional es saber el número. El número lo quieres siempre, incluso cuando la decisión es no hacer nada.

