Argomenti sulle prestazioni avanzate

Pooling DbContext

Un DbContext è in genere un oggetto chiaro: la creazione e l'eliminazione di uno non comporta un'operazione di database e la maggior parte delle applicazioni può farlo senza alcun impatto notevole sulle prestazioni. Tuttavia, ogni istanza di contesto configura vari servizi e oggetti interni necessari per l'esecuzione dei compiti e il sovraccarico di questa operazione può essere significativo in scenari a prestazioni elevate. Per questi casi, EF Core può raggruppare le istanze di contesto: quando si elimina il contesto, EF Core reimposta lo stato e lo archivia in un pool interno. Quando viene richiesta una nuova istanza, tale istanza in pool viene restituita anziché impostarne una nuova. Il pool di contesti consente di pagare i costi di configurazione del contesto solo una volta all'avvio del programma, anziché continuamente.

Si noti che il pool di contesti è ortogonale al pool di connessioni al database, che viene gestito a un livello inferiore nel driver di database.

Il modello tipico in un'app ASP.NET Core che usa EF Core comporta la registrazione di un tipo personalizzato DbContext nel contenitore di inserimento delle dipendenze tramite AddDbContext. Quindi, le istanze di quel tipo vengono ottenute tramite i parametri del costruttore nei controller o in Razor Pages.

Per abilitare il pool di contesti, sostituire AddDbContext semplicemente con AddDbContextPool:

builder.Services.AddDbContextPool<WeatherForecastContext>(
    o => o.UseSqlServer(builder.Configuration.GetConnectionString("WeatherForecastContext")));

Il poolSize parametro di AddDbContextPool imposta il numero massimo di istanze mantenute dal pool (il valore predefinito è 1024). Una volta poolSize superato il superamento, le nuove istanze di contesto non vengono memorizzate nella cache e EF esegue il fallback al comportamento non di pooling della creazione di istanze su richiesta.

Benchmark

Di seguito sono riportati i risultati del benchmark per il recupero di una singola riga da un database di SQL Server in esecuzione localmente nello stesso computer, con e senza pool di contesto. Come sempre, i risultati cambieranno con il numero di righe, la latenza per il server di database e altri fattori. In particolare, questo benchmark consente di ottenere prestazioni di pooling a thread singolo, mentre uno scenario predefinito reale può avere risultati diversi; benchmark sulla piattaforma prima di prendere decisioni. Il codice sorgente è disponibile qui, è possibile usarlo come base per le misurazioni personalizzate.

metodo NumBlogs Media Error StdDev Generazione 0 Gen1 Gen2 Allocato
WithoutContextPooling 1 701.6 26.62 noi 78.48 11.7188 - - 50,38 KB
WithContextPooling 1 350.1 6.80 noi 14.64 noi 0.9766 - - 4,63 KB

Gestione dello stato in contesti in pool

Il pool di contesti funziona riutilizzando la stessa istanza di contesto tra le richieste; Ciò significa che viene registrato in modo efficace come Singleton e la stessa istanza viene riutilizzata tra più richieste (o ambiti di inserimento delle dipendenze). Ciò significa che è necessario prestare particolare attenzione quando il contesto implica qualsiasi stato che può cambiare tra le richieste. In modo cruciale, il contesto viene richiamato una sola volta, quando il contesto dell'istanza OnConfiguring viene creato per la prima volta e quindi non può essere usato per impostare lo stato che deve variare (ad esempio, un ID tenant).

Uno scenario tipico che coinvolge lo stato del contesto è un'applicazione multi-tenant ASP.NET Core, in cui l'istanza di contesto ha un ID tenant che viene preso in considerazione dalle query (vedere Filtri di query globali per altri dettagli). Poiché l'ID tenant deve cambiare con ogni richiesta Web, è necessario eseguire alcuni passaggi aggiuntivi per far sì che tutto funzioni con il pool di contesto.

Si supponga che l'applicazione registri un servizio con ITenant ambito, che esegue il wrapping dell'ID tenant e di eventuali altre informazioni correlate al tenant:

