Pokročilá témata týkající se výkonu

Sdružování DbContext

A DbContext je obecně lehký objekt: vytvoření a odstranění není součástí databázové operace a většina aplikací to může udělat bez znatelného dopadu na výkon. Každá instance kontextu ale nastavuje různé interní služby a objekty nezbytné pro plnění svých povinností a režijní náklady na průběžné provádění tohoto postupu můžou být významné ve scénářích s vysokým výkonem. V těchto případech může EF Core sdružovat vaše kontextové instance: když odstraníte kontext, EF Core resetuje jeho stav a uloží ho do interního fondu. Při další žádosti o novou instanci se tato instance ve fondu vrátí místo nastavení nové instance. Sdružování kontextu umožňuje platit náklady na nastavení kontextu pouze jednou při spuštění programu, a ne nepřetržitě.

Všimněte si, že sdružování kontextu je orthogonální pro sdružování připojení k databázi, které se spravuje na nižší úrovni v ovladači databáze.

Typický vzor v aplikaci ASP.NET Core pomocí EF Core zahrnuje registraci vlastního DbContext typu do kontejneru injektáže závislostí prostřednictvím AddDbContext. Potom se instance tohoto typu získávají prostřednictvím parametrů konstruktoru v kontroleru nebo Razor Pages.

Pokud chcete povolit sdružování kontextu, jednoduše nahraďte AddDbContext :AddDbContextPool

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

Parametr poolSizeAddDbContextPool nastaví maximální počet instancí uchovávaných fondem (výchozí hodnota je 1024). Po poolSize překročení se nové instance kontextu neukládají do mezipaměti a ef se vrátí k chování při vytváření instancí na vyžádání bez sdružování.

Srovnávací testy

Následují výsledky srovnávacího testu pro načtení jednoho řádku z databáze SQL Serveru spuštěné místně na stejném počítači s kontextovým fondem a bez toho. Stejně jako vždy se výsledky změní s počtem řádků, latencí databázového serveru a dalšími faktory. Důležité je, že tento testuje výkon sdružování s jedním vláknem, zatímco skutečný scénář může mít různé výsledky; na vaší platformě před provedením jakýchkoli rozhodnutí. Zdrojový kód je zde k dispozici, můžete ho použít jako základ pro vlastní měření.

metoda NumBlogs Střední hodnota Chyba Směrodatná odchylka Gen 0 Gen 1 Gen 2 Přiděleno
BezcontextPoolingu 0 701.6 nás 26.62 us 78,48 nás 11.7188 - - 50,38 kB
WithContextPooling 0 350.1 nás 6,80 nás 14.64 nás 0.9766 - - 4,63 kB

Správa stavu ve fondových kontextech

Sdružování kontextu funguje opětovným použitím stejné instance kontextu napříč požadavky; to znamená, že se efektivně zaregistruje jako Singleton a stejná instance se znovu použije napříč několika požadavky (nebo obory DI). To znamená, že je potřeba věnovat zvláštní pozornost v případě, že kontext zahrnuje jakýkoli stav, který se může mezi požadavky změnit. Zásadní je, že kontext OnConfiguring se vyvolá jen jednou – při prvním vytvoření kontextu instance – a proto se nedá použít k nastavení stavu, který se musí lišit (např. ID tenanta).

Typickým scénářem souvisejícím se stavem kontextu by byla aplikace s více tenanty ASP.NET Core, kde instance kontextu má ID tenanta, které se bere v úvahu pomocí dotazů (další podrobnosti najdete v tématu Globální filtry dotazů). Vzhledem k tomu, že id tenanta se musí s každou webovou požadavkem změnit, musíme projít některými dalšími kroky, aby všechno fungovalo s sdružováním kontextu.

Předpokládejme, že vaše aplikace zaregistruje vymezenou ITenant službu, která zabalí ID tenanta a všechny další informace související s tenanty:

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

