Partilhar via


Tópicos avançados de desempenho

Pool de DbContext

Um DbContext geralmente é um objeto leve: criar e descartar um não envolve uma operação de banco de dados, e a maioria dos aplicativos pode fazê-lo sem qualquer impacto percetível no desempenho. No entanto, cada instância de contexto configura vários serviços internos e objetos necessários para executar suas funções, e a sobrecarga de fazer isso continuamente pode ser significativa em cenários de alto desempenho. Para esses casos, o EF Core pode pool suas instâncias de contexto: quando você descarta seu contexto, o EF Core redefine seu estado e o armazena em um pool interno; Quando uma nova instância é solicitada em seguida, essa instância em pool é retornada em vez de configurar uma nova. O agrupamento de contexto permite pagar os custos de configuração de contexto apenas uma vez na inicialização do programa, em vez de fazê-lo continuamente.

Observe que o pool de contexto é ortogonal ao pool de conexões de banco de dados, que é gerenciado em um nível inferior no driver de banco de dados.

O padrão típico numa aplicação ASP.NET Core usando o EF Core envolve o registo de um tipo de DbContext personalizado no contentor de injeção de dependência por meio de AddDbContext. Em seguida, instâncias desse tipo são obtidas através de parâmetros de construtor em controladores ou Razor Pages.

Para habilitar a agregação de contexto, basta substituir AddDbContext por AddDbContextPool:

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

O parâmetro poolSize de AddDbContextPool define o número máximo de instâncias retidas pelo pool (o padrão é 1024). Depois de excedido o poolSize, novas instâncias de contexto não são armazenadas em cache e o EF retoma o comportamento de não agrupamento, criando instâncias conforme necessário.

Parâmetros de referência

A seguir estão os resultados de benchmark para buscar uma única linha de um banco de dados SQL Server executado localmente na mesma máquina, com e sem pool de contexto. Como sempre, os resultados serão alterados com o número de linhas, a latência do servidor de banco de dados e outros fatores. É importante ressaltar que isso compara o desempenho de pool de thread único, enquanto um cenário disputado no mundo real pode ter resultados diferentes; Faça benchmark na sua plataforma antes de tomar qualquer decisão. O código-fonte está disponível aqui, sinta-se à vontade para usá-lo como base para suas próprias medições.

Método NumBlogs Média Erro StdDev Geração 0 Geração 1 Geração 2 Atribuído
SemContextPooling 1 701,6 EUA 26,62 EUA 78,48 EUA 11.7188 - - 50,38 KB
WithContextPooling 1 350,1 EUA 6,80 EUA 14.64 EUA 0.9766 - - 4,63 KB

Gerenciando o estado em contextos agrupados

O pool de contexto funciona reutilizando a mesma instância de contexto entre solicitações; isso significa que ele é efetivamente registrado como um Singletone a mesma instância é reutilizada em várias solicitações (ou escopos DI). Isso significa que um cuidado especial deve ser tomado quando o contexto envolve qualquer estado que possa mudar entre solicitações. Crucialmente, o OnConfiguring do contexto é invocado apenas uma vez - quando o contexto da instância é criado pela primeira vez - e, portanto, não pode ser usado para definir o estado que precisa variar (por exemplo, um ID de locatário).

Um cenário típico envolvendo o estado do contexto seria um aplicativo ASP.NET Core multilocatário, onde a instância de contexto tem um ID de locatário que é levado em conta pelas consultas (consulte Filtros de Consulta Globais para obter mais detalhes). Como o ID do inquilino precisa ser alterado a cada pedido web, precisamos realizar alguns passos adicionais para que tudo funcione com o pooling de contexto.

Vamos supor que seu aplicativo registre um serviço de ITenant com escopo, que encapsula a ID do locatário e quaisquer outras informações relacionadas ao locatário:

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

Como escrito acima, preste especial atenção de onde você obtém o ID do locatário - este é um aspeto importante da segurança do seu aplicativo.

Assim que tivermos o serviço ITenant com escopo, registe uma fábrica de contexto de pool como um serviço Singleton, como de costume:

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

