Entity Framework Core: Optimización del Rendimiento y Buenas Prácticas en 2026

Guía completa de optimización de rendimiento con Entity Framework Core 10 en .NET 10. AsNoTracking, consultas compiladas, actualizaciones por lotes, split queries y LeftJoin.

Entity Framework Core performance optimization guide

Entity Framework Core 10, distribuido con .NET 10 LTS en noviembre de 2025, introduce operadores LeftJoin, búsqueda vectorial, filtros de consulta con nombre y mejoras significativas en la traducción SQL. Esta guía cubre las buenas prácticas de EF Core que impactan directamente la velocidad de las consultas, la asignación de memoria y la escalabilidad en producción.

EF Core 10 solo funciona con .NET 10

EF Core 10 es una versión Long-Term Support, con soporte hasta noviembre de 2028. Requiere el SDK y el runtime de .NET 10. Las aplicaciones que todavía utilizan .NET 8 deben apuntar a EF Core 8 (LTS) hasta completar la migración.

Seguimiento de Consultas: Cuándo Desactivarlo y Por Qué

Cada llamada a DbSet<T> adjunta las entidades devueltas al change tracker de forma predeterminada. El tracker mantiene una instantánea de los valores originales de las propiedades, calcula las diferencias al ejecutar SaveChanges y resuelve conflictos de identidad entre las navegaciones. Esa sobrecarga es innecesaria cuando los datos fluyen directamente hacia una respuesta de API o un modelo de vista de solo lectura.

ProductRepository.cscsharp
public async Task<List<ProductDto>> GetActiveByCategoryAsync(
    int categoryId, CancellationToken ct)
{
    return await _context.Products
        .AsNoTracking()              // omitir el change tracker por completo
        .Where(p => p.CategoryId == categoryId && p.IsActive)
        .OrderBy(p => p.Name)
        .Select(p => new ProductDto   // proyectar al DTO a nivel de base de datos
        {
            Id = p.Id,
            Name = p.Name,
            Price = p.Price
        })
        .ToListAsync(ct);
}

AsNoTracking elimina la sobrecarga por entidad de la creación de instantáneas y la resolución de identidad. Combinado con Select, solo las columnas necesarias viajan por la red. En una tabla con 50,000 registros, este patrón reduce típicamente las asignaciones de memoria entre un 40 y un 60% en comparación con una consulta completa con seguimiento.

Para contextos que nunca modifican datos, se puede establecer el comportamiento predeterminado durante el registro:

Program.cscsharp
builder.Services.AddDbContext<CatalogContext>(options =>
    options.UseSqlServer(connectionString)
           .UseQueryTrackingBehavior(QueryTrackingBehavior.NoTracking));

Split Queries para Evitar la Explosión Cartesiana

Cargar una entidad con múltiples navegaciones de colección mediante Include genera una única sentencia SQL con JOINs. Cuando se cargan dos o más colecciones simultáneamente, el conjunto de resultados crece como un producto cartesiano de las colecciones, duplicando los datos de la fila padre en cada combinación.

OrderRepository.cscsharp
public async Task<Order?> GetWithDetailsAsync(int orderId, CancellationToken ct)
{
    return await _context.Orders
        .AsSplitQuery()              // un SQL por cada Include
        .Include(o => o.Items)
            .ThenInclude(i => i.Product)
        .Include(o => o.Payments)
        .Include(o => o.ShippingEvents)
        .FirstOrDefaultAsync(o => o.Id == orderId, ct);
}

AsSplitQuery descompone la carga en sentencias SQL separadas por cada navegación. La contrapartida: múltiples viajes de ida y vuelta en lugar de uno, pero cada conjunto de resultados permanece pequeño y evita el problema de duplicación de filas. EF Core 10 también corrige una inconsistencia de larga data en el ordenamiento de las split queries, asegurando que el orden de la subconsulta coincida con el de la consulta principal.

Cuándo usar consultas únicas