Jak je uvedeno výše, věnujte zvláštní pozornost tomu, odkud získáte ID tenanta – to je důležitý aspekt zabezpečení vaší aplikace.

Jakmile máme službu s vymezeným ITenant oborem, zaregistrujte jako službu Singleton objekt pro sdružování kontextu jako obvykle:

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

Dále napište vlastní kontextovou továrnu, která získá fondový kontext z objektu pro vytváření Singleton, který jsme zaregistrovali, a vloží ID tenanta do kontextových instancí, které předává:

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

Jakmile máme vlastní kontextovou továrnu, zaregistrujte ji jako službu s vymezeným oborem:

builder.Services.AddScoped<WeatherForecastScopedFactory>();

Nakonec uspořádejte kontext, který se vloží z naší továrny s vymezeným oborem:

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

V tomto okamžiku se kontrolery automaticky vloží do kontextové instance, která má správné ID tenanta, aniž by o něm museli něco vědět.

Úplný zdrojový kód pro tuto ukázku je k dispozici zde.

Poznámka:

I když se EF Core stará o resetování interního stavu a DbContext souvisejících služeb, obecně se nenuluje stav v podkladovém ovladači databáze, který je mimo EF. Pokud například ručně otevřete a použijete nebo jinak manipulujete se stavem DbConnection ADO.NET, je na vás, abyste tento stav obnovili před vrácením instance kontextu do fondu, například zavřením připojení. Pokud to neuděláte, může to způsobit únik stavu mezi nesouvisejícími požadavky.

Kompilované dotazy

Když EF obdrží strom dotazu LINQ ke spuštění, musí nejprve "zkompilovat" tento strom, například vytvořit z něj SQL. Vzhledem k tomu, že je tento úkol náročným procesem, ef ukládá dotazy do mezipaměti podle obrazce stromu dotazu, takže dotazy se stejnou strukturou opakovaně používají výstupy kompilace v interní mezipaměti. Toto ukládání do mezipaměti zajišťuje, že provádění stejného dotazu LINQ několikrát je velmi rychlé, i když se hodnoty parametrů liší.

Ef však musí ještě před použitím interní mezipaměti dotazů provádět určité úlohy. Například strom výrazů dotazu musí být rekurzivně porovnán se stromy výrazů dotazů uložených v mezipaměti, aby bylo možné najít správný dotaz uložený v mezipaměti. Režie za toto počáteční zpracování je ve většině aplikací EF zanedbatelná, zejména v porovnání s jinými náklady souvisejícími se spouštěním dotazů (vstupně-výstupní operace sítě, skutečné zpracování dotazů a vstupně-výstupní operace disku v databázi...). V některých vysoce výkonných scénářích však může být žádoucí ho odstranit.

EF podporuje kompilované dotazy, které umožňují explicitní kompilaci dotazu LINQ do delegáta .NET. Po získání tohoto delegáta je možné ho vyvolat přímo ke spuštění dotazu bez poskytnutí stromu výrazů LINQ. Tato technika obchází vyhledávání v mezipaměti a poskytuje nejoptimaličtější způsob spuštění dotazu v EF Core. Následuje několik výsledků srovnávacích testů, které porovnávají kompilovaný a nekopilovaný výkon dotazů; na vaší platformě před provedením jakýchkoli rozhodnutí. Zdrojový kód je zde k dispozici, můžete ho použít jako základ pro vlastní měření.

metoda NumBlogs Střední hodnota Chyba Směrodatná odchylka Gen 0 Přiděleno
WithCompiledQuery 0 564.2 us 6,75 nás 5,99 nás 1.9531 9 kB
WithoutCompiledQuery 0 671.6 us 12.72 nás 16,54 nás 2.9297 13 kB
WithCompiledQuery 10 645.3 nás 10.00 nás 9,35 nás 2.9297 13 kB
WithoutCompiledQuery 10 709,8 nás 25.20 nás 73.10 nás 3.9063 18 kB

