Dela via


Avancerade prestandaämnen

DbContext-poolning

En DbContext är i allmänhet ett lätt objekt: att skapa och ta bort ett objekt innebär inte en databasåtgärd, och de flesta program kan göra det utan någon märkbar inverkan på prestanda. Varje kontextinstans konfigurerar dock olika interna tjänster och objekt som krävs för att utföra sina uppgifter, och omkostnaderna för att kontinuerligt göra det kan vara betydande i scenarier med höga prestanda. I dessa fall kan EF Core pool dina kontextinstanser: när du avyttrar kontexten återställer EF Core dess tillstånd och lagrar det i en intern pool. När en ny instans nästa gång begärs returneras den poolade instansen istället för att en ny skapas. Med kontextpooler kan du bara betala kostnader för kontextkonfiguration en gång vid programstart i stället för kontinuerligt.

Observera att kontextpooler är ortoggoniska för databasanslutningspooler, som hanteras på en lägre nivå i databasdrivrutinen.

Det typiska mönstret i en ASP.NET Core-app med EF Core är att registrera en anpassad DbContext-typ i beroendeinjektionscontainern via AddDbContext. Sedan hämtas instanser av den typen via konstruktorparametrar i styrenheter eller Razor Pages.

Om du vill aktivera kontextpooler ersätter du bara AddDbContext med AddDbContextPool:

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

Parametern poolSize för AddDbContextPool anger det maximala antalet instanser som bevaras av poolen (standardvärdet är 1024). När poolSize har överskridits cachelagras inte nya kontextinstanser och EF återgår till beteendet att skapa instanser på begäran.

Riktmärken

Här följer benchmark-resultaten för att hämta en enskild rad från en SQL Server-databas som körs lokalt på samma dator, med och utan kontextpooler. Som alltid ändras resultatet med antalet rader, svarstiden till databasservern och andra faktorer. Det är viktigt att detta fungerar som ett riktmärke för enkeltrådad poolprestanda, medan ett verkligt scenario med konkurrens kan ge olika resultat; testa prestanda på din plattform innan du fattar några beslut. Källkoden finns härkan du använda den som grund för dina egna mätningar.

Metod NumBlogs Betyda Fel StdDev Gen 0 Gen 1 Gen 2 Tilldelade
WithoutContextPooling 1 701.6 oss 26.62 oss 78.48 oss 11.7188 - - 50,38 KB
WithContextPooling 1 350.1 oss 6.80 oss 14.64 oss 0.9766 - - 4,63 KB

Hantera tillstånd i poolkontexter

Kontextpooler fungerar genom att återanvända samma kontextinstans mellan begäranden. Det innebär att den är effektivt registrerad som en Singleton-och att samma instans återanvänds i flera begäranden (eller DI-omfång). Det innebär att särskild försiktighet måste iakttas när kontexten omfattar alla tillstånd som kan ändras mellan begäranden. Det är viktigt att kontextens OnConfiguring bara anropas en gång – när instanskontexten först skapas – och kan därför inte användas för att ange tillstånd som måste variera (t.ex. ett klient-ID).

Ett typiskt scenario med kontexttillstånd skulle vara ett ASP.NET Core-program med flera klientorganisationer, där kontextinstansen har ett klient-ID som beaktas av frågor (se Globala frågefilter för mer information). Eftersom klientorganisations-ID:t måste ändras med varje webbegäran måste vi gå igenom några extra steg så att allt fungerar med kontextpoolning.

Anta att ditt program registrerar en scopad ITenant-tjänst som omsluter hyresgäst-ID och annan hyresgästrelaterad information.

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

Som du skrev ovan bör du vara särskilt uppmärksam på var du får klient-ID:t från – det här är en viktig aspekt av programmets säkerhet.

När vi har vår avgränsade ITenant-tjänst, registrerar vi en poolkontextfabrik som en Singleton-tjänst, som vanligt.

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

