Entity Framework Core: Ottimizzazione delle Prestazioni e Best Practice nel 2026

Ottimizzazione delle prestazioni di EF Core 10 con AsNoTracking, split query, operazioni batch, il nuovo operatore LeftJoin e filtri di query con nome. Guida pratica con esempi C# per applicazioni .NET 10 in produzione.

Ottimizzazione delle prestazioni di Entity Framework Core con query database e .NET 10

Entity Framework Core 10, rilasciato con .NET 10 LTS a novembre 2025, introduce gli operatori LeftJoin, la ricerca vettoriale, i filtri di query con nome e miglioramenti significativi nella traduzione SQL. Questa guida approfondisce le best practice di EF Core che incidono direttamente sulla velocità delle query, sull'allocazione di memoria e sulla scalabilità in ambienti di produzione.

EF Core 10 richiede esclusivamente .NET 10

EF Core 10 rappresenta una release Long-Term Support, con supporto garantito fino a novembre 2028. Il suo funzionamento richiede SDK e runtime .NET 10. Le applicazioni ancora basate su .NET 8 dovrebbero continuare a utilizzare EF Core 8 (LTS) fino al completamento della migrazione.

Tracking delle Query: Quando Disabilitarlo e Perché

Ogni chiamata a DbSet<T> aggancia le entità restituite al change tracker per impostazione predefinita. Il tracker mantiene uno snapshot dei valori originali delle proprietà, calcola le differenze durante SaveChanges e risolve i conflitti di identità nelle navigazioni. Questo overhead risulta superfluo quando i dati vengono indirizzati direttamente verso una risposta API o un view model di sola lettura.

ProductRepository.cscsharp
public async Task<List<ProductDto>> GetActiveByCategoryAsync(
    int categoryId, CancellationToken ct)
{
    return await _context.Products
        .AsNoTracking()              // skip change tracker entirely
        .Where(p => p.CategoryId == categoryId && p.IsActive)
        .OrderBy(p => p.Name)
        .Select(p => new ProductDto   // project to DTO at the database level
        {
            Id = p.Id,
            Name = p.Name,
            Price = p.Price
        })
        .ToListAsync(ct);
}

AsNoTracking elimina il costo per-entità legato alla creazione dello snapshot e alla risoluzione dell'identità. Combinato con Select, solo le colonne necessarie transitano sulla rete. Su una tabella con 50.000 righe, questo pattern riduce tipicamente le allocazioni di memoria del 40-60% rispetto a una query con tracking completo sull'entità intera.

Per i contesti che non modificano mai i dati, il default si imposta a livello di registrazione:

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

Split Query per Evitare l'Esplosione Cartesiana

Il caricamento di un'entità con più navigazioni di collezione tramite Include genera un singolo statement SQL con JOIN. Quando due o più collezioni vengono caricate simultaneamente, il result set cresce come prodotto cartesiano delle collezioni, duplicando i dati della riga padre per ogni combinazione.

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

AsSplitQuery suddivide il caricamento in statement SQL separati per ogni navigazione. Il compromesso consiste in più round-trip al posto di uno solo, ma ogni result set rimane contenuto ed evita la duplicazione delle righe. EF Core 10 corregge inoltre un'incoerenza di lunga data nell'ordinamento delle split query, garantendo che l'ordinamento delle sottoquery coincida con quello della query principale.

Quando preferire la query singola

Le query singole restano preferibili quando si carica una sola navigazione di collezione o quando la latenza dei round-trip risulta elevata (chiamate cross-region al database). Prima di prendere una decisione definitiva, conviene eseguire benchmark su entrambe le modalità per lo specifico pattern di accesso.

Operazioni Batch con ExecuteUpdate e ExecuteDelete

Il flusso tradizionale di EF Core prevede il caricamento delle entità, la modifica delle proprietà e la chiamata a SaveChanges. Per operazioni massive che coinvolgono migliaia di righe, questo approccio genera migliaia di istanze tracciate e singoli statement UPDATE. EF Core 7 ha introdotto ExecuteUpdateAsync e ExecuteDeleteAsync per condensare l'operazione in un unico comando SQL. EF Core 10 semplifica ulteriormente l'API accettando una semplice lambda al posto di un expression tree.

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 = number of rows updated
}