Em seguida, escreva uma fábrica de contexto personalizada que obtenha um contexto agrupado da fábrica Singleton que registramos e injete o ID do locatário nas instâncias de contexto que ele distribui:

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

Assim que tivermos nossa fábrica de contexto personalizada, registre-a como um serviço com escopo:

builder.Services.AddScoped<WeatherForecastScopedFactory>();

Finalmente, prepare um contexto para ser injetado a partir da nossa fábrica de escopo delimitado.

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

Neste ponto, os seus controladores são automaticamente injetados com uma instância de contexto que tem o ID de inquilino correto, sem precisar saber mais nada sobre isso.

O código-fonte completo para este exemplo está disponível aqui.

Observação

Embora o EF Core cuide da redefinição do estado interno do DbContext e seus serviços relacionados, ele geralmente não redefine o estado no driver de banco de dados subjacente, que está fora do EF. Por exemplo, se abrires e usares manualmente um DbConnection ou manipulares o estado do ADO.NET, é tua responsabilidade restaurar esse estado antes de devolveres a instância de contexto ao pool, por exemplo, fechando a conexão. A falha em fazer isso pode fazer com que o estado seja vazado em solicitações não relacionadas.

Considerações sobre o pool de conexões

Com a maioria dos bancos de dados, uma conexão de longa duração é necessária para executar operações de banco de dados, e essas conexões podem ser caras para abrir e fechar. O EF não implementa o pool de conexões em si, mas depende do driver de banco de dados subjacente (por exemplo, driver ADO.NET) para gerenciar conexões de banco de dados. O pool de conexões é um mecanismo do lado do cliente que reutiliza conexões de banco de dados existentes para reduzir a sobrecarga de abrir e fechar conexões repetidamente. Esse mecanismo geralmente é consistente entre bancos de dados suportados pelo EF, como o Banco de Dados SQL do Azure, PostgreSQL e outros., embora fatores específicos do banco de dados ou ambiente, como limites de recursos ou configurações de serviço, possam afetar a eficiência do pooling. A agregação de conexões é geralmente ativada por defeito, e qualquer configuração de agregação deve ser realizada no nível inferior do driver, conforme documentado por esse driver; por exemplo, ao usar ADO.NET, parâmetros como tamanhos mínimos ou máximos de agregação são geralmente configurados por meio da string de conexão.

O pool de conexões é completamente ortogonal ao pool de DbContext do EF, que é descrito acima: enquanto o driver de banco de dados de baixo nível agrupa conexões de banco de dados (para evitar a sobrecarga de abrir/fechar conexões), o EF pode agrupar instâncias de contexto (para evitar sobrecarga de alocação de memória de contexto e despesas gerais de inicialização). Independentemente de uma instância de contexto estar agrupada ou não, o EF geralmente abre conexões imediatamente antes de cada operação (por exemplo, consulta) e a fecha logo em seguida, fazendo com que ela seja retornada ao pool; Isso é feito para evitar manter as conexões fora da piscina por mais tempo do que o necessário.

Consultas compiladas

Quando o EF recebe uma árvore de consulta LINQ para execução, ele deve primeiro "compilar" essa árvore, por exemplo, produzir SQL a partir dela. Como essa tarefa é um processo pesado, o EF armazena em cache consultas pela forma da árvore de consulta, de modo que consultas com a mesma estrutura reutilizem saídas de compilação armazenadas em cache interno. Esse cache garante que a execução da mesma consulta LINQ várias vezes seja muito rápida, mesmo que os valores dos parâmetros sejam diferentes.

No entanto, o EF ainda deve executar determinadas tarefas antes de poder usar o cache de consulta interno. Por exemplo, a árvore de expressões da consulta deve ser comparada recursivamente com as árvores de expressão de consultas armazenadas em cache, para encontrar a consulta em cache correta. A sobrecarga para esse processamento inicial é insignificante na maioria dos aplicativos EF, especialmente quando comparada a outros custos associados à execução de consultas (E/S de rede, processamento de consulta real e E/S de disco no banco de dados...). No entanto, em certos cenários de alto desempenho, pode ser desejável eliminá-lo.

