AL.
🇺🇸 EN
Volver al blog
.NET y C# · 8 min de lectura

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 (EF Core) es un ORM poderoso que simplifica el acceso a datos en .NET. Pero con ese poder viene la responsabilidad de usarlo correctamente. Los desarrolladores frecuentemente caen en patrones que funcionan bien en desarrollo pero se desmoronan bajo carga de producción.

En este artículo, cubriremos 6 problemas comunes de rendimiento en EF Core, por qué ocurren, y cómo solucionarlos con ejemplos del mundo real.

1. El Problema N+1

Este es el problema de rendimiento más común en ORMs. Ocurre cuando ejecutas una query para recuperar una lista de entidades, luego ejecutas queries adicionales por cada entidad para cargar datos relacionados.

Mal Ejemplo

public async Task<List<OrderDto>> GetOrdersAsync()
{
    var orders = await _context.Orders.ToListAsync();

    var result = new List<OrderDto>();
    foreach (var order in orders)
    {
        // Esto ejecuta una nueva query POR CADA orden
        var customer = await _context.Customers
            .FirstOrDefaultAsync(c => c.Id == order.CustomerId);

        result.Add(new OrderDto
        {
            OrderId = order.Id,
            CustomerName = customer?.Name
        });
    }

    return result;
}

Problema: Si tienes 100 órdenes, esto ejecuta 1 query inicial + 100 queries = 101 queries totales.

SQL Generado:

-- Query 1: Cargar órdenes
SELECT * FROM Orders;

-- Query 2: Cargar cliente para orden 1
SELECT * FROM Customers WHERE Id = 1;

-- Query 3: Cargar cliente para orden 2
SELECT * FROM Customers WHERE Id = 2;

-- ... 98 más queries ...

Solución: Usar Include (Eager Loading)

public async Task<List<OrderDto>> GetOrdersAsync()
{
    return await _context.Orders
        .Include(o => o.Customer) // Carga clientes en una sola query
        .Select(o => new OrderDto
        {
            OrderId = o.Id,
            CustomerName = o.Customer.Name
        })
        .ToListAsync();
}

SQL Generado:

SELECT o.Id, o.CustomerId, c.Name
FROM Orders o
LEFT JOIN Customers c ON o.CustomerId = c.Id;

Ahora solo 1 query en lugar de 101.

Cuándo Usar

  • Eager Loading (Include): Cuando siempre necesitas datos relacionados.
  • Explicit Loading: Cuando solo a veces necesitas datos relacionados.
  • Lazy Loading: Evítalo en producción (puede causar N+1 accidentalmente).

2. Tracking de Entidades Innecesario

Por default, EF Core rastrea todas las entidades cargadas en el DbContext. Esto permite detección de cambios para updates, pero viene con sobrecarga de memoria y CPU.

Mal Ejemplo

public async Task<List<ProductDto>> GetProductsAsync()
{
    // EF Core rastrea todas las entidades Product
    var products = await _context.Products
        .Where(p => p.IsActive)
        .ToListAsync();

    return products.Select(p => new ProductDto
    {
        Id = p.Id,
        Name = p.Name,
        Price = p.Price
    }).ToList();
}

Problema: Rastreamos entidades que nunca modificaremos, desperdiciando memoria.

Solución 1: Proyectar Directamente a DTO

public async Task<List<ProductDto>> GetProductsAsync()
{
    return await _context.Products
        .Where(p => p.IsActive)
        .Select(p => new ProductDto
        {
            Id = p.Id,
            Name = p.Name,
            Price = p.Price
        })
        .ToListAsync();
}

EF Core no rastrea el resultado porque estás proyectando a un tipo diferente.

Solución 2: Usar AsNoTracking

public async Task<List<Product>> GetProductsAsync()
{
    return await _context.Products
        .AsNoTracking() // Deshabilita tracking
        .Where(p => p.IsActive)
        .ToListAsync();
}

Cuándo Usar:

  • AsNoTracking(): Queries read-only.
  • Tracking (default): Cuando planeas modificar y guardar entidades.

3. Cargar Entidades Completas Cuando Solo Necesitas Algunos Campos

Cargar columnas que no necesitas desperdicia ancho de banda de red y memoria.

Mal Ejemplo

public async Task<List<string>> GetProductNamesAsync()
{
    var products = await _context.Products.ToListAsync(); // Carga TODAS las columnas
    return products.Select(p => p.Name).ToList();
}

SQL Generado:

SELECT Id, Name, Description, Price, Stock, ImageUrl, CreatedAt, UpdatedAt, ...
FROM Products;

Problema: Cargamos 10+ columnas cuando solo necesitamos Name.

Solución: Proyectar Solo Lo Que Necesitas

public async Task<List<string>> GetProductNamesAsync()
{
    return await _context.Products
        .Select(p => p.Name) // Solo selecciona Name
        .ToListAsync();
}

SQL Generado:

SELECT Name FROM Products;

Mucho más eficiente.

4. No Usar Compiled Queries para Queries Frecuentes

EF Core traduce expresiones LINQ a SQL en cada ejecución. Para queries que corren frecuentemente, esto desperdicia CPU.

Mal Ejemplo

public async Task<User?> GetUserByIdAsync(int id)
{
    return await _context.Users
        .Where(u => u.Id == id)
        .FirstOrDefaultAsync();
}

Problema: Cada llamada recompila la expresión LINQ a SQL.

Solución: Usar Compiled Queries

private static readonly Func<AppDbContext, int, Task<User?>> _getUserById =
    EF.CompileAsyncQuery((AppDbContext context, int id) =>
        context.Users.Where(u => u.Id == id).FirstOrDefault());

public async Task<User?> GetUserByIdAsync(int id)
{
    return await _getUserById(_context, id);
}