Las consultas únicas siguen siendo preferibles al cargar una sola navegación de colección o cuando la latencia de los viajes de ida y vuelta es alta (llamadas a bases de datos entre regiones). Es recomendable hacer benchmark de ambos modos para el patrón de acceso específico antes de tomar una decisión.

Operaciones por Lotes con ExecuteUpdate y ExecuteDelete

El flujo de trabajo tradicional de EF carga las entidades, modifica las propiedades y luego llama a SaveChanges. Para operaciones masivas que afectan miles de filas, esto crea miles de instancias rastreadas y sentencias UPDATE individuales. EF Core 7 introdujo ExecuteUpdateAsync y ExecuteDeleteAsync para enviar la operación como un único comando SQL. EF Core 10 simplifica aún más la API al aceptar una lambda simple en lugar de un árbol de expresiones.

PromotionService.cscsharp
public async Task ApplySeasonalDiscountAsync(
    int categoryId, decimal discountPercent, CancellationToken ct)
{
    var affected = await _context.Products
        .Where(p => p.CategoryId == categoryId && p.IsActive)
        .ExecuteUpdateAsync(s =>
        {
            s.SetProperty(p => p.Price, p => p.Price * (1 - discountPercent / 100));
            s.SetProperty(p => p.LastModified, DateTime.UtcNow);
        }, ct);

    // affected = número de filas actualizadas
}

Esto se traduce en una única sentencia UPDATE ... SET ... WHERE. No se cargan entidades en memoria. Para una actualización de 10,000 filas, el tiempo de ejecución pasa de varios segundos (enfoque con seguimiento) a milisegundos.

El mismo patrón aplica para las eliminaciones:

CleanupService.cscsharp
public async Task PurgeExpiredSessionsAsync(CancellationToken ct)
{
    await _context.Sessions
        .Where(s => s.ExpiresAt < DateTime.UtcNow)
        .ExecuteDeleteAsync(ct);
}

¿Listo para aprobar tus entrevistas de .NET?

Practica con nuestros simuladores interactivos, flashcards y tests técnicos.

El Operador LeftJoin en EF Core 10

Antes de EF Core 10, realizar un LEFT JOIN requería combinar GroupJoin, SelectMany y DefaultIfEmpty en un patrón específico que la mayoría de los desarrolladores tenían que buscar cada vez. EF Core 10 agrega LeftJoin como un operador LINQ de primera clase.

ReportService.cscsharp
public async Task<List<EmployeeReportDto>> GetEmployeeDepartmentReportAsync(
    CancellationToken ct)
{
    return await _context.Employees
        .LeftJoin(
            _context.Departments,
            employee => employee.DepartmentId,
            department => department.Id,
            (employee, department) => new EmployeeReportDto
            {
                FullName = employee.FirstName + " " + employee.LastName,
                Department = department.Name ?? "Unassigned",
                HiredAt = employee.HiredAt
            })
        .OrderBy(r => r.FullName)
        .ToListAsync(ct);
}

El SQL generado utiliza una cláusula LEFT JOIN estándar. El operador RightJoin también está disponible. Ambos operadores eliminan la cadena verbosa de tres métodos que se requería anteriormente.

Filtros de Consulta con Nombre para Filtrado de Múltiples Preocupaciones

Los filtros de consulta globales han estado limitados a un solo filtro por tipo de entidad desde EF Core 2.0. Las aplicaciones que implementan tanto eliminación lógica como multi-tenancy tenían que combinar las condiciones en una sola expresión y no podían desactivarlas selectivamente. EF Core 10 introduce los filtros de consulta con nombre.

AppDbContext.cscsharp
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.Entity<Invoice>()
        .HasQueryFilter("SoftDelete", i => !i.IsDeleted)
        .HasQueryFilter("Tenant", i => i.TenantId == _tenantId);
}

Los endpoints de administración pueden desactivar el filtro de eliminación lógica mientras mantienen activo el aislamiento de tenants:

InvoiceRepository.cscsharp
public async Task<List<Invoice>> GetAllIncludingDeletedAsync(CancellationToken ct)
{
    return await _context.Invoices
        .IgnoreQueryFilters(["SoftDelete"])  // el filtro de tenant sigue aplicado
        .ToListAsync(ct);
}