Il risultato è un singolo UPDATE ... SET ... WHERE a livello SQL. Nessuna entità viene caricata in memoria. Per un aggiornamento di 10.000 righe, il tempo di esecuzione scende da secondi (approccio con tracking) a millisecondi.

Lo stesso pattern si applica alle eliminazioni:

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

Pronto a superare i tuoi colloqui su .NET?

Pratica con i nostri simulatori interattivi, flashcards e test tecnici.

L'Operatore LeftJoin in EF Core 10

Prima di EF Core 10, eseguire un LEFT JOIN richiedeva la combinazione di GroupJoin, SelectMany e DefaultIfEmpty in un pattern specifico che la maggior parte degli sviluppatori doveva consultare ogni volta. EF Core 10 aggiunge LeftJoin come operatore LINQ di prima classe.

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);
}

Il codice SQL generato utilizza una clausola LEFT JOIN standard. L'operatore RightJoin risulta anch'esso disponibile. Entrambi gli operatori eliminano la catena verbosa dei tre metodi precedentemente necessaria.

Filtri di Query con Nome per Filtraggio Multi-Concern

I global query filter sono stati limitati a un singolo filtro per tipo di entità sin da EF Core 2.0. Le applicazioni che implementavano sia il soft-delete sia la multi-tenancy dovevano combinare le condizioni in un'unica espressione, senza possibilità di disabilitarle selettivamente. EF Core 10 introduce i filtri di query con nome.

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

Gli endpoint di amministrazione possono disabilitare il filtro soft-delete mantenendo attivo l'isolamento del tenant:

InvoiceRepository.cscsharp
public async Task<List<Invoice>> GetAllIncludingDeletedAsync(CancellationToken ct)
{
    return await _context.Invoices
        .IgnoreQueryFilters(["SoftDelete"])  // tenant filter still applied
        .ToListAsync(ct);
}

Questo approccio elimina la necessità di metodi di estensione IQueryable personalizzati che aggiungevano manualmente le condizioni di filtro.

Resilienza delle Connessioni e Configurazione del Pooling

I guasti transitori del database (interruzioni di rete, failover di Azure SQL, esaurimento del pool di connessioni) generano eccezioni che spesso causano il crash delle pipeline di richiesta. EF Core fornisce una logica di retry integrata, ma i valori predefiniti richiedono una configurazione esplicita.

Program.cscsharp
builder.Services.AddDbContext<AppDbContext>(options =>
    options.UseSqlServer(connectionString, sqlOptions =>
    {
        sqlOptions.EnableRetryOnFailure(
            maxRetryCount: 5,
            maxRetryDelay: TimeSpan.FromSeconds(10),
            errorNumbersToAdd: null);    // retry on all transient errors
        sqlOptions.CommandTimeout(30);   // 30-second command timeout
    }));

Anche i parametri di pooling nella stringa di connessione rivestono un ruolo importante:

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

Min Pool Size mantiene connessioni pronte per picchi di traffico improvvisi. Max Pool Size limita il numero totale di connessioni aperte per prevenire il sovraccarico del database. Il valore predefinito di 100 risulta adeguato per la maggior parte delle applicazioni web, ma i servizi ad alto throughput potrebbero necessitare di un tuning basato sul volume effettivo di query concorrenti.

Logica di retry e transazioni

I retry automatici non funzionano all'interno di transazioni avviate dall'utente. L'intera transazione va racchiusa in una strategia di retry manuale utilizzando context.Database.CreateExecutionStrategy().ExecuteAsync(...) per gestire i guasti transitori che coinvolgono più operazioni.

Strategia di Indicizzazione e Analisi delle Query

Le migrazioni di EF Core consentono di definire gli indici in modo dichiarativo, ma la scelta delle colonne da indicizzare richiede la comprensione dei pattern di accesso alle query.

AppDbContext.cs - OnModelCreatingcsharp
modelBuilder.Entity<Order>(entity =>
{
    // composite index for frequent query pattern
    entity.HasIndex(o => new { o.CustomerId, o.Status, o.CreatedAt })
          .HasDatabaseName("IX_Order_Customer_Status_Date");

    // filtered index for active orders only
    entity.HasIndex(o => o.Status)
          .HasFilter("[Status] <> 'Cancelled'")
          .HasDatabaseName("IX_Order_ActiveStatus");
});