Pokud chcete použít kompilované dotazy, nejprve zkompilujte dotaz EF.CompileAsyncQuery následujícím způsobem (použijte EF.CompileQuery pro synchronní dotazy):

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

V této ukázce kódu poskytujeme EF s lambda, která DbContext přijímá instanci, a libovolný parametr, který se má předat dotazu. Delegáta teď můžete vyvolat při každém spuštění dotazu:

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

Všimněte si, že delegát je bezpečný pro přístup z více vláken a lze ho vyvolat souběžně v různých kontextových instancích.

Omezení

  • Kompilované dotazy je možné použít pouze pro jeden model EF Core. Někdy je možné nakonfigurovat různé kontextové instance stejného typu tak, aby používaly různé modely; spuštění kompilovaných dotazů v tomto scénáři není podporováno.
  • Při použití parametrů v kompilovaných dotazech použijte jednoduché skalární parametry. Složitější výrazy parametrů , jako jsou přístupy k členům nebo metodám v instancích, se nepodporují.

Ukládání dotazů do mezipaměti a parametrizace

Když EF obdrží strom dotazu LINQ ke spuštění, musí nejprve "zkompilovat" tento strom, například vytvořit z něj SQL. Vzhledem k tomu, že je tento úkol náročným procesem, ef ukládá dotazy do mezipaměti podle obrazce stromu dotazu, takže dotazy se stejnou strukturou opakovaně používají výstupy kompilace v interní mezipaměti. Toto ukládání do mezipaměti zajišťuje, že provádění stejného dotazu LINQ několikrát je velmi rychlé, i když se hodnoty parametrů liší.

Zvažte následující dva dotazy:

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

Vzhledem k tomu, že stromy výrazů obsahují různé konstanty, strom výrazů se liší a každý z těchto dotazů bude kompilován zvlášť ef Core. Každý dotaz navíc vytvoří trochu jiný příkaz SQL:

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

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

Vzhledem k tomu, že se SQL liší, bude váš databázový server pravděpodobně také muset vytvořit plán dotazu pro oba dotazy, a ne opakovaně používat stejný plán.

Malé změny dotazů můžou výrazně změnit:

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

Vzhledem k tomu, že název blogu je teď parametrizovaný, oba dotazy mají stejný tvar stromu a EF je potřeba zkompilovat pouze jednou. Vygenerovaný SQL je také parametrizován a umožňuje databázi opakovaně používat stejný plán dotazů:

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

Všimněte si, že není nutné parametrizovat každý a každý dotaz: je naprosto v pořádku mít některé dotazy s konstantami, a databáze (a EF) mohou někdy provádět určitou optimalizaci kolem konstant, které nejsou možné, když je dotaz parametrizován. V části o dynamicky vytvořených dotazech se podívejte na příklad, ve kterém je zásadní správné parametrizace.

Poznámka:

Čítače událostí EF Core hlásí rychlost dosažení mezipaměti dotazů. V normální aplikaci tento čítač brzy po spuštění programu dosáhne 100 %, jakmile se většina dotazů aspoň jednou spustí. Pokud tento čítač zůstává stabilní pod 100 %, znamená to, že vaše aplikace může dělat něco, co porazí mezipaměť dotazů – je vhodné to prozkoumat.

Poznámka:

Způsob správy plánů dotazů v mezipaměti je závislý na databázi. SQL Server například implicitně udržuje mezipaměť plánu dotazů LRU, zatímco PostgreSQL ne (ale připravené příkazy můžou způsobit velmi podobný koncový efekt). Další podrobnosti najdete v dokumentaci k databázi.

Dynamicky vytvořené dotazy