Esto elimina la necesidad de métodos de extensión IQueryable personalizados que agregaban manualmente las condiciones de filtrado.

Resiliencia de Conexiones y Configuración del Pool

Las fallas transitorias de base de datos (interrupciones de red, failovers de Azure SQL, agotamiento del pool de conexiones) causan excepciones que frecuentemente hacen fallar los pipelines de solicitudes. EF Core proporciona lógica de reintento integrada, pero los valores predeterminados requieren configuración explícita.

Program.cscsharp
builder.Services.AddDbContext<AppDbContext>(options =>
    options.UseSqlServer(connectionString, sqlOptions =>
    {
        sqlOptions.EnableRetryOnFailure(
            maxRetryCount: 5,
            maxRetryDelay: TimeSpan.FromSeconds(10),
            errorNumbersToAdd: null);    // reintentar en todos los errores transitorios
        sqlOptions.CommandTimeout(30);   // timeout de comando de 30 segundos
    }));

Los parámetros de pooling de la cadena de conexión también importan:

text
Server=db.example.com;Database=AppDb;Min Pool Size=5;Max Pool Size=100;Connection Timeout=15;

Min Pool Size mantiene conexiones preparadas para ráfagas de tráfico. Max Pool Size limita el total de conexiones abiertas para prevenir la sobrecarga de la base de datos. El valor predeterminado de 100 funciona para la mayoría de las aplicaciones web, pero los servicios de alto rendimiento pueden necesitar ajustes basados en el volumen real de consultas concurrentes.

Lógica de reintento y transacciones

Los reintentos automáticos no funcionan dentro de transacciones iniciadas por el usuario. Es necesario envolver toda la transacción en una estrategia de reintento manual usando context.Database.CreateExecutionStrategy().ExecuteAsync(...) para manejar fallas transitorias a través de múltiples operaciones.

Estrategia de Indexación y Análisis de Consultas

Las migraciones de EF Core permiten definir índices de forma declarativa, pero elegir qué columnas indexar requiere comprender los patrones de consulta.

AppDbContext.cs - OnModelCreatingcsharp
modelBuilder.Entity<Order>(entity =>
{
    // índice compuesto para un patrón de consulta frecuente
    entity.HasIndex(o => new { o.CustomerId, o.Status, o.CreatedAt })
          .HasDatabaseName("IX_Order_Customer_Status_Date");

    // índice filtrado solo para pedidos activos
    entity.HasIndex(o => o.Status)
          .HasFilter("[Status] <> 'Cancelled'")
          .HasDatabaseName("IX_Order_ActiveStatus");
});

Los índices filtrados reducen el tamaño del índice al excluir filas que las consultas nunca apuntan. Para una tabla con un 80% de pedidos cancelados, un índice filtrado sobre estados activos puede ser 5 veces más pequeño y rápido de recorrer.

Para analizar el SQL generado, se puede habilitar el registro en desarrollo:

Program.cs (solo desarrollo)csharp
builder.Services.AddDbContext<AppDbContext>(options =>
    options.UseSqlServer(connectionString)
           .LogTo(Console.WriteLine, LogLevel.Information)
           .EnableSensitiveDataLogging());

Se recomienda copiar el SQL registrado en SQL Server Management Studio y ejecutarlo con SET STATISTICS IO ON para verificar las lecturas lógicas, o usar EXPLAIN ANALYZE en PostgreSQL. Las sugerencias de índices faltantes del plan de consulta suelen revelar las oportunidades de optimización de mayor impacto.

Consultas Compiladas para Rutas Críticas

Los árboles de expresiones LINQ se analizan y traducen a SQL en cada ejecución. Para consultas que se ejecutan miles de veces por minuto (búsquedas de autenticación, validación de sesiones), este costo de traducción se acumula. Las consultas compiladas almacenan en caché la traducción al iniciar la aplicación.