Skriv sedan en anpassad kontextfabrik som hämtar en poolkontext från den Singleton-fabrik som vi registrerade och matar in klientorganisations-ID:t i kontextinstanser som den delar ut:

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

När vi har vår anpassade kontextfabrik registrerar du den som en begränsad tjänst:

builder.Services.AddScoped<WeatherForecastScopedFactory>();

Se slutligen till att en kontext matas in från vår Omfångsfabrik:

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

I det här läget injiceras dina kontroller automatiskt med en kontextinstans som har rätt klient-ID, utan att de behöver veta något om det.

Den fullständiga källkoden för det här exemplet är tillgänglig här.

Not

Även om EF Core tar hand om återställning av internt tillstånd för DbContext och dess relaterade tjänster återställs vanligtvis inte tillståndet i den underliggande databasdrivrutinen, som ligger utanför EF. Om du till exempel öppnar och använder en DbConnection eller på annat sätt ändrar ADO.NET tillstånd manuellt är det upp till dig att återställa tillståndet innan du returnerar kontextinstansen till poolen, t.ex. genom att stänga anslutningen. Om du inte gör det, kan det leda till att tillstånd sprids över orelaterade förfrågningar.

Överväganden för anslutningspooler

Med de flesta databaser krävs en långvarig anslutning för att utföra databasåtgärder, och sådana anslutningar kan vara dyra att öppna och stänga. EF implementerar inte själva anslutningspoolen, men förlitar sig på den underliggande databasdrivrutinen (t.ex. ADO.NET drivrutin) för att hantera databasanslutningar. Anslutningspooler är en mekanism på klientsidan som återanvänder befintliga databasanslutningar för att minska kostnaderna för att öppna och stänga anslutningar upprepade gånger. Den här mekanismen är vanligtvis konsekvent för databaser som stöds av EF, till exempel Azure SQL Database, PostgreSQL och andra. Även om faktorer som är specifika för databasen eller miljön, till exempel resursbegränsningar eller tjänstkonfigurationer, kan påverka pooleffektiviteten. Anslutningspooler är vanligtvis aktiverade som standard, och alla poolkonfigurationer måste utföras på den lågnivådrivrutinsnivå som dokumenteras av drivrutinen. När du till exempel använder ADO.NET konfigureras parametrar som minsta eller högsta poolstorlekar vanligtvis via anslutningssträngen.

Anslutningspooler är helt ortogonala till EF:s DbContext poolning, vilket beskrivs ovan: medan lågnivådatabasdrivrutiner poolar databasanslutningar (för att undvika omkostnaderna för att öppna/stänga anslutningar), kan EF poola kontextinstanser (för att undvika kontextminnesallokering och initieringskostnader). Oavsett om en kontextinstans är poolad eller inte, öppnar EF vanligtvis anslutningar precis före varje åtgärd (t.ex. fråga) och stänger den direkt efteråt, vilket gör att den returneras till poolen. Detta görs för att undvika att hålla anslutningarna borta från poolen längre än vad som är nödvändigt.

Kompilerade frågor

När EF tar emot ett LINQ-frågeträd för körning måste det först "kompilera" trädet, t.ex. skapa SQL från det. Eftersom den här uppgiften är en tung process cachelagrar EF frågor efter frågeträdsformen, så att frågor med samma struktur återanvänder internt cachelagrade kompileringsutdata. Den här cachelagringen säkerställer att körningen av samma LINQ-fråga flera gånger är mycket snabb, även om parametervärdena skiljer sig åt.

EF måste dock fortfarande utföra vissa uppgifter innan den kan använda den interna frågecachen. Frågans uttrycksträd måste till exempel rekursivt jämföras med uttrycksträden för cachelagrade frågor för att hitta rätt cachelagrad fråga. Kostnaden för den här inledande bearbetningen är försumbar i de flesta EF-program, särskilt jämfört med andra kostnader som är kopplade till frågekörning (nätverks-I/O, faktisk frågebearbetning och disk-I/O i databasen...). I vissa scenarier med höga prestanda kan det dock vara önskvärt att eliminera det.

