Tópicos de desempenho avançado

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 perceptí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 agrupar 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, essa instância em pool é retornada em vez de configurar uma nova. O pool de contexto permite que você pague custos de configuração de contexto apenas uma vez na inicialização do programa, em vez de continuamente.

Observe que o pool de contexto é ortogonal para o 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 em um aplicativo ASP.NET Core usando o EF Core envolve o registro de um tipo de DbContext personalizado no contêiner de injeção de dependência por meio de AddDbContext. Em seguida, instâncias desse tipo são obtidas por meio de parâmetros de construtor em controladores ou Razor Pages.

Para habilitar o pool 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 que poolSize for excedido, novas instâncias de contexto não serão armazenadas em cache e o EF retornará ao comportamento de não pooling da criação de instâncias sob demanda.

Parâmetros de comparação

A seguir estão os resultados de parâmetro de comparação para buscar uma única linha de um banco de dados do SQL Server em execução localmente no mesmo computador, com e sem pool de contexto. Como sempre, os resultados serão alterados com o número de linhas, a latência para o servidor de banco de dados e outros fatores. É importante ressaltar que isso avalia o desempenho do pooling de thread único, ao passo que um cenário de disputa no mundo real pode ter resultados diferentes; faça uma avaliação comparativa em sua plataforma antes de tomar qualquer decisão. O código-fonte está disponível aqui, fique à vontade para usá-lo como base para suas próprias medidas.

Método NumBlogs Média Erro StdDev Geração 0 Gen 1 Gen 2 Alocado
WithoutContextPooling 1 701.6 us 26.62 us 78.48 us 11.7188 - - 50,38 KB
WithContextPooling 1 350.1 us 6.80 us 14.64 us 0.9766 - - 4,63 KB

Gerenciando o estado em contextos em pool

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 é reutilizado 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 contexto OnConfiguring é 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, uma ID de locatário).

Um cenário típico que envolve o estado de contexto seria um aplicativo ASP.NET Core multilocatário, em que a instância de contexto tem uma ID de locatário que é levada em conta por consultas (consulte filtros de consulta globais para obter mais detalhes). Como a ID do locatário precisa ser alterada com cada solicitação da Web, precisamos passar por algumas etapas extras para que tudo funcione com o pool 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;
});

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

Depois que tivermos nosso serviço com ITenant escopo, registre 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 obtém um contexto em pool da fábrica singleton que registramos e injeta a ID do locatário em instâncias de contexto que ela 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;
    }
}

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

builder.Services.AddScoped<WeatherForecastScopedFactory>();

Por fim, organize um contexto para ser injetado em nossa fábrica com escopo:

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

Como este ponto, seus controladores são injetados automaticamente com uma instância de contexto que tem a ID de locatário certa, sem precisar saber nada sobre ela.

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

Observação

Embora o EF Core cuide da redefinição do estado interno para 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 você abrir manualmente e usar um DbConnection ou manipular ADO.NET estado, cabe a você restaurar esse estado antes de retornar a instância de contexto para o pool, por exemplo, fechando a conexão. A falha ao fazer isso pode fazer com que o estado seja vazado entre solicitações não relacionadas.

Consultas compiladas

Quando o EF recebe uma árvore de consulta LINQ para execução, ele deve primeiro "compilar" essa árvore, por exemplo, produzir SQL 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 internamente. Esse cache garante que a execução da mesma consulta LINQ várias vezes seja muito rápida, mesmo que os valores de parâmetro 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ão da consulta deve ser recursivamente comparada com as árvores de expressão de consultas armazenadas em cache para localizar a consulta armazenada em cache correta. A sobrecarga para esse processamento inicial é insignificante na maioria dos aplicativos EF, especialmente quando comparada a outros custos associados à execução da consulta (E/S de rede, processamento de consulta real e E/S de disco no banco de dados...). No entanto, em determinados cenários de alto desempenho, pode ser desejável eliminá-lo.

O EF dá suporte a consultas compiladas, que permitem a compilação explícita de uma consulta LINQ em um delegado do .NET. Depois que esse delegado é adquirido, ele pode ser invocado diretamente para executar a consulta, sem fornecer a árvore de expressão 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 parâmetro de comparação comparando o desempenho de consulta compilado e não compilado; benchmark em sua plataforma antes de tomar qualquer decisão. O código-fonte está disponível aqui, fique à vontade para usá-lo como base para suas próprias medidas.

Método NumBlogs Média Erro StdDev Geração 0 Alocado
WithCompiledQuery 1 564.2 us 6.75 us 5.99 us 1.9531 9 KB
WithoutCompiledQuery 1 671.6 us 12.72 us 16.54 us 2.9297 13 KB
WithCompiledQuery 10 645.3 us 10.00 us 9.35 us 2.9297 13 KB
WithoutCompiledQuery 10 709.8 us 25.20 us 73.10 us 3.9063 18 KB