// Below is a minimal tenant resolution strategy, which registers a scoped ITenant service in DI.
// In this sample, we simply accept the tenant ID as a request query, which means that a client can impersonate any
// tenant. In a real application, the tenant ID would be set based on secure authentication data.
builder.Services.AddHttpContextAccessor();
builder.Services.AddScoped<ITenant>(sp =>
{
    var tenantIdString = sp.GetRequiredService<IHttpContextAccessor>().HttpContext.Request.Query["TenantId"];

    return tenantIdString != StringValues.Empty && int.TryParse(tenantIdString, out var tenantId)
        ? new Tenant(tenantId)
        : null;
});

Come scritto in precedenza, prestare particolare attenzione alla posizione da cui si ottiene l'ID tenant. Si tratta di un aspetto importante della sicurezza dell'applicazione.

Dopo aver creato il servizio con ITenant ambito, registrare una factory del contesto di pooling come servizio Singleton, come di consueto:

builder.Services.AddPooledDbContextFactory<WeatherForecastContext>(
    o => o.UseSqlServer(builder.Configuration.GetConnectionString("WeatherForecastContext")));

Scrivere quindi una factory di contesto personalizzata che ottiene un contesto in pool dalla factory Singleton registrata e inserisce l'ID tenant nelle istanze di contesto che distribuisce:

public class WeatherForecastScopedFactory : IDbContextFactory<WeatherForecastContext>
{
    private const int DefaultTenantId = -1;

    private readonly IDbContextFactory<WeatherForecastContext> _pooledFactory;
    private readonly int _tenantId;

    public WeatherForecastScopedFactory(
        IDbContextFactory<WeatherForecastContext> pooledFactory,
        ITenant tenant)
    {
        _pooledFactory = pooledFactory;
        _tenantId = tenant?.TenantId ?? DefaultTenantId;
    }

    public WeatherForecastContext CreateDbContext()
    {
        var context = _pooledFactory.CreateDbContext();
        context.TenantId = _tenantId;
        return context;
    }
}

Dopo aver creato la factory del contesto personalizzata, registrarla come servizio con ambito:

builder.Services.AddScoped<WeatherForecastScopedFactory>();

Infine, disporre di un contesto per l'iniezione dalla factory con ambito:

builder.Services.AddScoped(
    sp => sp.GetRequiredService<WeatherForecastScopedFactory>().CreateDbContext());

A questo punto, i controller vengono inseriti automaticamente con un'istanza di contesto con l'ID tenant corretto, senza doverlo conoscere.

Il codice sorgente completo per questo esempio è disponibile qui.

Nota

Anche se EF Core si occupa della reimpostazione dello stato interno per DbContext e dei servizi correlati, in genere non reimposta lo stato nel driver di database sottostante, che si trova all'esterno di ENTITY. Ad esempio, se si apre manualmente e si usa uno DbConnection stato o si modifica ADO.NET stato, è necessario ripristinare tale stato prima di restituire l'istanza del contesto al pool, ad esempio chiudendo la connessione. In caso contrario, potrebbe verificarsi una perdita di stato tra richieste non correlate.

Query compilate

Quando EF riceve un albero di query LINQ per l'esecuzione, deve prima "compilare" tale albero, ad esempio produrre SQL da esso. Poiché questa attività è un processo pesante, Ef memorizza nella cache le query dalla forma dell'albero delle query, in modo che le query con la stessa struttura riutilizzino gli output di compilazione memorizzati internamente nella cache. Questa memorizzazione nella cache garantisce che l'esecuzione della stessa query LINQ più volte sia molto veloce, anche se i valori dei parametri sono diversi.

Tuttavia, Entity Framework deve comunque eseguire determinate attività prima di poter usare la cache di query interna. Ad esempio, l'albero delle espressioni della query deve essere confrontato in modo ricorsivo con gli alberi delle espressioni delle query memorizzate nella cache per trovare la query memorizzata nella cache corretta. Il sovraccarico per questa elaborazione iniziale è trascurabile nella maggior parte delle applicazioni EF, soprattutto rispetto ad altri costi associati all'esecuzione di query (I/O di rete, elaborazione effettiva delle query e I/O su disco nel database...). Tuttavia, in alcuni scenari ad alte prestazioni potrebbe essere preferibile eliminarlo.