V některých situacích je nutné dynamicky vytvářet dotazy LINQ a nezadávat je přímo ve zdrojovém kódu. K tomu může dojít například na webu, který přijímá libovolné podrobnosti dotazu z klienta s operátory dotazů s otevřeným koncem (řazení, filtrování, stránkování...). V zásadě, pokud je to správně, dynamicky vytvořené dotazy mohou být stejně efektivní jako běžné dotazy (i když není možné použít zkompilovanou optimalizaci dotazů s dynamickými dotazy). V praxi jsou však často zdrojem problémů s výkonem, protože je snadné náhodně vytvářet stromy výrazů s obrazci, které se pokaždé liší.

Následující příklad používá tři techniky k vytvoření výrazu Where lambda dotazu:

  1. Rozhraní API výrazu s konstantou: Dynamicky sestavte výraz pomocí rozhraní API výrazu pomocí konstantního uzlu. Jedná se o častou chybu, když dynamicky vytváří stromy výrazů a způsobí, že EF dotaz pokaždé, když je vyvolán s jinou konstantní hodnotou (obvykle také způsobuje znečištění mezipaměti plánu na databázovém serveru).
  2. Rozhraní API výrazu s parametrem: Lepší verze, která nahradí konstantu parametrem. Tím se zajistí, že se dotaz zkompiluje pouze jednou bez ohledu na zadanou hodnotu a vygeneruje se stejný (parametrizovaný) SQL.
  3. Jednoduchý s parametrem: Verze, která nepoužívá rozhraní API výrazu, pro porovnání, která vytvoří stejný strom jako výše uvedená metoda, ale je mnohem jednodušší. V mnoha případech je možné dynamicky sestavovat strom výrazů bez použití rozhraní API pro výrazy, což je snadné se pokazit.

Where Operátor přidáme do dotazu pouze v případě, že daný parametr nemá hodnotu null. Všimněte si, že se nejedná o vhodný případ použití pro dynamické vytváření dotazu, ale pro jednoduchost ho používáme:

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

Srovnávací testy těchto dvou technik poskytují následující výsledky:

metoda Střední hodnota Chyba Směrodatná odchylka Gen0 Gen1 Přiděleno
ExpressionApiWithConstant 1 665,8 nás 56,99 nás 163.5 nás 15.6250 - 109,92 KB
ExpressionApiWithParameter 757.1 nás 35.14 nás 103.6 nás 12.6953 0.9766 54,95 KB
SimpleWithParameter 760.3 us 37,99 nás 112.0 us 12.6953 - 55.03 kB

I když je rozdíl v milisekundách malý, mějte na paměti, že konstantní verze nepřetržitě znečišťuje mezipaměť a způsobuje opětovné kompilaci jiných dotazů, zpomaluje je i a má obecný negativní dopad na celkový výkon. Důrazně doporučujeme vyhnout se opakovanému dokončování konstantních dotazů.

Poznámka:

Vyhněte se vytváření dotazů pomocí rozhraní API stromu výrazů, pokud opravdu nepotřebujete. Kromě složitosti rozhraní API je velmi snadné neúmyslně způsobit významné problémy s výkonem při jejich použití.

Kompilované modely

Kompilované modely můžou zlepšit dobu spouštění EF Core pro aplikace s velkými modely. Velký model obvykle znamená stovky až tisíce typů entit a relací. Čas spuštění je čas provést první operaci při DbContext prvním použití tohoto DbContext typu v aplikaci. Všimněte si, že pouhé vytvoření DbContext instance nezpůsobí inicializaci modelu EF. Místo toho typické první operace, které způsobují inicializaci modelu, zahrnují volání DbContext.Add nebo spuštění prvního dotazu.

Kompilované modely se vytvářejí pomocí nástroje příkazového dotnet ef řádku. Než budete pokračovat, ujistěte se, že jste nainstalovali nejnovější verzi nástroje .

K vygenerování kompilovaného modelu se používá nový dbcontext optimize příkaz. Příklad:

dotnet ef dbcontext optimize