O EF suporta consultas compiladas, que permitem a compilação explícita de uma consulta LINQ em um delegado .NET. Depois que esse delegado é adquirido, ele pode ser invocado diretamente para executar a consulta, sem fornecer a árvore de expressões LINQ. Essa técnica ignora a pesquisa de cache e fornece a maneira mais otimizada de executar uma consulta no EF Core. A seguir estão alguns resultados de benchmark comparando o desempenho de consultas compiladas e não compiladas; Faça benchmark na sua plataforma antes de tomar qualquer decisão. O código-fonte está disponível aqui, sinta-se à vontade para usá-lo como base para suas próprias medições.

Método NumBlogs Média Erro StdDev Geração 0 Atribuído
WithCompiledQuery 1 564,2 EUA 6.75 EUA 5,99 EUA 1.9531 9 KB
WithoutCompiledQuery 1 671,6 EUA 12,72 EUA 16.54 EUA 2.9297 13 KB
WithCompiledQuery 10 645,3 EUA 10.00 Nós 9.35 Nós 2.9297 13 KB
WithoutCompiledQuery 10 709,8 EUA 25.20 USD 73,10 USD 3,9063 18 KB

Para usar consultas compiladas, primeiro compile uma consulta com EF.CompileAsyncQuery da seguinte forma (use EF.CompileQuery para consultas síncronas):

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

Neste exemplo de código, fornecemos ao EF um lambda aceitando uma instância DbContext e um parâmetro arbitrário a ser passado para a consulta. Agora você pode invocar esse delegado sempre que desejar executar a consulta:

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

Observe que o delegado é thread-safe e pode ser invocado simultaneamente em diferentes instâncias de contexto.

Limitações

  • As consultas compiladas só podem ser usadas em um único modelo EF Core. Às vezes, instâncias de contexto diferentes do mesmo tipo podem ser configuradas para usar modelos diferentes; Não há suporte para a execução de consultas compiladas neste cenário.
  • Ao usar parâmetros em consultas compiladas, use parâmetros simples e escalares. Não há suporte para expressões de parâmetros mais complexas - como acessos de membro/método em instâncias.

Cache de consultas e parametrização

Quando o EF recebe uma árvore de consulta LINQ para execução, ele deve primeiro "compilar" essa árvore, por exemplo, produzir SQL a partir dela. Como essa tarefa é um processo pesado, o EF armazena em cache consultas pela forma da árvore de consulta, de modo que consultas com a mesma estrutura reutilizem saídas de compilação armazenadas em cache interno. Esse cache garante que a execução da mesma consulta LINQ várias vezes seja muito rápida, mesmo que os valores dos parâmetros sejam diferentes.

Considere as duas consultas a seguir:

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

Como as árvores de expressão contêm constantes diferentes, a árvore de expressão difere e cada uma dessas consultas será compilada separadamente pelo EF Core. Além disso, cada consulta produz um comando SQL ligeiramente diferente:

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'

Como o SQL é diferente, o servidor de banco de dados provavelmente também precisará produzir um plano de consulta para ambas as consultas, em vez de reutilizar o mesmo plano.

Uma pequena modificação nas suas consultas pode alterar consideravelmente as coisas:

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

Como o nome do blog agora está parametrizado, ambas as consultas têm a mesma forma de árvore, e o EF só precisa ser compilado uma vez. O SQL produzido também é parametrizado, permitindo que o banco de dados reutilize o mesmo plano de consulta:

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

Observe que não há necessidade de parametrizar cada consulta: é perfeitamente bom ter algumas consultas com constantes e, de fato, os bancos de dados (e EF) às vezes podem executar certas otimizações em torno de constantes que não são possíveis quando a consulta é parametrizada. Veja a seção sobre consultas construídas dinamicamente para um exemplo onde a parametrização adequada é crucial.

Observação

As métricas do EF Core indicam a Taxa de Acertos de Cache de Consultas. Em uma aplicação normal, essa métrica atinge 100% logo após a inicialização do programa, uma vez que a maioria das consultas tenha sido executada pelo menos uma vez. Se essa métrica permanecer estável abaixo de 100%, isso é uma indicação de que seu aplicativo pode estar fazendo algo que derrota o cache de consulta - é uma boa ideia investigar isso.