EF supporta query compilate, che consentono la compilazione esplicita di una query LINQ in un delegato .NET. Una volta acquisito, questo delegato può essere richiamato direttamente per eseguire la query, senza fornire l'albero delle espressioni LINQ. Questa tecnica ignora la ricerca della cache e offre il modo più ottimizzato per eseguire una query in EF Core. Di seguito sono riportati alcuni risultati del benchmark che confrontano le prestazioni delle query compilate e non compilate; benchmark sulla piattaforma prima di prendere decisioni. Il codice sorgente è disponibile qui, è possibile usarlo come base per le misurazioni personalizzate.

metodo NumBlogs Media Error StdDev Generazione 0 Allocato
WithCompiledQuery 1 564.2 6.75 noi 5.99 noi 1.9531 9 KB
WithoutCompiledQuery 1 671.6 12.72 16.54 noi 2.9297 13 KB
WithCompiledQuery 10 645.3 10.00 9.35 2.9297 13 KB
WithoutCompiledQuery 10 709.8 25.20 73.10 3.9063 18 KB

Per usare le query compilate, compilare prima di tutto una query con EF.CompileAsyncQuery come indicato di seguito (usare EF.CompileQuery per le query sincrone):

private static readonly Func<BloggingContext, int, IAsyncEnumerable<Blog>> _compiledQuery
    = EF.CompileAsyncQuery(
        (BloggingContext context, int length) => context.Blogs.Where(b => b.Url.StartsWith("http://") && b.Url.Length == length));

In questo esempio di codice viene fornito ef con un'espressione lambda che accetta un'istanza DbContext e un parametro arbitrario da passare alla query. È ora possibile richiamare tale delegato ogni volta che si vuole eseguire la query:

await foreach (var blog in _compiledQuery(context, 8))
{
    // Do something with the results
}

Si noti che il delegato è thread-safe e può essere richiamato simultaneamente in istanze di contesto diverse.

Limiti

  • Le query compilate possono essere usate solo su un singolo modello di EF Core. A volte è possibile configurare istanze di contesto diverse dello stesso tipo per l'uso di modelli diversi; L'esecuzione di query compilate in questo scenario non è supportata.
  • Quando si usano parametri nelle query compilate, usare parametri scalari semplici. Le espressioni di parametro più complesse, ad esempio gli accessi a membro/metodo nelle istanze, non sono supportate.

Memorizzazione nella cache delle query e parametrizzazione

Quando EF riceve un albero di query LINQ per l'esecuzione, deve prima "compilare" tale albero, ad esempio produrre SQL da esso. Poiché questa attività è un processo pesante, Ef memorizza nella cache le query dalla forma dell'albero delle query, in modo che le query con la stessa struttura riutilizzino gli output di compilazione memorizzati internamente nella cache. Questa memorizzazione nella cache garantisce che l'esecuzione della stessa query LINQ più volte sia molto veloce, anche se i valori dei parametri sono diversi.

Si considerino le due query seguenti:

var post1 = context.Posts.FirstOrDefault(p => p.Title == "post1");
var post2 = context.Posts.FirstOrDefault(p => p.Title == "post2");

Poiché gli alberi delle espressioni contengono costanti diverse, l'albero delle espressioni è diverso e ognuna di queste query verrà compilata separatamente da EF Core. Inoltre, ogni query produce un comando SQL leggermente diverso:

SELECT TOP(1) [b].[Id], [b].[Name]
FROM [Posts] AS [b]
WHERE [b].[Name] = N'post1'

SELECT TOP(1) [b].[Id], [b].[Name]
FROM [Posts] AS [b]
WHERE [b].[Name] = N'post2'

