El problema de consultas N+1 es un patrón donde leer N registros dispara una consulta extra por registro — N+1 idas a la base en lugar de una o dos. Es el bug de performance serio más común en aplicaciones respaldadas por datos, y el mejor camuflado: cada query individual es rápida, el código se lee perfecto y la página funciona — justo hasta que llegan los tamaños de datos reales.
Puntos clave
| Pregunta | Respuesta |
|---|---|
| La forma | 1 query para la lista + N queries por relaciones = N+1 round trips |
| La causa | Lazy loading tocado dentro de un loop — queries invisibles por iteración |
| La firma | Muchas queries idénticas y rápidas — los logs de queries lentas nunca la ven |
| Las salidas | Eager loading · joins · IN en batch · loaders por solicitud |
| El multiplicador | Latencia de round trip × tamaño de página — brutal sobre la red |
El bug, a plena vista
// 1 query: trae 100 posts
const posts = await postRepo.findRecent(100);
for (const post of posts) {
// +1 query POR POST — post.author parece una propiedad,
// pero corre lazy loading: SELECT * FROM users WHERE id = ?
render(post.title, (await post.author).name);
}
// Log de queries: 1 + 100 = 101 idas a la base para un solo render de página
El arreglo, como lo expresan los SDKs — declara la relación por adelantado y la plataforma la trae en la misma solicitud:
// JavaScript / Node.js — Back4app JS SDK
// The fix: fetch the relation in the same request — 1 query, not N+1
const query = new Parse.Query('Comment');
query.equalTo('post', post);
query.include('author'); // eager-load the pointer
const comments = await query.find(); // one round trip, total
comments.forEach((c) =>
render(c.get('text'), c.get('author').get('username')) // already loaded
); // Flutter / Dart — Back4app Flutter SDK
// The fix: fetch the relation in the same request — 1 query, not N+1
final query = QueryBuilder<ParseObject>(ParseObject('Comment'))
..whereEqualTo('post', post.toPointer())
..includeObject(['author']); // eager-load the pointer
final response = await query.query(); // one round trip, total // iOS / Swift — Back4app Swift SDK
// The fix: fetch the relation in the same request — 1 query, not N+1
let query = Comment.query("post" == post)
.include("author") // eager-load the pointer
query.find { result in
if case .success(let comments) = result { // one round trip, total
comments.forEach { render($0.text, $0.author?.username) }
}
} // Android / Kotlin — Back4app Android SDK
// The fix: fetch the relation in the same request — 1 query, not N+1
val query = ParseQuery.getQuery<ParseObject>("Comment")
query.whereEqualTo("post", post)
query.include("author") // eager-load the pointer
query.findInBackground { comments, e -> // one round trip, total
if (e == null) comments.forEach {
render(it.getString("text"), it.getParseObject("author")?.getString("username"))
}
} El costo, multiplicado
La aritmética que los explicadores se saltan — tiempo total ≈ query de lista + (N × latencia de round trip):
| Tamaño de página N | @1 ms/query | @5 ms/query | En batch (1–2 queries) |
|---|---|---|---|
| 10 | ~11 ms | ~55 ms | ~10 ms |
| 100 | ~101 ms | ~505 ms | ~12 ms |
| 1.000 | ~1,0 s | ~5,0 s | ~20 ms |
Los arreglos publicados coinciden con la matemática: páginas cayendo de 1,4 s a 0,16 s, endpoints acelerándose 30× y más. Dos corolarios que vale la pena fijar: la paginación acota N — un tamaño de página limitado acota el radio de daño incluso antes del arreglo real; y la versión de red es peor — cuando cada una de las N llamadas es una solicitud HTTP y no una query local, multiplica por decenas de milisegundos en vez de un solo dígito.
Las cuatro salidas
| Arreglo | Cómo | Úsalo cuando | Cuidado con |
|---|---|---|---|
| Eager loading / include | Declara las relaciones en la query | La pantalla siempre necesita la relación | Incluir de más infla los payloads |
| Join | Una sentencia trae ambos | Motor relacional, lecturas tipo reporte | Explosión de filas en joins anchos |
| IN en batch | Junta las claves, trae una vez | Cualquier stack, incluso a mano | Un round trip extra (aceptable) |
| Loader por solicitud | Auto-batch durante la ejecución | Resolvers de GraphQL, código en capas | Debe ser por solicitud, no global |
La última fila es el caso famoso de GraphQL: los resolvers se disparan por objeto padre, recreando el loop del lado del servidor, y el patrón loader — juntar las claves en un tick, traer una vez, memoizar por solicitud — es la cura estándar de la industria. Una regla sutil viaja con él: los loaders tienen scope de solicitud; uno global se convierte en una caché vieja con bugs de autorización.
Detección: caza las queries rápidas
La firma del N+1 está invertida respecto del trabajo normal de performance: buscas muchas queries idénticas y rápidas, no una lenta — y por eso los logs de queries lentas, la herramienta habitual, nunca lo ven. Los métodos que sí: el logging de debug del ORM (la misma sentencia, N parámetros distintos, un solo render); las vistas de spans del APM, donde la cascada de spans cortos idénticos es inconfundible; y el más barato de todos — cuenta las queries de una carga de página en desarrollo, con un umbral en la cabeza: una página de lista debería costar queries de un solo dígito, no múltiplos de sus filas. El gemelo del lado de escritura también merece su auditoría: un loop de insert-por-ítem es N+1 de escrituras, arreglado con operaciones bulk.
Casos de uso comunes
- Vistas de lista con autores, dueños o estados — el hogar canónico: cada feed, bandeja de entrada y tabla que une personas con ítems.
- APIs GraphQL — los campos de lista anidados son estructuralmente N+1 hasta que existen loaders.
- Bases de documentos — un find por documento referenciado; se arregla con include, IN en batch o embebiendo, según las reglas de modelado.
- Fan-outs de microservicios — una lista del servicio A, una llamada HTTP al servicio B por ítem; los endpoints compuestos y los BFFs existen para terminar con esto.
- Jobs en segundo plano — el loop que procesa 10.000 registros con dos queries cada uno, costando horas en silencio.
Lazy loading vs. eager loading: matriz de decisión
| Carga eager cuando… | Quédate lazy cuando… |
|---|---|
| La relación se renderiza en cada fila | La relación está detrás de un clic |
| El loop es el patrón de acceso | El acceso es de a un registro a la vez |
| N es del tamaño de una página o más | N es garantizado diminuto |
| La latencia es de cara al usuario | Una tarea de fondo puede permitirse la espera |
| Acabas de arreglar este bug aquí | Mediste, en lugar de suponer |
Y la regla de auditoría permanente que sobrevive a cualquier matriz: toda relación tocada dentro de un loop es culpable hasta que el log de queries demuestre lo contrario.
Limitaciones y trade-offs
- El eager loading puede sobrecorregir. Incluir relaciones pesadas en todas partes cambia N+1 por payloads inflados y joins anchos — incluye lo que la pantalla renderiza, no el grafo completo.
- Los joins tienen su propio precipicio. Los joins uno-a-muchos duplican las filas del padre por cada hijo; con fan-out alto, la lectura en batch de dos queries le gana al join único.
- Los loaders agregan maquinaria. El scope por solicitud, la invalidación de caché dentro de la solicitud y las ventanas de batching son código real — el precio del batching automático.
- Los frameworks lo reintroducen en silencio. Los serializers, los helpers de plantillas y los reviews de “solo un campo más” son la forma en que las páginas arregladas recaen; el chequeo de conteo de queries pertenece al CI, no a la memoria.
- El arreglo es por ruta, no global. N+1 es un bug de patrón de acceso; cada pantalla nueva vuelve a hacer la pregunta.
El N+1 en Back4app
Back4app es una plataforma open-source de Backend as a Service (BaaS) que combina base de datos gestionada, APIs REST y GraphQL generadas automáticamente, autenticación, almacenamiento de archivos y funciones serverless con Cloud Code. Sus SDKs hacen del arreglo el idioma y no la remediación: las relaciones son Pointers tipados, e include() — las pestañas de código de arriba — las trae en la misma solicitud, en batch del lado del servidor. La API GraphQL resuelve queries anidadas sin fan-out por campo, y los defaults de paginación acotan N antes de que saque los dientes. El loop que causa el N+1 no tiene una forma natural de escribirse — que es la mejor clase de arreglo: el que nadie tiene que recordar.
Preguntas frecuentes
¿Qué es el problema de consultas N+1, en términos simples?
Traes una lista de N registros con una consulta, y luego tu código hace una query más por registro para sus datos relacionados — cien posts se vuelven ciento una queries. Cada query es individualmente rápida, y exactamente por eso el problema se esconde: nada es lo bastante lento como para alarmar a nadie, hasta que la página hace cientos de round trips.
¿Qué causa las consultas N+1?
El lazy loading como default. Los ORMs y SDKs te dejan navegar relaciones como propiedades de objeto — post.author — y corren una query de forma transparente cuando tocas una. Pon ese acceso dentro de un loop sobre N resultados y habrás escrito N queries en silencio. La abstracción que hizo agradable el acceso a datos también volvió invisibles las idas a la base.
¿Cómo se arregla el problema N+1?
Cuatro salidas estándar, a elegir por caso: eager loading — dile a la query desde el inicio que incluya la relación; un join que trae ambos en una sola sentencia; batching — junta las N claves foráneas y tráelas en una sola query de contenido-en; y loaders con scope de solicitud que agrupan automáticamente. Las cuatro convierten N+1 round trips en uno o dos.
¿Qué tan lento es N+1 en realidad?
Multiplícalo: cada ida a la base cuesta de uno a cinco milisegundos antes de que la query siquiera corra, así que 100 filas a 5 ms agregan medio segundo frente a una sola query en batch de ~10 ms. Arreglos reales publicados reportan páginas pasando de cerca de 1,4 segundos a 0,16, y endpoints de API acelerándose treinta veces. La matemática empeora linealmente con el tamaño de página — y catastróficamente sobre la red.
¿Por qué el problema N+1 es tan común en GraphQL?
Porque los resolvers corren por campo, por objeto. Una query de posts con sus autores corre el resolver de posts una vez y el de author N veces — el loop del ORM renacido del lado del servidor. La cura canónica es el patrón loader: un batcher con scope de solicitud que junta los IDs de autor durante la ejecución y emite una sola lectura en batch, memoizada para la solicitud.
¿Cómo se detectan las consultas N+1?
Busca muchas queries idénticas y rápidas, no una lenta — esa es la firma. El logging de debug del ORM muestra la sentencia repetida con parámetros distintos; los logs de queries lentas se lo pierden por completo porque cada query es veloz; las herramientas de APM marcan el patrón de spans explícitamente. El hábito que lo atrapa temprano: lee el log de queries de un solo render de página, y cuenta.
¿El problema N+1 ocurre en bases de documentos y APIs REST?
En todos lados donde los datos tienen relaciones. En almacenes de documentos es un find por documento referenciado — arreglado con mecanismos include, queries de contenido-en agrupadas o embebiendo. Sobre REST es un endpoint de lista más una llamada HTTP por ítem — peor que la versión de base de datos, porque la latencia de red empequeñece la latencia de query; los endpoints compuestos, los endpoints batch y los lenguajes de consulta existen en gran parte para matarlo.
¿El lazy loading siempre está mal?
No — está mal dentro de loops. El lazy loading es exactamente correcto cuando los datos relacionados rara vez se necesitan: paga por ellos en el único registro que los necesita, en lugar de pagar eager por los N. La disciplina es saber qué patrón de acceso tiene cada pantalla: las relaciones siempre necesarias se cargan eager, las raramente necesarias lazy, y cualquier cosa dentro de un loop se audita.