Beneficio: La query se compila una vez y se reutiliza, reduciendo sobrecarga de CPU.

Cuándo Usar

  • Queries que se ejecutan cientos/miles de veces por segundo.
  • Queries en hot paths (ej. autenticación, APIs públicas).

5. Índices Faltantes en Columnas de Filtro/Join

EF Core genera SQL, pero no crea índices automáticamente. Sin índices apropiados, las queries escanean tablas completas.

Mal Ejemplo

public async Task<List<Order>> GetOrdersByCustomerEmailAsync(string email)
{
    return await _context.Orders
        .Include(o => o.Customer)
        .Where(o => o.Customer.Email == email)
        .ToListAsync();
}

SQL Generado:

SELECT *
FROM Orders o
JOIN Customers c ON o.CustomerId = c.Id
WHERE c.Email = 'user@example.com';

Problema: Si Customers.Email no tiene índice, esto escanea toda la tabla Customers.

Solución: Agregar Índice

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.Entity<Customer>()
        .HasIndex(c => c.Email); // Crea índice en Email
}

Luego genera una migración:

dotnet ef migrations add AddEmailIndex
dotnet ef database update

SQL de Migración:

CREATE INDEX IX_Customers_Email ON Customers (Email);

Ahora la query usa el índice en lugar de un table scan.

Mejores Prácticas

  • Indexa columnas frecuentemente usadas en WHERE, JOIN, ORDER BY.
  • Usa índices compuestos para queries con múltiples condiciones.
  • Monitorea ejecución de queries (usa herramientas como SQL Server Profiler, ToQueryString()).

6. Cartesian Explosion con Múltiples Includes

Cuando usas múltiples Include() para colecciones, EF Core puede generar JOINs que producen cartesian products, retornando filas duplicadas masivamente.

Mal Ejemplo

public async Task<Order?> GetOrderDetailsAsync(int orderId)
{
    return await _context.Orders
        .Include(o => o.Items)        // Colección 1
        .Include(o => o.Payments)     // Colección 2
        .FirstOrDefaultAsync(o => o.Id == orderId);
}

Problema: Si una orden tiene 10 items y 5 payments, EF Core genera un JOIN que retorna 10 × 5 = 50 filas en lugar de 1.

SQL Generado:

SELECT o.*, i.*, p.*
FROM Orders o
LEFT JOIN OrderItems i ON o.Id = i.OrderId
LEFT JOIN Payments p ON o.Id = p.OrderId
WHERE o.Id = 1;

Esto retorna:

| OrderId | ItemId | PaymentId |
|---------|--------|-----------|
| 1       | 1      | 1         |
| 1       | 1      | 2         |
| 1       | 1      | 3         |
| 1       | 2      | 1         |
| 1       | 2      | 2         |
| ...     | ...    | ...       |

EF Core de-duplica esto en memoria, pero todavía transfirió 50 filas en lugar de 16.

Solución: Usar Split Queries

public async Task<Order?> GetOrderDetailsAsync(int orderId)
{
    return await _context.Orders
        .AsSplitQuery() // Divide en queries separadas
        .Include(o => o.Items)
        .Include(o => o.Payments)
        .FirstOrDefaultAsync(o => o.Id == orderId);
}

SQL Generado (3 queries separadas):

-- Query 1: Orden
SELECT * FROM Orders WHERE Id = 1;

-- Query 2: Items
SELECT * FROM OrderItems WHERE OrderId = 1;

-- Query 3: Payments
SELECT * FROM Payments WHERE OrderId = 1;

Ahora solo 1 + 10 + 5 = 16 filas transferidas.

Cuándo Usar

  • Usa AsSplitQuery() al incluir múltiples colecciones.
  • Usa default (single query) cuando incluyes solo propiedades de navegación de referencia (uno-a-uno, muchos-a-uno).

Bonus: Debugging de Queries

Siempre verifica qué SQL genera EF Core:

Método 1: Logging

protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
    optionsBuilder
        .UseSqlServer("your-connection-string")
        .LogTo(Console.WriteLine, LogLevel.Information);
}

Método 2: ToQueryString()

var query = _context.Orders
    .Include(o => o.Customer)
    .Where(o => o.Status == "Pending");

var sql = query.ToQueryString();
Console.WriteLine(sql);

Método 3: Herramientas de Profiling

  • SQL Server: SQL Server Profiler
  • PostgreSQL: pg_stat_statements
  • MySQL: Slow Query Log

Resumen de Mejores Prácticas

  1. Evita N+1: Usa Include() o proyecta a DTOs con Select().
  2. Deshabilita tracking cuando sea read-only: Usa AsNoTracking() o proyecta directamente.
  3. Proyecta solo lo que necesitas: No cargues columnas innecesarias.
  4. Compila queries frecuentes: Usa EF.CompileAsyncQuery().
  5. Agrega índices: En columnas de filtro, join y ordenamiento.
  6. Divide queries con múltiples colecciones: Usa AsSplitQuery() para prevenir cartesian explosion.

Conclusión

EF Core es increíblemente productivo, pero puedes escribir fácilmente código que funciona en desarrollo pero falla en producción. Los 6 problemas cubiertos aquí son los más comunes:

  1. N+1 queries
  2. Tracking innecesario
  3. Cargar entidades completas
  4. No usar compiled queries
  5. Índices faltantes
  6. Cartesian explosion

Al seguir las mejores prácticas en esta guía, harás que tus aplicaciones EF Core sean más rápidas, usen menos memoria y escalen mejor.

Recuerda siempre:

  • Revisa el SQL generado.
  • Mide el rendimiento en producción.
  • Itera y optimiza.

EF Core es una gran herramienta — úsala sabiamente, y potenciará tu aplicación en lugar de ralentizarla.