Gli indici filtrati riducono le dimensioni dell'indice escludendo le righe che le query non interrogano mai. Per una tabella con l'80% di ordini cancellati, un indice filtrato sugli stati attivi può risultare 5 volte più piccolo e veloce da scansionare.

Per analizzare il codice SQL generato, si abilita il logging in ambiente di sviluppo:

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

Il codice SQL registrato nei log va copiato in SQL Server Management Studio ed eseguito con SET STATISTICS IO ON per verificare le letture logiche, oppure si utilizza EXPLAIN ANALYZE su PostgreSQL. I suggerimenti di indici mancanti nel piano di esecuzione rivelano spesso le opportunità di ottimizzazione con il maggiore impatto.

Compiled Query per gli Hot Path

Gli expression tree LINQ vengono analizzati e tradotti in SQL ad ogni esecuzione. Per le query eseguite migliaia di volte al minuto (ad esempio, lookup di autenticazione, validazione di sessione), questo costo di traduzione si accumula. Le compiled query memorizzano nella cache la traduzione all'avvio dell'applicazione.

CompiledQueries.cscsharp
public static class UserQueries
{
    // compiled once, reused on every call
    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));
}

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

Le compiled query saltano completamente la fase di parsing dell'expression tree. La differenza di prestazioni risulta misurabile solo su percorsi ad alta frequenza (oltre 1.000 chiamate al minuto). Per gli endpoint CRUD standard, la query cache integrata in EF Core gestisce già il riutilizzo della traduzione.

Traduzione delle Collezioni Parametrizzate in EF Core 10

Le query che filtrano per una lista di ID rappresentano uno dei pattern più comuni nell'accesso ai dati. EF Core 10 modifica la strategia di traduzione predefinita per le collezioni parametrizzate. Invece di codificare la lista come array JSON (EF Core 8-9) o di inserire costanti inline (EF Core 7 e precedenti), EF Core 10 traduce ogni valore come parametro SQL separato.

csharp
// Before (EF Core 8-9): JSON array parameter
// @__ids_0='[1,2,3]'
// SELECT ... WHERE Id IN (SELECT value FROM OPENJSON(@__ids_0))

// EF Core 10: individual parameters with 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);

Il nuovo approccio fornisce al query planner del database informazioni sulla cardinalità, evitando al contempo l'inflazione della plan cache attraverso il padding dei parametri. Nei casi in cui l'approccio JSON offra prestazioni superiori (collezioni molto grandi), il comportamento si sovrascrive per la singola query:

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

Inizia a praticare!

Metti alla prova le tue conoscenze con i nostri simulatori di colloquio e test tecnici.

Conclusione

  • Utilizzare AsNoTracking() e proiezioni Select su ogni query di sola lettura per eliminare l'overhead del change tracker
  • Applicare AsSplitQuery() durante il caricamento di più navigazioni di collezione per evitare l'esplosione cartesiana
  • Sostituire il pattern tracked load-modify-save con ExecuteUpdateAsync e ExecuteDeleteAsync per le operazioni massive
  • Adottare l'operatore LeftJoin di EF Core 10 in sostituzione della catena verbosa GroupJoin/SelectMany/DefaultIfEmpty
  • Configurare i filtri di query con nome per gestire soft-delete e multi-tenancy in modo indipendente
  • Impostare EnableRetryOnFailure e calibrare le dimensioni del connection pool per la resilienza in produzione
  • Definire indici compositi e filtrati basandosi sui pattern reali delle query, non su supposizioni
  • Riservare le compiled query ai percorsi realmente critici che superano le 1.000 esecuzioni al minuto
  • Lasciare che EF Core 10 gestisca la traduzione delle collezioni parametrizzate per impostazione predefinita, sovrascrivendo il comportamento solo quando i benchmark lo giustifichino

Approfondimenti: il modulo avanzato su EF Core tratta questi pattern in un contesto di colloquio tecnico, mentre la guida alla Clean Architecture con .NET illustra come strutturare i livelli repository e service che incapsulano queste query.

Inizia a praticare!

Metti alla prova le tue conoscenze con i nostri simulatori di colloquio e test tecnici.

Tag

#dotnet
#entity framework core
#csharp
#performance
#ef core 10

Condividi

Articoli correlati