CompiledQueries.cscsharp
public static class UserQueries
{
    // compilada una vez, reutilizada en cada llamada
    public static readonly Func<AppDbContext, string, CancellationToken, Task<UserSession?>>
        GetActiveSession = EF.CompileAsyncQuery(
            (AppDbContext ctx, string token, CancellationToken ct) =>
                ctx.UserSessions
                    .AsNoTracking()
                    .FirstOrDefault(s => s.Token == token && s.ExpiresAt > DateTime.UtcNow));
}

// Uso en middleware
var session = await UserQueries.GetActiveSession(dbContext, bearerToken, ct);

Las consultas compiladas omiten por completo la fase de análisis del árbol de expresiones. La diferencia de rendimiento solo es medible en rutas de alta frecuencia (más de 1,000 llamadas/minuto). Para endpoints CRUD estándar, la caché de consultas integrada de EF Core ya maneja la reutilización de la traducción.

Traducción de Colecciones Parametrizadas en EF Core 10

Las consultas que filtran por una lista de IDs representan uno de los patrones más comunes en el acceso a datos. EF Core 10 cambia la estrategia de traducción predeterminada para colecciones parametrizadas. En lugar de codificar la lista como un arreglo JSON (EF Core 8-9) o insertar constantes en línea (EF Core 7 y anteriores), EF Core 10 traduce cada valor como un parámetro SQL separado.

csharp
// Antes (EF Core 8-9): parámetro de arreglo JSON
// @__ids_0='[1,2,3]'
// SELECT ... WHERE Id IN (SELECT value FROM OPENJSON(@__ids_0))

// EF Core 10: parámetros individuales con padding
// SELECT ... WHERE Id IN (@ids1, @ids2, @ids3)

var orderIds = new[] { 101, 205, 389 };
var orders = await _context.Orders
    .Where(o => orderIds.Contains(o.Id))
    .ToListAsync(ct);

El nuevo enfoque proporciona al planificador de consultas de la base de datos información de cardinalidad mientras evita la saturación de la caché de planes mediante el padding de parámetros. Para casos donde el enfoque JSON tiene mejor rendimiento (colecciones muy grandes), se puede sobrescribir el comportamiento por consulta:

csharp
var orders = await _context.Orders
    .Where(o => EF.Parameter(orderIds).Contains(o.Id))  // forzar modo JSON
    .ToListAsync(ct);

¡Empieza a practicar!

Pon a prueba tu conocimiento con nuestros simuladores de entrevista y tests técnicos.

Conclusión

  • Usar AsNoTracking() y proyecciones Select en cada consulta de solo lectura para eliminar la sobrecarga del change tracker
  • Aplicar AsSplitQuery() al cargar múltiples navegaciones de colección para evitar la explosión cartesiana
  • Reemplazar los patrones de cargar-modificar-guardar con seguimiento por ExecuteUpdateAsync y ExecuteDeleteAsync para operaciones masivas
  • Adoptar el operador LeftJoin de EF Core 10 para reemplazar la cadena verbosa GroupJoin/SelectMany/DefaultIfEmpty
  • Configurar los filtros de consulta con nombre para gestionar eliminación lógica y multi-tenancy de forma independiente
  • Configurar EnableRetryOnFailure y ajustar los tamaños del pool de conexiones para la resiliencia en producción
  • Definir índices compuestos y filtrados basados en patrones de consulta reales, no en suposiciones
  • Reservar las consultas compiladas para rutas genuinamente críticas que superen las 1,000 ejecuciones por minuto
  • Dejar que EF Core 10 maneje la traducción de colecciones parametrizadas por defecto, y sobrescribir solo cuando los benchmarks lo justifiquen

Para profundizar: el módulo avanzado de EF Core cubre estos patrones en un contexto de entrevista técnica, y la guía de Clean Architecture con .NET demuestra cómo estructurar las capas de repositorio y servicio que encapsulan estas consultas.

¡Empieza a practicar!

Pon a prueba tu conocimiento con nuestros simuladores de entrevista y tests técnicos.

Etiquetas

#dotnet
#entity-framework-core
#performance
#best-practices

Compartir

Artículos relacionados