Para usar consultas compiladas, primeiro compile uma consulta com EF.CompileAsyncQuery da seguinte maneira (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 de DbContext e um parâmetro arbitrário a ser passado para a consulta. Agora você pode invocar esse delegado sempre que quiser 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 instâncias de contexto diferentes.

Limitações

  • As consultas compiladas só podem ser usadas em um único modelo do EF Core. Às vezes, diferentes instâncias de contexto 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 escalares simples. Não há suporte para expressões de parâmetro mais complexas , como acessos de membro/método em instâncias.

Cache de consulta 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 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 internamente. Esse cache garante que a execução da mesma consulta LINQ várias vezes seja muito rápida, mesmo que os valores de parâmetro sejam diferentes.

Considere as duas consultas a seguir:

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

Como as árvores de expressão contêm constantes diferentes, a árvore de expressão é diferente 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 [Blogs] AS [b]
WHERE [b].[Name] = N'blog1'

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

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 reutilizando o mesmo plano.

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

var postTitle = "post1";
var post1 = context.Posts.FirstOrDefault(p => p.Title == postTitle);
postTitle = "post2";
var post2 = context.Posts.FirstOrDefault(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 [Blogs] AS [b]
WHERE [b].[Name] = @__blogName_0

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

Observação

Os contadores de eventos de do EF Core relatar a Taxa de Ocorrência do Cache de Consultas. Em um aplicativo normal, esse contador atinge 100% logo após a inicialização do programa, uma vez que a maioria das consultas foi executada pelo menos uma vez. Se esse contador permanecer estável abaixo de 100%, isso é uma indicação de que seu aplicativo pode estar fazendo algo que derrota o cache de consultas - é uma boa ideia investigar isso.

Observação

A forma como o banco de dados gerencia os planos de consulta de cache 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 as instruções preparadas podem produzir um efeito final muito semelhante). Consulte a documentação do 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 arbitrários de consulta 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, pois é fácil produzir acidentalmente árvores de expressão com formas que diferem todas as vezes.

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: compile dinamicamente a expressão com a API de Expressão usando um nó constante. Esse é um erro frequente ao criar dinamicamente árvores de expressão e faz com que o EF recompile a consulta sempre que ela for invocada com um valor constante diferente (isso também geralmente causa a poluição do cache de planos no servidor de banco de dados).
  2. API de expressão 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 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 criar dinamicamente sua árvore de expressão sem recorrer à API de Expressão, o que é fácil de errar.

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

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

O benchmark dessas duas técnicas fornece os seguintes resultados:

Método Média Erro StdDev Gen0 Gen1 Alocado
ExpressionApiWithConstant 1,665.8 us 56.99 us 163.5 us 15.6250 - 109,92 KB
ExpressionApiWithParameter 757.1 us 35.14 us 103.6 us 12.6953 0.9766 54,95 KB
SimpleWithParameter 760.3 us 37.99 us 112.0 us 12.6953 - 55,03 KB

Mesmo que a diferença de submissegundo pareça pequena, tenha em mente 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 em seu desempenho geral. É altamente recomendável evitar a recompilação de consulta constante.

Observação

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

Modelos compilados

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 entidade e relações. A hora de inicialização aqui é a hora de 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 de 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 por meio da ferramenta de linha de comando dotnet ef. Verifique se você instalou a última versão 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 no qual 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 desse comando inclui uma parte do 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 do modelo compilado

Normalmente, não é necessário examinar o código de inicialização gerado. No entanto, às vezes, talvez seja útil personalizar o modelo ou o carregamento dele. O código de inicialização é parecido com este:

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

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

Além disso, vários modelos compilados podem ser gerados para tipos de DbContext que podem usar modelos diferentes, dependendo de alguma configuração de runtime. Eles devem ser colocados em pastas e namespaces diferentes, conforme mostrado acima. Em seguida, as informações de runtime, 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. Normalmente, não vale a pena compilar modelos pequenos.

Se o suporte a qualquer um desses recursos for fundamental para seu sucesso, vote nos problemas apropriados vinculados acima.

Reduzindo a sobrecarga de runtime

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

  • Ative o pool de DbContext; nossos parâmetros de comparação mostram que esse recurso pode ter um impacto decisivo em aplicativos de alta perf e baixa latência.
    • Verifique se o maxPoolSize corresponde ao seu cenário de uso; se for muito baixo, DbContext instâncias serão constantemente criadas e descartadas, degradando o desempenho. Defini-lo muito alto pode consumir memória desnecessariamente, pois instâncias de DbContext não usadas são mantidas no pool.
    • Para obter um aumento extra de perf, considere usar PooledDbContextFactory em vez de ter instâncias de contexto de injeção de DI diretamente. O gerenciamento de DI do pool de DbContext incorre em uma pequena sobrecarga.
  • Use consultas pré-compiladas para consultas frequentes.
    • Quanto mais complexa a consulta LINQ - quanto mais operadores ela contiver e maior a árvore de expressão resultante - mais ganhos podem ser esperados com o uso de consultas compiladas.
  • Considere desabilitar verificações de segurança de thread definindo EnableThreadSafetyChecks como false na configuração de contexto.
    • Não há suporte para usar a mesma instância DbContext simultaneamente de threads diferentes. O EF Core tem um recurso de segurança que detecta esse bug de programação em muitos casos (mas não todos) e gera imediatamente uma exceção informativa. No entanto, esse recurso de segurança adiciona alguma sobrecarga de tempo de execução.
    • AVISO: desabilite apenas as verificações de segurança de thread após testar minuciosamente se seu aplicativo não contém esses bugs de simultaneidade.