Pomocí --output-dir možností --namespace lze určit adresář a obor názvů, do kterého se bude kompilovaný model generovat. Příklad:

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>

Výstup spuštění tohoto příkazu zahrnuje část kódu, která se má zkopírovat a vložit do DbContext konfigurace, aby EF Core používala zkompilovaný model. Příklad:

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

Bootstrapping kompilovaného modelu

Obvykle není nutné se podívat na vygenerovaný kód bootstrappingu. Někdy ale může být užitečné přizpůsobit model nebo jeho načítání. Kód bootstrappingu vypadá přibližně takto:

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

Jedná se o částečnou třídu s částečnými metodami, které je možné implementovat pro přizpůsobení modelu podle potřeby.

Kromě toho lze vygenerovat více kompilovaných modelů pro DbContext typy, které mohou v závislosti na určité konfiguraci modulu runtime používat různé modely. Ty by se měly umístit do různých složek a oborů názvů, jak je znázorněno výše. Informace o modulu runtime, jako je například připojovací řetězec, je pak možné prozkoumat a podle potřeby vrátit správný model. Příklad:

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

Omezení

Kompilované modely mají určitá omezení:

Z důvodu těchto omezení byste měli použít pouze kompilované modely, pokud je čas spuštění EF Core příliš pomalý. Kompilace malých modelů obvykle nestojí za to.

Pokud je podpora některé z těchto funkcí pro váš úspěch důležitá, hlasujte prosím o odpovídajících problémech, které jsou propojené výše.

Snížení režie za běhu

Stejně jako u jakékoli vrstvy přidává EF Core v porovnání s kódováním přímo proti rozhraním API databáze nižší úrovně režijní náklady za běhu. Tato režie za běhu pravděpodobně významně neovlivní většinu reálných aplikací; další témata v tomto průvodci výkonem, jako je efektivita dotazů, využití indexů a minimalizace zaokrouhlení, jsou mnohem důležitější. Kromě toho platí, že i u vysoce optimalizovaných aplikací bude latence sítě a vstupně-výstupní operace databáze obvykle dominovat kdykoliv stráveným uvnitř samotného EF Core. U vysoce výkonných aplikací s nízkou latencí, kde je důležitý každý bit výkonu, se ale dají použít následující doporučení ke snížení režie EF Core na minimum:

  • Zapněte sdružování DbContext. Naše srovnávací testy ukazují, že tato funkce může mít rozhodující dopad na aplikace s vysokou výkonem a nízkou latencí.
    • Ujistěte se, že maxPoolSize odpovídá vašemu scénáři použití. Pokud je příliš nízká, DbContext instance se budou neustále vytvářet a odstraňovat, což snižuje výkon. Nastavení příliš vysoké může zbytečně spotřebovávat paměť, protože se ve fondu spravují nepoužívané DbContext instance.
    • Pokud chcete zvýšit výkon navíc, zvažte použití namísto přímé PooledDbContextFactory vkládání instancí kontextu DI. Správa di fondů DbContext způsobuje mírné režijní náklady.
  • Používejte předkompilované dotazy pro horké dotazy.
    • Čím složitější je dotaz LINQ – čím více operátorů obsahuje, a čím větší je výsledný strom výrazů, tím větší je možné od použití kompilovaných dotazů očekávat další zisky.
  • Zvažte zakázání kontrol zabezpečení vláken nastavením EnableThreadSafetyChecks na false v konfiguraci kontextu.
    • Použití stejné DbContext instance souběžně z různých vláken se nepodporuje. EF Core má bezpečnostní funkci, která detekuje tuto chybu programování v mnoha případech (ale ne všechny) a okamžitě vyvolá informativní výjimku. Tato bezpečnostní funkce ale přidává určitou režii za běhu.
    • UPOZORNĚNÍ: Po důkladném testování, že vaše aplikace neobsahuje takové chyby souběžnosti, zakažte pouze bezpečnostní kontroly vláken.