EF stöder kompilerade frågor, som tillåter explicit kompilering av en LINQ-fråga till ett .NET-ombud. När denna delegat har hämtats kan den anropas direkt för att köra frågan, utan att ange något LINQ-uttrycksträd. Den här tekniken kringgår cachesökningen och ger det mest optimerade sättet att köra en fråga i EF Core. Här följer några benchmark-resultat som jämför prestandan mellan kompilerade och icke-kompilerade frågor; benchmarka på din plattform innan du fattar några beslut. Källkoden finns härkan du använda den som grund för dina egna mätningar.

Metod NumBlogs Betyda Fel StdDev Gen 0 Tilldelat
WithCompiledQuery 1 564.2 oss 6.75 oss 5.99 oss 1.9531 9 KB
UtanKompileradFråga 1 671.6 oss 12.72 oss 16.54 oss 2.9297 13 KB
WithCompiledQuery 10 645.3 oss 10.00 oss 9.35 oss 2.9297 13 KB
UtanKompileradFråga 10 709.8 oss 25.20 oss 73.10 oss 3.9063 18 KB

Om du vill använda kompilerade frågor kompilerar du först en fråga med EF.CompileAsyncQuery enligt följande (använd EF.CompileQuery för synkrona frågor):

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

I det här kodexemplet ger vi EF en lambda som accepterar en DbContext-instans och en godtycklig parameter som ska skickas till frågan. Du kan nu anropa denna delegerade när du vill utföra frågan:

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

Observera att delegaten är trådsäker och kan anropas samtidigt på olika kontextexemplar.

Begränsningar

  • Kompilerade frågor kan endast användas mot en enda EF Core-modell. Olika kontextinstanser av samma typ kan ibland konfigureras för att använda olika modeller. det går inte att köra kompilerade frågor i det här scenariot.
  • När du använder parametrar i kompilerade frågor använder du enkla, skalära parametrar. Mer komplexa parameteruttryck – till exempel medlems-/metodåtkomster på instanser – stöds inte.

Cachelagring av frågor och parameterisering

När EF tar emot ett LINQ-frågeträd för körning måste det först "kompilera" trädet, t.ex. skapa SQL från det. Eftersom den här uppgiften är en tung process cachelagrar EF frågor efter frågeträdsformen, så att frågor med samma struktur återanvänder internt cachelagrade kompileringsutdata. Den här cachelagringen säkerställer att körningen av samma LINQ-fråga flera gånger är mycket snabb, även om parametervärdena skiljer sig åt.

Överväg följande två frågor:

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

Eftersom uttrycksträden innehåller olika konstanter skiljer sig uttrycksträdet åt och var och en av dessa frågor kompileras separat av EF Core. Dessutom skapar varje fråga ett något annorlunda SQL-kommando:

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'

Eftersom SQL skiljer sig åt måste databasservern förmodligen också skapa en frågeplan för båda frågorna i stället för att återanvända samma plan.

En liten ändring av dina frågor kan ändra saker avsevärt:

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

Eftersom bloggnamnet nu är parametriserathar båda frågorna samma trädform och EF behöver bara kompileras en gång. Den SQL som skapas parametriseras också, vilket gör att databasen kan återanvända samma frågeplan:

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

Observera att det inte finns något behov av att parametrisera varje fråga: det är helt okej att ha vissa frågor med konstanter, och databaser (och EF) kan ibland utföra viss optimering kring konstanter som inte är möjliga när frågan parametriseras. Se avsnittet om dynamiskt konstruerade frågor för ett exempel där rätt parameterisering är avgörande.

Not

EF Cores mått visar träffrekvensen för frågecachen. I ett normalt program når det här måttet 100% strax efter programstart, när de flesta frågor har körts minst en gång. Om det här måttet förblir stabilt under 100%är det en indikation på att din applikation kanske gör något som förhindrar frågecachens funktion – det är en bra idé att utreda det.

