6 Problemas de Rendimiento en Entity Framework Core (y Como Solucionarlos)
Evita estos 6 errores comunes de rendimiento en Entity Framework Core en .NET — con escenarios reales, causas raiz y refactorizaciones.
Entity Framework Core hace que el acceso a datos se sienta gratis. Escribís algo de C# y EF se encarga del SQL. Pero esa comodidad viene con una trampa: es muy fácil escribir código que se ve bien y rinde pésimo cuando crece.
No lo notás cuando la tabla tiene 100 filas. Cuando tiene 100.000, tu API empieza a dar timeout.
En este artículo recorro seis problemas de rendimiento de EF Core que veo seguido en code reviews. De cada uno te muestro el código problemático, te explico por qué es lento y te doy el arreglo.
Todos los números que siguen están medidos. Armé un banco de pruebas para exactamente estos seis escenarios y lo corrí, y donde el resultado honesto es menos espectacular que el folklore, lo digo. La metodología y las advertencias están al final, en Cómo se midieron estos números, y el banco de pruebas está en el repositorio de benchmarks por si querés reproducir o desmentir cualquier cosa.
1. Queries N+1 (La Trampa del Lazy Loading)
El Problema
[HttpGet]
public async Task<ActionResult<List<OrderDto>>> GetOrders()
{
var orders = await _context.Orders.ToListAsync();
var orderDtos = orders.Select(o => new OrderDto
{
Id = o.Id,
CustomerName = o.Customer.Name, // ¡Query N+1!
Items = o.Items.Select(i => new OrderItemDto // ¡Otro N+1!
{
Quantity = i.Quantity,
UnitPrice = i.UnitPrice
}).ToList()
}).ToList();
return Ok(orderDtos);
}
Contá los statements con cuidado, porque acá es donde la versión habitual de esta historia se equivoca. Cada orden dispara dos cargas perezosas, no una: tocar o.Customer emite una query, y tocar o.Items emite otra. Así que para N órdenes el total es 1 + 2N, no 1 + N.
Para las 600 órdenes de mi benchmark:
- 1 query para las órdenes
- 600 queries para los clientes, una por orden
- 600 queries para las colecciones de items, una por orden
Total: 1.201 queries. Si tu DTO además metiera mano en i.Product.Name, sumarías una query más por cada item y el total se te iría a los miles.
Por Qué Es Lento
Cada query tiene su costo: un round trip a la base, parseo y ejecución, y materialización del resultado. En una base cliente/servidor el round trip domina todo lo demás, y 1.200 de ellos no se amortizan de ninguna manera.
La Solución: Eager Loading con Include
[HttpGet]
public async Task<ActionResult<List<OrderDto>>> GetOrders()
{
var orders = await _context.Orders
.Include(o => o.Customer)
.Include(o => o.Items)
.AsNoTracking() // Optimización extra (ver problema 2)
.ToListAsync();
var orderDtos = orders.Select(o => new OrderDto
{
Id = o.Id,
CustomerName = o.Customer.Name,
Items = o.Items.Select(i => new OrderItemDto
{
Quantity = i.Quantity,
UnitPrice = i.UnitPrice
}).ToList()
}).ToList();
return Ok(orderDtos);
}
Total: 1 query (o 2 a 3 con AsSplitQuery, ver problema 6).
Impacto en el Rendimiento
Acá el titular honesto es la cantidad de statements, no el cronómetro:
- Antes: 1.201 statements SQL
- Después: 1 statement SQL
Esa proporción es una propiedad de la forma de la query. No depende del motor de base de datos, ni del hardware, ni de la red.
El número de reloj necesita más cuidado, y es la afirmación que más quise verificar, porque el “60x más rápido” es la cifra que circula y no pude reproducir nada ni cerca. Contra SQLite en archivo, corriendo en el mismo proceso que la aplicación, 600 órdenes con 3.000 items midieron 130 ms antes y 23 ms después, o sea 5,6x. Pero esa comparación cambia dos cosas al mismo tiempo. Prender lazy loading también hace que EF materialice cada entidad como una subclase proxy dinámica, y los proxies no son gratis. Así que agregué un tercer caso que deja los proxies prendidos pero usa Include, y eso separa los dos efectos:
| Variante | Queries | Media | Asignado |
|---|---|---|---|
| Proxies de lazy loading, N+1 | 1.201 | 130,1 ms | 37,1 MB |
Proxies de lazy loading, Include | 1 | 45,0 ms | 16,8 MB |
Sin proxies, Include | 1 | 23,0 ms | 6,4 MB |
Mirá la fila del medio y la división se vuelve obvia. Aproximadamente 2,9x de la mejora viene de eliminar los round trips, y aproximadamente 1,9x viene de dejar de materializar proxies. Solo el primero es el problema N+1. Los números de un orden de magnitud que se citan por ahí no son lo que pasa dentro del mismo proceso.
Ahora bien, sí son perfectamente creíbles contra un servidor de base de datos real, y la razón es aritmética más que optimismo. SQLite corre en proceso, así que ahí una “query” es una llamada a una función de una biblioteca y un round trip no cuesta prácticamente nada. Poné PostgreSQL en otro host con 1 ms de latencia de ida y vuelta y esos 1.200 statements extra agregan alrededor de 1,2 segundos por sí solos, antes de que la base haya hecho ningún trabajo. De ahí sale una cifra de 10x o 50x, y escala con tu latencia de red, no con tu CPU. Medilo en tu propia infraestructura en lugar de confiar en el multiplicador de nadie, incluido el mío.
Cuándo Usar Include
Usá Include cuando:
- Siempre necesitás los datos relacionados
- La relación es uno-a-uno o uno-a-muchos y la colección no es enorme
Evitá Include cuando:
- Los datos relacionados son opcionales
- Las colecciones son gigantes; ahí conviene proyectar, ver el problema 3
Y tratá al lazy loading en sí con desconfianza. Es la característica que convierte un Include faltante en 1.200 queries silenciosas y, como muestra la tabla de arriba, encima te cobra la materialización de proxies.
2. Trackear Todo
El Problema
[HttpGet("products")]
public async Task<ActionResult<List<ProductDto>>> GetProducts()
{
// EF Core trackea todas las entidades por defecto
var products = await _context.Products.ToListAsync();
return Ok(products.Select(p => new ProductDto
{
Id = p.Id,
Name = p.Name,
Price = p.Price
}));
}
Por Qué Es Lento
Por defecto, el change tracker vigila cada entidad que devuelve una query. Reserva entradas de tracking, toma snapshots de los valores originales y espera modificaciones. En una query de solo lectura, todo ese trabajo se tira a la basura apenas termina el request.
La Solución: AsNoTracking
[HttpGet("products")]
public async Task<ActionResult<List<ProductDto>>> GetProducts()
{
var products = await _context.Products
.AsNoTracking() // Deshabilita el change tracking
.ToListAsync();
return Ok(products.Select(p => new ProductDto
{
Id = p.Id,
Name = p.Name,
Price = p.Price
}));
}
Impacto en el Rendimiento
Cargando 10.000 productos, con tracking contra AsNoTracking:
- Tiempo: entre 1,84x y 1,98x más rápido en cuatro corridas, mediana 1,93x. En la corrida publicada, 33,7 ms bajaron a 18,3 ms.
- Asignación de memoria: 16,65 MB bajaron a 8,06 MB, o sea 51,6% menos.
La cifra de memoria es la que más confianza me da de todo el artículo. Salió idéntica byte a byte en las cuatro corridas, porque no es un tiempo: es la memoria que el change tracker necesita para 10.000 snapshots, y eso es determinístico. El tiempo se mueve un poco según el humor de la máquina. La memoria no.
Así que “cerca de 2x más rápido y más o menos la mitad de la memoria” es un resumen justo, y fijate que el ahorro de memoria es el efecto más grande y más confiable de los dos.
Cuándo Usar AsNoTracking
Usá AsNoTracking cuando:
- La query es de solo lectura
- No vas a llamar a
SaveChangessobre esas entidades - Estás armando DTOs o view models
No lo uses cuando:
- Necesitás actualizar las entidades que cargaste
- Dependés de funciones del change tracking, como la resolución de identidad
No-Tracking Global (Tracking Opt-In)
Si la mayoría de tus queries son de solo lectura, invertí el default y volvé a activarlo donde haga falta:
public class AppDbContext : DbContext
{
public AppDbContext(DbContextOptions<AppDbContext> options)
: base(options)
{
// Cambia el comportamiento por defecto
ChangeTracker.QueryTrackingBehavior = QueryTrackingBehavior.NoTracking;
}
public DbSet<Product> Products => Set<Product>();
// Adjuntá explícitamente cuando sí necesitás escribir
public async Task<int> UpdateProductAsync(Product product)
{
Attach(product).State = EntityState.Modified;
return await SaveChangesAsync();
}
}
Ojo con un detalle: este método vive adentro del DbContext, así que llama a Attach y a SaveChangesAsync directamente. No hay ningún campo _context de por medio: el contexto es this. Si preferís poner el método en un repositorio o en un servicio, ahí sí necesita un AppDbContext inyectado y el prefijo _context. vuelve. Mezclar las dos convenciones es una forma segura de escribir código que no compila.
3. Cargar Entidades Completas Cuando Solo Necesitás Algunas Columnas
El Problema
[HttpGet("product-names")]
public async Task<ActionResult<List<string>>> GetProductNames()
{
var products = await _context.Products.ToListAsync();
return Ok(products.Select(p => p.Name).ToList());
}
Si Product tiene veinte columnas, incluyendo campos de texto grande o BLOBs, estás leyendo, transfiriendo y materializando todo eso para usar un solo string.
Por Qué Es Lento
- La base lee columnas que nunca vas a mirar
- El proveedor las decodifica en objetos CLR
- Esos objetos ocupan memoria hasta que pasa el GC
- En un motor cliente/servidor, cada uno de esos bytes además cruza la red
La Solución: Proyectar con Select
[HttpGet("product-names")]
public async Task<ActionResult<List<string>>> GetProductNames()
{
var names = await _context.Products
.Select(p => p.Name)
.ToListAsync();
return Ok(names);
}
SQL generado:
-- Antes
SELECT * FROM Products
-- Después
SELECT Name FROM Products
Proyección Más Compleja
[HttpGet("orders")]
public async Task<ActionResult<List<OrderSummaryDto>>> GetOrderSummaries()
{
var summaries = await _context.Orders
.Select(o => new OrderSummaryDto
{
Id = o.Id,
CustomerName = o.Customer.Name, // Hace el join solo
ItemCount = o.Items.Count, // Agrega en SQL
TotalAmount = o.Items.Sum(i => i.UnitPrice * i.Quantity)
})
.ToListAsync();
return Ok(summaries);
}
Esto genera una sola query SQL eficiente con joins y agregaciones, y nunca materializa una entidad Order.
Impacto en el Rendimiento
Este es el problema donde el tamaño del payload habla por sí solo. Mi benchmark carga 1.000 órdenes que llevan cada una una descripción de 4.000 caracteres y un blob de 6.000 bytes, o sea unos 10 KB por fila, contra una proyección de cuatro columnas escalares:
- Tiempo: entre 5,5x y 6,3x más rápido según la corrida, mediana 5,6x. En la corrida publicada, 8,0 ms bajaron a 1,27 ms.
- Memoria administrada: 14.336 KB bajaron a 475 KB, unas 30x menos.
- Datos de columnas leídos: 9,57 MB bajaron a 37,3 KB, unas 263x menos.
Esa última línea merece una definición precisa, porque “transfiere 10 MB” es de esas frases que suenan medidas y no lo están. SQLite corre en proceso, así que nada cruza un cable y no puedo reportar honestamente bytes de red. Lo que medí en cambio es la longitud total de los valores de las columnas que toca cada forma de query, sumada con SUM(LENGTH(...)) sobre las 1.000 filas. Es la cantidad de datos de columna que el motor tiene que leer y decodificar. En PostgreSQL o SQL Server pagarías una proporción parecida otra vez en la red, y después otra vez en la memoria de tu aplicación, que es exactamente por qué el efecto se multiplica tan feo en producción.
La caída de 30x en memoria es memoria administrada de tu proceso, y es el número sobre el que un desarrollador puede actuar directamente.
Cuándo Proyectar
Proyectá siempre para:
- DTOs que devuelve una API
- View models para una UI
- Reportes y exportaciones
- Cualquier query de solo lectura
4. No Usar Compiled Queries en Hot Paths
El Problema
public async Task<Product?> GetProductByIdAsync(int id)
{
return await _context.Products.FirstOrDefaultAsync(p => p.Id == id);
}
Cada llamada recorre el árbol de expresión de LINQ, lo traduce y busca el resultado en la caché de queries de EF. La caché significa que no estás regenerando SQL desde cero cada vez, pero igual estás pagando por construir y hashear la expresión en absolutamente cada llamada.
Por Qué Es Lento
Para una query que se invoca miles de veces por segundo, ese trabajo de traducción por llamada pasa a ser una porción medible del request. Es puro costo: el SQL que produce es idéntico siempre.
La Solución: Compiled Queries
private static readonly Func<AppDbContext, int, Task<Product?>> _getProductById =
EF.CompileAsyncQuery((AppDbContext context, int id) =>
context.Products.FirstOrDefault(p => p.Id == id));
public async Task<Product?> GetProductByIdAsync(int id)
{
return await _getProductById(_context, id);
}
El delegado se construye una vez, y todas las llamadas posteriores van derecho a ejecutarlo.
Ejemplo Más Complejo
private static readonly Func<AppDbContext, DateTime, DateTime, IAsyncEnumerable<Order>> _getOrdersByDateRange =
EF.CompileAsyncQuery((AppDbContext context, DateTime start, DateTime end) =>
context.Orders
.Where(o => o.OrderDate >= start && o.OrderDate <= end)
.Include(o => o.Customer)
.OrderByDescending(o => o.OrderDate));
public async Task<List<Order>> GetOrdersByDateRangeAsync(DateTime start, DateTime end)
{
var orders = new List<Order>();
await foreach (var order in _getOrdersByDateRange(_context, start, end))
{
orders.Add(order);
}
return orders;
}
Impacto en el Rendimiento
Diez mil llamadas contra un DbContext caliente, LINQ común contra EF.CompileQuery:
- Tiempo: entre 34% y 42% más rápido según la corrida, mediana 38%. En la corrida publicada, 407 ms bajaron a 237 ms.
- Memoria: 133,6 MB bajaron a 83,0 MB, 1,61x menos.
Hay dos cosas para decir sobre ese rango. La primera es que es un rango por algo. Este benchmark varió bastante entre corridas, así que citar el mejor resultado de 42% y llamarlo “40% más rápido” sería elegir al ganador. La mediana es 38%.
La segunda es que el contexto está caliente a propósito: se crea una vez y se reutiliza para las 10.000 llamadas, así que la caché de queries de EF ya está tibia y el caso de LINQ común no está pagando la generación completa de SQL. Ese es el escenario realista de un hot path, y también el menos favorable para las compiled queries. Un contexto frío mostraría una diferencia mucho más grande en la primera llamada y más o menos esta diferencia después. Dicho de otro modo: 38% es lo que obtenés una vez que todo está caliente, que es el estado en el que un servicio de producción pasa su vida.
Cuándo Usar Compiled Queries
Usá compiled queries para:
- Queries de alta frecuencia, cientos de veces por segundo o más
- Endpoints de API bajo carga sostenida
- Jobs de fondo que corren la misma query en un loop
No vale la pena para:
- Queries ad-hoc
- Queries cuya forma cambia según el input, como filtros dinámicos
5. Índices Faltantes que Provocan Table Scans
El Problema
public async Task<Customer?> GetCustomerByEmailAsync(string email)
{
return await _context.Customers
.FirstOrDefaultAsync(c => c.Email == email);
}
Si Email no tiene índice, la base tiene una sola opción: mirar todas las filas.
Por Qué Es Lento
No hay ninguna astucia disponible para el planificador. Con un millón de clientes y sin índice, el motor lee filas hasta encontrar una coincidencia, y si no hay ninguna, las lee todas. SQLite lo dice con sus propias palabras. Esto es EXPLAIN QUERY PLAN sobre las dos bases, que son idénticas salvo por el índice:
-- Sin el índice
SCAN c
-- Con el índice
SEARCH c USING INDEX IX_Customers_Email (Email=?)
SCAN contra SEARCH es la misma distinción que SQL Server y PostgreSQL harían entre un table scan y un index seek. La salida del planificador es específica de cada motor; el hecho que reporta no lo es.
La Solución: Agregar el Índice con la Fluent API
public class AppDbContext : DbContext
{
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
// Índice sobre Email
modelBuilder.Entity<Customer>()
.HasIndex(c => c.Email);
// Índice compuesto para una query frecuente
modelBuilder.Entity<Order>()
.HasIndex(o => new { o.CustomerId, o.CreatedAt });
// Índice único
modelBuilder.Entity<User>()
.HasIndex(u => u.Username)
.IsUnique();
// Índice filtrado (SQL Server)
modelBuilder.Entity<Order>()
.HasIndex(o => o.Status)
.HasFilter("[Status] = 'Pending'");
}
}
Generá la migración:
dotnet ef migrations add AddIndexes
dotnet ef database update
Impacto en el Rendimiento
Este es el problema que mejor sobrevive al escrutinio, y es el único lugar de todo el artículo donde una afirmación de un orden de magnitud aguanta la medición. Dos bases de 1.000.000 de clientes cada una, con filas idénticas, que solo se diferencian en IX_Customers_Email:
- Búsqueda promedio: 18,73 ms sin el índice, 49,0 µs con él. Eso es unas 380x, y entre corridas la proporción fue de 347x a 382x.
- Peor caso: una búsqueda de una dirección que no coincide con nada tiene que leer la tabla entera antes de poder contestar “no”. Eso midió 41,9 ms sin el índice contra 39,5 µs con él, o sea cerca de 1.000x. Dos sondeos de este caso dieron 930x y 1.060x, así que tomalo como “del orden de mil veces” y no como una cifra precisa.
El número promedio necesita una aclaración honesta. FirstOrDefault se traduce a LIMIT 1, y un scan sin índice se detiene en el instante en que encuentra su fila, así que el costo de un índice faltante depende enteramente de qué tan profundo en la tabla está la respuesta. Buscar al primer cliente es casi gratis; buscar al último lee un millón de filas. Mi benchmark corre un conjunto fijo de diez búsquedas repartidas parejo por el rango de claves, lo que deja la profundidad media de scan en 45% de la tabla por construcción. Ese es un promedio defendible, pero es un promedio amable: las búsquedas reales de direcciones que no existen, como un intento de login contra una cuenta desconocida, caen siempre en el peor caso.
La otra aclaración va en la misma dirección. La tabla sin índice ocupa 78 MB en una máquina con 46 GiB de RAM, así que está completamente residente en la page cache del sistema operativo y cada “scan” es un scan de memoria. En un servidor cuyo working set no entra en su buffer pool, ese mismo scan empieza a tocar disco y el número de “antes” empeora muchísimo. Las dos advertencias significan lo mismo: las proporciones medidas son pisos.
Cuándo Agregar Índices
Indexá las columnas que se usan en:
- Cláusulas
WHERE - Condiciones de
JOIN - Cláusulas
ORDER BY - Claves foráneas, aunque esos índices EF Core te los crea solo
No te pases de índices:
- Cada índice hace más lentos los
INSERT,UPDATEyDELETE - Cada índice ocupa disco
- Demasiados índices le dan al planificador más formas de elegir mal
Cómo Encontrar Índices Faltantes
Prendé el logging de queries en desarrollo y leé los planes de las queries que importan:
// Habilitá el logging de datos sensibles solo en desarrollo
optionsBuilder.EnableSensitiveDataLogging()
.LogTo(Console.WriteLine, LogLevel.Information);
Después llevá el SQL generado a tu base y preguntale qué piensa hacer: EXPLAIN QUERY PLAN en SQLite, EXPLAIN ANALYZE en PostgreSQL, o un plan de ejecución en SQL Server. Lo que buscás es la palabra “scan” sobre una tabla grande.
6. Cartesian Explosion con Múltiples Includes
El Problema
public async Task<List<Order>> GetOrdersWithDetailsAsync()
{
return await _context.Orders
.Include(o => o.Items) // 10 items por orden
.Include(o => o.Payments) // 2 pagos por orden
.Include(o => o.Shipments) // 3 envíos por orden
.ToListAsync();
}
Por Qué Es Lento
EF Core genera una sola query con varios LEFT JOIN, y juntar tres colecciones hermanas produce su producto cartesiano. Para una orden con 10 items, 2 pagos y 3 envíos, el resultado son 10 × 2 × 3 = 60 filas, cada una repitiendo las columnas de la orden.
Para 100 órdenes así, la base devuelve 100 × 60 = 6.000 filas. EF las deduplica en memoria en 100 órdenes, pero recién después de que cada una de esas filas fue producida, transferida y materializada.
La Solución: AsSplitQuery
public async Task<List<Order>> GetOrdersWithDetailsAsync()
{
return await _context.Orders
.Include(o => o.Items)
.Include(o => o.Payments)
.Include(o => o.Shipments)
.AsSplitQuery() // Divide en varias queries
.ToListAsync();
}
Esto ejecuta cuatro statements en lugar de uno:
- Uno para las órdenes
- Uno para los items
- Uno para los pagos
- Uno para los envíos
Ahora las filas se suman en vez de multiplicarse: 100 órdenes + 1.000 items + 200 pagos + 300 envíos.
Impacto en el Rendimiento
Para esas 100 órdenes, con AsSplitQuery como única diferencia entre las dos variantes:
- Filas devueltas: 6.000 antes, 1.600 después. Son 3,75x menos filas, y es aritmética exacta más que una medición: 100 × 10 × 2 × 3 contra 100 + 1.000 + 200 + 300.
- Tiempo: 28,3 ms bajaron a 3,65 ms, 7,76x más rápido. Fue el benchmark más estable de todo el conjunto, entre 7,4x y 7,9x en todas las corridas.
- Memoria: 6,92 MB bajaron a 1,40 MB, 4,94x menos.
Dos observaciones. La primera es que la mejora de tiempo es más grande que la proporción de filas, 7,8x contra 3,75x, porque las filas duplicadas no solo se transfieren: además hay que decodificarlas y volver a fusionarlas en un único grafo de objetos cuando llegan, y con tracking prendido el change tracker suma encima el trabajo del mapa de identidad. La segunda es que este problema, igual que el N+1, empeora en un servidor de base de datos real en lugar de mejorar, porque esas 4.400 filas de más tienen que cruzar la red antes de que se las pueda descartar.
Cuándo Usar AsSplitQuery
Usá AsSplitQuery cuando:
- Estás incluyendo más de una colección
- Los resultados son grandes
- Ves la multiplicación de filas ocurriendo
No lo uses cuando:
- Solo incluís navegaciones de referencia, que no multiplican
- Los resultados son chicos, donde varios round trips cuestan más que la duplicación
Y tené presente el trade-off: las split queries se ejecutan como statements separados, así que sin una transacción explícita no son una foto consistente de los datos.
Hacer que las Split Queries Sean el Default
public class AppDbContext : DbContext
{
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
optionsBuilder.UseQuerySplittingBehavior(QuerySplittingBehavior.SplitQuery);
}
}
Cuando lo necesites, sobrescribilo query por query con .AsSingleQuery().
Bonus: Buenas Prácticas Generales
1. Paginación
Nunca cargues una tabla entera:
// Mal
var products = await _context.Products.ToListAsync();
// Bien
var products = await _context.Products
.OrderBy(p => p.Id)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.ToListAsync();
2. Async de Punta a Punta
// Mal: bloquea el hilo
var products = _context.Products.ToList();
// Bien: async
var products = await _context.Products.ToListAsync();
3. Guardar en Lote
// Mal: N round trips
foreach (var product in products)
{
product.Price *= 1.1m;
await _context.SaveChangesAsync(); // No hagas esto
}
// Bien: una transacción
foreach (var product in products)
{
product.Price *= 1.1m;
}
await _context.SaveChangesAsync(); // Una sola vez, al final
4. Usar ExecuteUpdate para Cambios Masivos (EF Core 7+)
// En lugar de cargar, modificar y guardar
await _context.Products
.Where(p => p.CategoryId == 5)
.ExecuteUpdateAsync(s => s.SetProperty(p => p.Price, p => p.Price * 1.1m));
Un solo UPDATE de SQL, sin cargar nada y sin trackear nada.
Cómo Se Midieron Estos Números
No quería publicar otro artículo lleno de multiplicadores redondos, así que armé un banco de pruebas que corre los seis escenarios y reporta lo que encuentre. Varios resultados volvieron más chicos que los números que este artículo citaba antes. Esos son los números de arriba.
Entorno. Un Intel Core i7-11700K con 46 GiB de RAM, sobre Arch Linux. .NET SDK 10.0.110, EF Core 10.0.10, BenchmarkDotNet 0.15.8.
Base de datos. SQLite en archivo, una base por escenario, sembrada de forma determinística desde una semilla fija de RNG para que volver a correrlo produzca archivos idénticos. Las cantidades de filas son las que nombra cada problema: 600 órdenes con 3.000 items para el caso N+1, 10.000 productos para tracking y compiled queries, 1.000 órdenes con unos 10 KB de payload cada una para la proyección, 1.000.000 de clientes completos en cada una de dos bases para el caso del índice, y 100 órdenes con 10 items, 2 pagos y 3 envíos para el cartesiano.
Método. Cada benchmark es un job de BenchmarkDotNet con 3 iteraciones de calentamiento y 15 medidas, con MemoryDiagnoser activado. El benchmark N+1 usa 10 de calentamiento, porque asigna 37 MB por operación y con 3 todavía no había llegado a un estado estable. Donde reporto un rango, es entre cuatro corridas separadas de todo el conjunto; donde reporto una cifra sola, sale de la corrida cuya salida completa está versionada junto al código. Los problemas 1 y 5 se volvieron a correr por separado después de agregar el tercer caso y los casos de peor escenario, así que sus tablas salen de esas corridas dirigidas, que están versionadas junto a los cuatro logs del conjunto completo. Las cantidades de queries, de filas y los planes de ejecución se recolectan aparte con un DbCommandInterceptor, porque son conteos y no tiempos, y no necesitan un benchmark.
Ahora las advertencias, que importan más que los números.
SQLite corre en proceso, así que toda proporción de tiempo de acá es un piso. No hay conexión TCP, ni handshake de TLS, ni serialización, ni latencia de red. Una “query” es una llamada a una función de una biblioteca. Eso hace que el banco de pruebas sea trivial de reproducir, y hace que los dos problemas que son fundamentalmente de round trips, el N+1 y la explosión cartesiana, se vean mucho más suaves de lo que son en producción. Agregale un milisegundo de latencia por statement y las 1.200 queries de más del problema 1 cuestan más de un segundo por sí solas.
Los conteos no tienen ese problema. 1.201 statements contra 1, y 6.000 filas contra 1.600, son propiedades de la forma de la query. Son idénticos en SQLite, PostgreSQL, SQL Server y cualquier otro motor. Cuando un problema tiene un conteo y un tiempo, confiá en el conteo.
“Memoria” significa memoria administrada, no bytes en un cable. Es lo que reporta MemoryDiagnoser para tu proceso: no es RSS, no es la memoria del servidor de base de datos y no es tráfico de red. Es la cifra sobre la que podés actuar desde el código de tu aplicación, y por eso la reporto, pero no es intercambiable con “datos transferidos”. Donde sí reporto datos de columnas, como en el problema 3, es la suma de las longitudes de los valores que toca la query, medida dentro de la base, y está etiquetada como tal.
Todas las bases están enteras en RAM. La más grande son 113 MB en una máquina con 46 GiB, así que la page cache del sistema operativo las tiene completas y ningún benchmark espera a un disco. En un servidor cuyo working set no entra en su buffer pool, los casos de “antes” empeoran bastante y los de “después” casi no se mueven. Otra razón por la que las proporciones son pisos.
Una máquina de escritorio, cuatro corridas. La máquina estaba en una sesión de escritorio normal, no en un banco de pruebas aislado. Estos números establecen órdenes de magnitud y direcciones. No son constantes portables, y no deberías citarlos como si lo fueran.
Si querés verificar algo de esto, el banco de pruebas, la salida cruda de BenchmarkDotNet y una comparación afirmación por afirmación están en el repositorio de benchmarks. Correrlo lleva un comando y unos cuatro minutos.
Conclusión
EF Core es potente, pero requiere entender qué está pasando abajo. Estos seis problemas explican la gran mayoría de los dolores de rendimiento con EF que me toca ver:
- Queries N+1: usá
IncludeyThenInclude, y desconfiá del lazy loading - Costo del tracking: usá
AsNoTrackingen queries de solo lectura - Cargar de más: proyectá con
Select - Traducción repetida de queries: usá compiled queries en los hot paths
- Índices faltantes: indexá claves foráneas y columnas que filtrás seguido
- Explosión cartesiana: usá
AsSplitQuerycuando incluís varias colecciones
Arreglarlos es casi siempre cuestión de agregar una llamada a un método en el lugar correcto, que es lo que los hace valer la pena.
La lección más amplia, igual, es la que me enseñaron las mediciones. Cuatro de estos seis problemas resultaron menos espectaculares dentro del proceso de lo que sugiere su fama, y los dos que siguieron siendo espectaculares, el índice faltante y la explosión cartesiana, lo siguieron siendo por razones que podés ver en un plan de ejecución y no en un cronómetro. Así que mirá el SQL generado, contá los statements y las filas, y medí tu propio sistema en tu propio hardware. Esos conteos te van a decir más que el multiplicador de cualquiera, incluido el mío.