Poiché SQL è diverso, è probabile che anche il server di database debba produrre un piano di query per entrambe le query, anziché riutilizzare lo stesso piano.

Una piccola modifica alle query può cambiare notevolmente le cose:

var postTitle = "post1";
var post1 = context.Posts.FirstOrDefault(p => p.Title == postTitle);
postTitle = "post2";
var post2 = context.Posts.FirstOrDefault(p => p.Title == postTitle);

Poiché il nome del blog è ora con parametri, entrambe le query hanno la stessa forma ad albero e EF deve essere compilata una sola volta. Il codice SQL generato viene parametrizzato, consentendo al database di riutilizzare lo stesso piano di query:

SELECT TOP(1) [b].[Id], [b].[Name]
FROM [Posts] AS [b]
WHERE [b].[Name] = @__postTitle_0

Si noti che non è necessario parametrizzare ogni query e ogni query: è perfettamente corretto disporre di alcune query con costanti e, in effetti, i database (e Entity Framework) possono talvolta eseguire determinate ottimizzazioni sulle costanti che non sono possibili quando la query è parametrizzata. Vedere la sezione sulle query create in modo dinamico per un esempio in cui la parametrizzazione appropriata è fondamentale.

Nota

Le metriche di EF Core segnalano la frequenza di riscontri nella cache delle query. In un'applicazione normale, questa metrica raggiunge il 100% poco dopo l'avvio del programma, una volta che la maggior parte delle query è stata eseguita almeno una volta. Se questa metrica rimane stabile al di sotto del 100%, si tratta di un'indicazione che l'applicazione potrebbe eseguire un'operazione che sconfigge la cache delle query. È consigliabile esaminarla.

Nota

Il modo in cui il database gestisce i piani di query della cache dipende dal database. Ad esempio, SQL Server gestisce in modo implicito una cache del piano di query LRU, mentre PostgreSQL non (ma le istruzioni preparate possono produrre un effetto finale molto simile). Per altre informazioni, vedere la documentazione del database.

Query create in modo dinamico

