N+1 queries: el problema que sigue apareciendo
Una consulta para la lista y otra por cada elemento. Por qué el N+1 se escapa en desarrollo y cómo detectarlo y resolverlo en EF Core y en Dapper.
La pantalla de pedidos tarda 4 segundos en cargar. El SQL de la consulta principal es simple, tiene índice y se ejecuta en 15 ms. El problema no es esa consulta: son las otras 200 que se ejecutan después, una por cada pedido de la lista.
Eso es el problema N+1: una consulta para traer N elementos y luego una consulta adicional por cada uno. Es uno de los problemas de rendimiento más viejos y sigue apareciendo, porque en desarrollo casi nunca se nota.
Por qué se escapa en desarrollo
Con 10 pedidos de prueba y la base de datos en la misma máquina, 11 consultas de 1 ms cada una suman 11 ms. Nadie lo nota.
En producción, con 200 pedidos y la base de datos en otro servidor, cada consulta paga el viaje de red. Si son 3 ms por viaje, son 600 ms solo en latencia, sin contar el trabajo de cada consulta. Y si la página carga 500 pedidos, son 1,5 segundos de esperas que no aparecen en ninguna consulta lenta, porque ninguna consulta es lenta. Lo lento es la cantidad.
Cómo aparece en EF Core
Con lazy loading
var orders = await db.Orders.Where(o => o.CreatedAt >= from).ToListAsync(ct); // 1 consulta
foreach (var order in orders)
{
// Cada acceso a Customer dispara una consulta nueva
Console.WriteLine($"{order.Id}: {order.Customer.Name}");
}
El código no muestra ninguna consulta dentro del bucle. Esa es justamente la trampa del lazy loading: el acceso a datos queda escondido detrás de una propiedad.
Sin lazy loading, desde el servicio
var orders = await db.Orders.Where(o => o.CreatedAt >= from).ToListAsync(ct);
foreach (var order in orders)
{
// ❌ Explícito, pero igual de costoso
order.CustomerName = await customerService.GetNameAsync(order.CustomerId, ct);
}
Este es más común en código con muchas capas: cada servicio tiene su método "obtener por id", y alguien los combina en un bucle.
Cómo aparece con Dapper
Dapper no tiene lazy loading, pero el N+1 aparece igual cuando se arma un objeto con colecciones:
var orders = await conn.QueryAsync<Order>("SELECT * FROM Sales.Orders WHERE CustomerId = @id", new { id });
foreach (var order in orders)
{
// ❌ Una consulta por pedido
order.Lines = (await conn.QueryAsync<OrderLine>(
"SELECT * FROM Sales.OrderLines WHERE OrderId = @OrderId", new { order.OrderId })).ToList();
}
Cómo detectarlo
- Contando consultas por petición. En desarrollo, registra el SQL (
LogToen EF Core) y mira cuántas consultas genera un endpoint. Si el número crece con la cantidad de datos, es N+1. - En el monitoreo de producción. Herramientas de APM como Application Insights muestran las dependencias de cada petición: una petición con 300 llamadas a SQL idénticas es N+1 de manual.
- Con una prueba. Una prueba de integración puede contar los comandos ejecutados con un
DbCommandInterceptory fallar si un endpoint supera un límite.
Solución 1: cargar todo en una consulta
En EF Core, con Include o, mejor, con una proyección:
var orders = await db.Orders
.Where(o => o.CreatedAt >= from)
.Select(o => new OrderRow(o.Id, o.Total, o.Customer.Name))
.ToListAsync(ct);
La proyección genera un solo JOIN y trae solo las columnas necesarias.
Solución 2: una consulta por colección, no por elemento
Cuando hay colecciones grandes, un JOIN puede repetir demasiados datos. La alternativa es traer los hijos de todos los padres en una segunda consulta y unirlos en memoria:
var orders = (await conn.QueryAsync<Order>(
"SELECT * FROM Sales.Orders WHERE CustomerId = @id", new { id })).ToList();
var ids = orders.Select(o => o.OrderId).ToArray();
var lines = await conn.QueryAsync<OrderLine>(
"SELECT * FROM Sales.OrderLines WHERE OrderId IN @ids", new { ids });
var linesByOrder = lines.ToLookup(l => l.OrderId);
foreach (var order in orders)
order.Lines = linesByOrder[order.OrderId].ToList();
Dos consultas en total, sin importar cuántos pedidos haya. Dapper expande IN @ids en una lista de parámetros. En EF Core, AsSplitQuery() hace lo mismo automáticamente.
Solución 3: cuando el N+1 viene de otro servicio
Si el dato viene de otro servicio o de una API externa, la solución es la misma idea a otro nivel: un método que reciba una lista de IDs.
// En lugar de GetNameAsync(id) dentro de un bucle
var names = await customerService.GetNamesAsync(orders.Select(o => o.CustomerId).Distinct(), ct);
El trade-off
Resolver el N+1 con un JOIN grande puede crear el problema contrario: una consulta que repite datos del padre en cada fila del hijo (la explosión cartesiana que vimos en problemas de rendimiento de EF Core en producción). Y traer todos los hijos "por si acaso" carga datos que la pantalla no muestra.
La regla práctica: un número fijo de consultas por petición, que no crezca con los datos. Si son una, dos o tres, depende de la forma de los datos.
Cuándo un N+1 es aceptable
Cuando N es pequeño y está acotado por diseño. Un detalle de pedido que siempre muestra 3 secciones y carga cada una por separado hace 4 consultas, y seguirá haciendo 4 aunque la base crezca. Eso no es un problema de escalabilidad. El N+1 preocupa cuando N depende de los datos del usuario.
Conclusión
El N+1 no aparece en ninguna consulta lenta, porque cada consulta individual es rápida. Por eso hay que buscarlo en otro lugar: en cuántas consultas genera una petición. Cuando ese número crece con los datos, ya sabes dónde está el problema.
¿Tu base de datos se volvió lenta?
Revisamos consultas, índices y modelo de datos para que tu sistema responda rápido aunque los datos sigan creciendo.
Ver desarrollo a medida