Observação

A forma como o banco de dados gerencia os planos de consulta de caches depende do banco de dados. Por exemplo, o SQL Server mantém implicitamente um cache de plano de consulta LRU, enquanto o PostgreSQL não (mas instruções preparadas podem produzir um efeito final muito semelhante). Consulte a documentação do seu banco de dados para obter mais detalhes.

Consultas construídas dinamicamente

Em algumas situações, é necessário construir dinamicamente consultas LINQ em vez de especificá-las diretamente no código-fonte. Isso pode acontecer, por exemplo, em um site que recebe detalhes de consulta arbitrários de um cliente, com operadores de consulta abertos (classificação, filtragem, paginação...). Em princípio, se feitas corretamente, as consultas construídas dinamicamente podem ser tão eficientes quanto as regulares (embora não seja possível usar a otimização de consulta compilada com consultas dinâmicas). Na prática, no entanto, eles são frequentemente a fonte de problemas de desempenho, uma vez que é fácil produzir acidentalmente árvores de expressão com formas que diferem a cada vez.

O exemplo a seguir usa três técnicas para construir a expressão lambda Where de uma consulta:

  1. API de Expressão com constante: Construa dinamicamente a expressão com a API de Expressão, utilizando um nó constante. Esse é um erro frequente ao criar árvores de expressão dinamicamente e faz com que o EF recompile a consulta cada vez que ela é invocada com um valor constante diferente (também geralmente causa poluição do cache de plano no servidor de banco de dados).
  2. Expression API com parâmetro: Uma versão melhor, que substitui a constante por um parâmetro. Isso garante que a consulta seja compilada apenas uma vez, independentemente do valor fornecido, e que o mesmo SQL (parametrizado) seja gerado.
  3. Simples com parâmetro: Uma versão que não usa a API de expressão, para comparação, que cria a mesma árvore que o método acima, mas é muito mais simples. Em muitos casos, é possível construir dinamicamente sua árvore de expressões sem recorrer à API de expressão, que é fácil de errar.

Adicionamos um operador Where à consulta somente se o parâmetro fornecido não for nulo. Observe que este não é um bom caso de uso para construir dinamicamente uma consulta - mas estamos usando-o para simplificar:

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

A avaliação comparativa destas duas técnicas produz os seguintes resultados:

Método Média Erro StdDev Gen0 Gen1 Atribuído
ExpressãoAPIComConstante 1.665,8 us 56,99 EUA 163,5 EUA 15.6250 - 109,92 KB
ExpressionApiWithParameter 757,1 EUA 35,14 EUA 103,6 nós 12,6953 0.9766 54,95 KB
SimpleWithParameter 760,3 EUA 37,99 EUA 112,0 EUA 12,6953 - 55,03 KB

Mesmo que a diferença de menos de milissegundos pareça pequena, lembre-se de que a versão constante polui continuamente o cache e faz com que outras consultas sejam recompiladas, retardando-as também e tendo um impacto negativo geral no seu desempenho geral. É altamente recomendável evitar a recompilação constante de consultas.

Observação

Evite construir consultas com a API da árvore de expressão, a menos que você realmente precise. Além da complexidade da API, é muito fácil causar inadvertidamente problemas significativos de desempenho ao usá-los.

Modelos compilados

Os modelos compilados podem melhorar o tempo de inicialização do EF Core para aplicativos com modelos grandes. Um modelo grande normalmente significa centenas a milhares de tipos de entidades e relacionamentos. O tempo de inicialização aqui é o tempo para executar a primeira operação em um DbContext quando esse tipo de DbContext é usado pela primeira vez no aplicativo. Observe que apenas a criação de uma instância DbContext não faz com que o modelo EF seja inicializado. Em vez disso, as primeiras operações típicas que fazem com que o modelo seja inicializado incluem chamar DbContext.Add ou executar a primeira consulta.