In alcune situazioni, è necessario costruire in modo dinamico query LINQ anziché specificarle correttamente nel codice sorgente. Ciò può verificarsi, ad esempio, in un sito Web che riceve dettagli di query arbitrari da un client, con operatori di query aperti (ordinamento, filtro, paging... ). In linea di principio, se eseguita correttamente, le query create in modo dinamico possono essere altrettanto efficienti di quelle normali (anche se non è possibile usare l'ottimizzazione delle query compilate con query dinamiche). In pratica, tuttavia, sono spesso la fonte di problemi di prestazioni, poiché è facile produrre accidentalmente alberi delle espressioni con forme che differiscono ogni volta.

Nell'esempio seguente vengono usate tre tecniche per costruire l'espressione lambda di Where una query:

  1. API di espressione con costante: compilare dinamicamente l'espressione con l'API Espressione, usando un nodo costante. Si tratta di un errore frequente durante la compilazione dinamica degli alberi delle espressioni e fa in modo che EF ricompila la query ogni volta che viene richiamata con un valore costante diverso (causa anche l'inquinamento della cache dei piani nel server di database).
  2. API di espressione con parametro: versione migliore, che sostituisce la costante con un parametro . In questo modo si garantisce che la query venga compilata solo una volta indipendentemente dal valore specificato e che venga generato lo stesso SQL con parametri.
  3. Semplice con il parametro: versione che non usa l'API Expression, per il confronto, che crea lo stesso albero del metodo precedente, ma è molto più semplice. In molti casi, è possibile compilare in modo dinamico l'albero delle espressioni senza ricorrere all'API di espressione, che è facile ottenere un errore.

Si aggiunge un Where operatore alla query solo se il parametro specificato non è Null. Si noti che questo non è un buon caso d'uso per la creazione dinamica di una query, ma viene usato per semplicità:

[Benchmark]
public int ExpressionApiWithConstant()
{
    var url = "blog" + Interlocked.Increment(ref _blogNumber);
    using var context = new BloggingContext();

    IQueryable<Blog> query = context.Blogs;

    if (_addWhereClause)
    {
        var blogParam = Expression.Parameter(typeof(Blog), "b");
        var whereLambda = Expression.Lambda<Func<Blog, bool>>(
            Expression.Equal(
                Expression.MakeMemberAccess(
                    blogParam,
                    typeof(Blog).GetMember(nameof(Blog.Url)).Single()),
                Expression.Constant(url)),
            blogParam);

        query = query.Where(whereLambda);
    }

    return query.Count();
}

Il benchmarking di queste due tecniche offre i risultati seguenti:

metodo Media Error StdDev Gen0 Prima generazione Allocato
ExpressionApiWithConstant 1.665.8 56.99 163.5 15.6250 - 109,92 KB
ExpressionApiWithParameter 757.1 35.14 103.6 12.6953 0.9766 54,95 KB
SimpleWithParameter 760.3 37.99 112.0 12.6953 - 55,03 KB

Anche se la differenza del sotto millisecondo sembra piccola, tenere presente che la versione costante inquina continuamente la cache e fa sì che altre query vengano ricompilata, rallentandole e avendo un impatto negativo generale sulle prestazioni complessive. È consigliabile evitare la ricompilazione costante delle query.

Nota

Evitare di costruire query con l'API dell'albero delle espressioni, a meno che non sia effettivamente necessario. Oltre alla complessità dell'API, è molto facile causare inavvertitamente problemi di prestazioni significativi quando vengono usati.

Modelli compilati

I modelli compilati possono migliorare il tempo di avvio di EF Core per le applicazioni con modelli di grandi dimensioni. Un modello di grandi dimensioni significa in genere centinaia a migliaia di tipi di entità e relazioni. L'ora di avvio è il tempo necessario per eseguire la prima operazione su un DbContext oggetto quando tale DbContext tipo viene usato per la prima volta nell'applicazione. Si noti che solo la creazione di un'istanza DbContext non comporta l'inizializzazione del modello di Entity Framework. Al contrario, le prime operazioni tipiche che causano l'inizializzazione del modello includono la chiamata DbContext.Add o l'esecuzione della prima query.

I modelli compilati vengono creati usando lo strumento da dotnet ef riga di comando. Assicurarsi di aver installato la versione più recente dello strumento prima di continuare.

Viene usato un nuovo dbcontext optimize comando per generare il modello compilato. Ad esempio:

dotnet ef dbcontext optimize

Le --output-dir opzioni e --namespace possono essere usate per specificare la directory e lo spazio dei nomi in cui verrà generato il modello compilato. Ad esempio:

PS C:\dotnet\efdocs\samples\core\Miscellaneous\CompiledModels> dotnet ef dbcontext optimize --output-dir MyCompiledModels --namespace MyCompiledModels
Build started...
Build succeeded.
Successfully generated a compiled model, to use it call 'options.UseModel(MyCompiledModels.BlogsContextModel.Instance)'. Run this command again when the model is modified.
PS C:\dotnet\efdocs\samples\core\Miscellaneous\CompiledModels>

L'output dell'esecuzione di questo comando include una parte di codice da copiare e incollare nella DbContext configurazione per fare in modo che EF Core usi il modello compilato. Ad esempio:

protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
    => optionsBuilder
        .UseModel(MyCompiledModels.BlogsContextModel.Instance)
        .UseSqlite(@"Data Source=test.db");

Bootstrap del modello compilato

In genere non è necessario esaminare il codice di bootstrap generato. Tuttavia, a volte può essere utile personalizzare il modello o il relativo caricamento. Il codice di bootstrapping è simile al seguente:

[DbContext(typeof(BlogsContext))]
partial class BlogsContextModel : RuntimeModel
{
    private static BlogsContextModel _instance;
    public static IModel Instance
    {
        get
        {
            if (_instance == null)
            {
                _instance = new BlogsContextModel();
                _instance.Initialize();
                _instance.Customize();
            }

            return _instance;
        }
    }

    partial void Initialize();

    partial void Customize();
}

Si tratta di una classe parziale con metodi parziali che possono essere implementati per personalizzare il modello in base alle esigenze.

Inoltre, è possibile generare più modelli compilati per DbContext i tipi che possono usare modelli diversi a seconda di una configurazione di runtime. Questi elementi devono essere inseriti in cartelle e spazi dei nomi diversi, come illustrato in precedenza. Le informazioni di runtime, ad esempio il stringa di connessione, possono quindi essere esaminate e il modello corretto restituito in base alle esigenze. Ad esempio:

public static class RuntimeModelCache
{
    private static readonly ConcurrentDictionary<string, IModel> _runtimeModels
        = new();

    public static IModel GetOrCreateModel(string connectionString)
        => _runtimeModels.GetOrAdd(
            connectionString, cs =>
            {
                if (cs.Contains("X"))
                {
                    return BlogsContextModel1.Instance;
                }

                if (cs.Contains("Y"))
                {
                    return BlogsContextModel2.Instance;
                }

                throw new InvalidOperationException("No appropriate compiled model found.");
            });
}

Limiti

I modelli compilati presentano alcune limitazioni:

A causa di queste limitazioni, è consigliabile usare modelli compilati solo se il tempo di avvio di EF Core è troppo lento. La compilazione di modelli di piccole dimensioni in genere non ne vale la pena.

Se il supporto di una di queste funzionalità è fondamentale per il successo, votare per i problemi appropriati collegati in precedenza.

Riduzione del sovraccarico di runtime

Come per qualsiasi livello, EF Core aggiunge un po' di sovraccarico di runtime rispetto alla codifica diretta rispetto alle API di database di livello inferiore. Questo sovraccarico di runtime non influisce in modo significativo sulla maggior parte delle applicazioni reali; gli altri argomenti di questa guida alle prestazioni, ad esempio efficienza delle query, utilizzo degli indici e riduzione al minimo dei round trip, sono molto più importanti. Inoltre, anche per applicazioni altamente ottimizzate, la latenza di rete e I/O del database in genere dominano qualsiasi tempo dedicato all'interno di EF Core stesso. Tuttavia, per applicazioni a bassa latenza e prestazioni elevate in cui ogni bit di prestazioni è importante, è possibile usare le raccomandazioni seguenti per ridurre il sovraccarico di EF Core al minimo:

  • Attivare il pooling DbContext. I benchmark mostrano che questa funzionalità può avere un impatto decisivo sulle applicazioni ad alta latenza e bassa latenza.
    • Assicurarsi che maxPoolSize corrisponda allo scenario di utilizzo. Se è troppo basso, DbContext le istanze verranno create ed eliminate costantemente, con prestazioni ridotte. L'impostazione di un valore troppo elevato potrebbe non richiedere memoria perché le istanze inutilizzate DbContext vengono mantenute nel pool.
    • Per un aumento di prestazioni aggiuntivo, è consigliabile usare PooledDbContextFactory invece di inserire direttamente istanze di contesto di inserimento delle dipendenze. La gestione delle dipendenze del DbContext pool comporta un leggero sovraccarico.
  • Usare query precompilate per le query ad accesso frequente.
    • Più complessa è la query LINQ, maggiore è il numero di operatori contenuti e più grande è l'albero delle espressioni risultante. È possibile ottenere maggiori vantaggi dall'uso di query compilate.
  • Valutare la possibilità di disabilitare i controlli di thread safety impostando su EnableThreadSafetyChecks false nella configurazione del contesto.
    • L'uso simultaneo della stessa DbContext istanza da thread diversi non è supportato. EF Core dispone di una funzionalità di sicurezza che rileva questo bug di programmazione in molti casi (ma non tutti) e genera immediatamente un'eccezione informativa. Tuttavia, questa funzionalità di sicurezza comporta un sovraccarico di runtime.
    • AVVISO: disabilitare solo i controlli di thread safety dopo un test accurato che l'applicazione non contiene tali bug di concorrenza.