Anmärkning

Hur databasen hanterar cachelagrade frågeplaner är databasberoende. Sql Server underhåller till exempel implicit en LRU-frågeplanscache, medan PostgreSQL inte gör det (men förberedda instruktioner kan ge en mycket liknande sluteffekt). Mer information finns i databasdokumentationen.

Dynamiskt konstruerade frågor

I vissa situationer är det nödvändigt att dynamiskt konstruera LINQ-frågor i stället för att ange dem direkt i källkoden. Detta kan till exempel inträffa på en webbplats som tar emot godtycklig frågeinformation från en klient, med öppna frågeoperatorer (sortering, filtrering, växling...). Om det görs korrekt kan dynamiskt konstruerade frågor i princip vara lika effektiva som vanliga frågor (även om det inte går att använda den kompilerade frågeoptimeringen med dynamiska frågor). I praktiken är de dock ofta källan till prestandaproblem, eftersom det är lätt att oavsiktligt skapa uttrycksträd med former som skiljer sig åt varje gång.

I följande exempel används tre tekniker för att konstruera en frågas Where lambda-uttryck:

  1. -uttrycks-API med konstant: Skapa uttrycket dynamiskt med uttrycks-API:et med hjälp av en konstant nod. Det här är ett vanligt misstag när du dynamiskt skapar uttrycksträd och får EF att kompilera om frågan varje gång den anropas med ett annat konstant värde (det orsakar också vanligtvis plancacheföroreningar på databasservern).
  2. -uttrycks-API:et med parametern: En bättre version, som ersätter konstanten med en parameter. Detta säkerställer att frågan bara kompileras en gång oavsett vilket värde som anges och att samma (parametriserade) SQL genereras.
  3. Simple med parametern: En version som inte använder uttrycks-API:et som jämförelse, som skapar samma träd som metoden ovan, men som är mycket enklare. I många fall är det möjligt att dynamiskt skapa uttrycksträdet utan att använda uttrycks-API:et, vilket är lätt att få fel.

Vi lägger bara till en Where-operator i frågan om den angivna parametern inte är null. Observera att detta inte är ett bra användningsfall för att dynamiskt konstruera en fråga – men vi använder den för enkelhetens skull:

[Benchmark]
public async Task<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 await query.CountAsync();
}

Jämförelse av dessa två tekniker ger följande resultat:

Metod Betyda Fel StdDev Gen0 Gen1 Tilldelade
ExpressionApiWithConstant 1 665,8 oss 56.99 US 163.5 oss 15,6250 - 109,92 KB
ExpressionApiMedParameter 757.1 oss 35.14 us 103.6 oss 12,6953 0.9766 54,95 KB
SimpleWithParameter 760.3 oss 37,99 USD 112.0 oss 12.6953 - 55,03 KB

Även om skillnaden under millisekunder verkar liten bör du tänka på att den konstanta versionen kontinuerligt förorenar cachen och gör att andra frågor kompileras på nytt, vilket gör att de också saktas ned och har en allmän negativ inverkan på din övergripande prestanda. Vi rekommenderar starkt att du undviker konstant frågekompilering.

Not

Undvik att skapa frågor med uttrycksträds-API:et om du inte verkligen behöver det. Förutom API:ets komplexitet är det mycket enkelt att oavsiktligt orsaka betydande prestandaproblem när du använder dem.

Kompilerade modeller

Kompilerade modeller kan förbättra EF Core-starttiden för program med stora modeller. En stor modell innebär vanligtvis hundratals till tusentals entitetstyper och relationer. Starttiden här är tiden det tar att utföra den första åtgärden på en DbContext när den DbContext-typen används för första gången i applikationen. Observera att det inte leder till att EF-modellen initieras när du bara skapar en DbContext instans. Vanliga första åtgärder som gör att modellen initieras är i stället att anropa DbContext.Add eller köra den första frågan.