Os modelos compilados são criados usando a ferramenta de linha de comando dotnet ef. Certifique-se de que instalou a versão mais recente da ferramenta antes de continuar.

Um novo comando dbcontext optimize é usado para gerar o modelo compilado. Por exemplo:

dotnet ef dbcontext optimize

As opções --output-dir e --namespace podem ser usadas para especificar o diretório e o namespace nos quais o modelo compilado será gerado. Por exemplo:

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>

A saída da execução deste comando inclui um pedaço de código para copiar e colar em sua configuração de DbContext para fazer com que o EF Core use o modelo compilado. Por exemplo:

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

Inicialização de modelo compilado

Normalmente, não é necessário examinar o código de inicialização gerado. No entanto, às vezes pode ser útil personalizar o modelo ou seu carregamento. O código de inicialização é mais ou menos assim:

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

Esta é uma classe parcial com métodos parciais que podem ser implementados para personalizar o modelo conforme necessário.

Além disso, podem ser gerados vários modelos compilados para tipos DbContext que podem usar modelos diferentes, dependendo de certa configuração de tempo de execução. Eles devem ser colocados em diferentes pastas e namespaces, como mostrado acima. As informações de tempo de execução, como a cadeia de conexão, podem ser examinadas e o modelo correto retornado conforme necessário. Por exemplo:

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

Limitações

Os modelos compilados têm algumas limitações:

Devido a essas limitações, você só deve usar modelos compilados se o tempo de inicialização do EF Core for muito lento. Compilar modelos pequenos normalmente não vale a pena.

Se o suporte a qualquer um desses recursos for fundamental para o seu sucesso, vote nas questões apropriadas vinculadas acima.

Reduzindo a sobrecarga de tempo de execução

Como em qualquer camada, o EF Core adiciona um pouco de sobrecarga de tempo de execução em comparação com a codificação diretamente em APIs de banco de dados de nível inferior. É improvável que essa sobrecarga de tempo de execução afete a maioria dos aplicativos do mundo real de forma significativa; Os outros tópicos deste guia de desempenho, como eficiência de consulta, uso de índice e minimização de viagens de ida e volta, são muito mais importantes. Além disso, mesmo para aplicativos altamente otimizados, a latência da rede e a E/S do banco de dados geralmente dominam qualquer tempo gasto dentro do próprio EF Core. No entanto, para aplicativos de alto desempenho e baixa latência em que cada bit de perf é importante, as seguintes recomendações podem ser usadas para reduzir ao mínimo a sobrecarga do EF Core:

  • Ative pool DbContext; Nossos benchmarks mostram que esse recurso pode ter um impacto decisivo em aplicativos de alta qualidade e baixa latência.
    • Certifique-se de que o maxPoolSize corresponde ao seu cenário de utilização; caso este número seja demasiado baixo, instâncias de DbContext serão constantemente criadas e descartadas, degradando o desempenho. Definir o valor muito alto pode consumir memória desnecessariamente, pois instâncias de DbContext não utilizadas são mantidas no pool.
    • Para um aumento extra de desempenho, considere usar PooledDbContextFactory em vez de ter as instâncias de contexto injetadas pelo DI diretamente. A gestão de DI do agrupamento de DbContext incorre numa ligeira sobrecarga.
  • Use consultas pré-compiladas para consultas frequentes.
    • Quanto mais complexa for a consulta LINQ - quanto mais operadores ela contiver e maior for a árvore de expressão resultante - mais ganhos podem ser esperados com o uso de consultas compiladas.
  • Considere desativar as verificações de segurança de thread definindo EnableThreadSafetyChecks como false em sua configuração de contexto.
    • Não há suporte para o uso simultâneo da mesma instância DbContext de threads diferentes. O EF Core tem um recurso de segurança que deteta esse bug de programação em muitos casos (mas não em todos) e imediatamente lança uma exceção informativa. No entanto, esse recurso de segurança adiciona alguma sobrecarga de tempo de execução.
    • AVISO: Desative apenas as verificações de segurança de thread depois de testar minuciosamente se seu aplicativo não contém tais bugs de simultaneidade.