Kompilerade modeller skapas med hjälp av kommandoradsverktyget dotnet ef. Kontrollera att du har installerat den senaste versionen av verktyget innan du fortsätter.

Ett nytt dbcontext optimize-kommando används för att generera den kompilerade modellen. Till exempel:

dotnet ef dbcontext optimize

Alternativen --output-dir och --namespace kan användas för att ange den katalog och det namnområde som den kompilerade modellen ska genereras till. Till exempel:

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>

Utdata från körningen av det här kommandot innehåller en kod som du kan kopiera och klistra in i din DbContext-konfiguration för att få EF Core att använda den kompilerade modellen. Till exempel:

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

Initiering av kompilerad modell

Det är vanligtvis inte nödvändigt att titta på den genererade bootstrapping-koden. Det kan ibland vara användbart att anpassa modellen eller dess inläsningsprocess. Bootstrapping-koden ser ut ungefär så här:

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

Det här är en partiell klass med partiella metoder som kan implementeras för att anpassa modellen efter behov.

Dessutom kan flera kompilerade modeller genereras för DbContext typer som kan använda olika modeller beroende på viss körningskonfiguration. Dessa ska placeras i olika mappar och namnområden, som du ser ovan. Körningsinformation, till exempel anslutningssträngen, kan sedan undersökas och rätt modell returneras efter behov. Till exempel:

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

Begränsningar

Kompilerade modeller har vissa begränsningar:

På grund av dessa begränsningar bör du bara använda kompilerade modeller om ef core-starttiden är för långsam. Kompilering av små modeller är vanligtvis inte värt det.

Om det är viktigt att stödja någon av dessa funktioner för att lyckas kan du rösta på lämpliga frågor som är länkade ovan.

Minska körningskostnaderna

Precis som med alla lager lägger EF Core till lite körningskostnader jämfört med kodning direkt mot databas-API:er på lägre nivå. Det här körningskostnaderna kommer sannolikt inte att påverka de flesta verkliga program på ett betydande sätt. de andra avsnitten i den här prestandaguiden, till exempel frågeeffektivitet, indexanvändning och minimering av tur och retur, är mycket viktigare. Dessutom dominerar nätverksfördröjning och databas-I/O vanligtvis all tid som spenderas i SJÄLVA EF Core, även för högoptimerade program. För program med höga prestanda och låg latens där varje bit perf är viktigt kan dock följande rekommendationer användas för att minska EF Core-omkostnaderna till ett minimum:

  • Aktivera DbContext-poolning; våra benchmark-tester visar att den här funktionen kan ha en avgörande inverkan på program med hög prestanda och låg latens.
    • Kontrollera att maxPoolSize motsvarar ditt användningsscenario. Om värdet är för lågt kommer DbContext-instansningar ständigt att skapas och tas bort, vilket försämrar prestandan. Om du ställer in den för hög kan det i onödan förbruka minne eftersom oanvända DbContext instanser underhålls i poolen.
    • Om du vill ha en extra liten perf-ökning bör du överväga att använda PooledDbContextFactory i stället för att låta DI mata in kontextinstanser direkt. DI-hantering av DbContext-poolning medför en liten kostnad.
  • Använd förkompilerade frågor för heta frågor.
    • Ju mer komplex LINQ-frågan är – ju fler operatorer den innehåller och desto större blir det resulterande uttrycksträdet – desto större vinster kan man förvänta sig av att använda kompilerade frågor.
  • Överväg att inaktivera trådsäkerhetskontroller genom att ange EnableThreadSafetyChecks till false i kontextkonfigurationen.
    • Det går inte att använda samma DbContext instans samtidigt från olika trådar. EF Core har en säkerhetsfunktion som identifierar den här programmeringsbuggen i många fall (men inte alla) och omedelbart utlöser ett informativt undantag. Den här säkerhetsfunktionen medför dock en viss prestandaförlust vid körning.
    • VARNING: Inaktivera endast trådsäkerhetskontroller efter noggrann testning av att programmet inte innehåller sådana samtidighetsbuggar.