Performance de código Entity Framework Core - Parte 2
Published Jun 08 2022 09:00 AM 1,897 Views
Microsoft

Como os dados chegam até à aplicação

 

Consultas LINQ com EF Core

 

No artigo anterior vimos como utilizar os logs para entender e otimizar consultas, neste artigo vamos entender um pouco mais sobre queries e como os dados são mapeados para objetos na aplicação.

LINQ ou Language Integrated Query é um modelo que permite utilizar linguagem declarativa para manipulação de dados dentro de linguagens de programação.

 

Entity Framework e Entity Framework Core utilizam LINQ como linguagem para elaboração de consultas. LINQ se baseia fortemente nas implementações de algumas interfaces, sendo uma das principais IQueryable. Essa interface possibilita a construção de árvores de expressões que por sua vez serão convertidas em comandos específicos do banco de dados utilizado pela aplicação.

Vamos tomar como exemplo esse trecho de código:

 

var qry = from c in context.Customers
        select c;

Pelo Visual Studio podemos identificar que a variável qry é do tipo IQueryable<Customer>?, ou seja ela representa uma consulta LINQ:

Internamente esse trecho de código será traduzido pelo compilador para métodos de uma API fluída, portanto o mesmo comando pode ser escrito como abaixo:

 

var qry = context.Customers
            .Select(c => c);

Ou ainda, de forma simplificada uma vez que DbSet<TEntity> implementa a interface IQueryable:

 

var qry = context.Customers;

Usando o método ToQueryString é possível visualizar a consulta que será enviada ao banco de dados:

 

var queryString = qry.ToQueryString();
Console.WriteLine(queryString);
SELECT [c].[CustomerID], [c].[CompanyName], [c].[EmailAddress], [c].[FirstName], [c].[LastName], [c].[MiddleName], [c].[ModifiedDate], [c].[NameStyle], [c].[PasswordHash], [c].[PasswordSalt], [c].[Phone], [c].[rowguid], [c].[SalesPerson], [c].[Suffix], [c].[Title]
FROM [SalesLT].[Customer] AS [c]

Note que eu usei o verbo no futuro “será”, ou seja nada foi executado ainda e sempre é possível modificar a consulta antes de efetivamente executá-la. O trecho de código a seguir:

 

int? id = 123;

var qry = context.Customers
            .Select(c => c);

if (id!=null) {
    qry = qry.Where(c => c.CustomerId==id);
}

Gerará o seguinte comando SQL:

 

DECLARE @__id_0 int = 123;

SELECT [c].[CustomerID], [c].[CompanyName], [c].[EmailAddress], [c].[FirstName], [c].[LastName], [c].[MiddleName], [c].[ModifiedDate], [c].[NameStyle], [c].[PasswordHash], [c].[PasswordSalt], [c].[Phone], [c].[rowguid], [c].[SalesPerson], [c].[Suffix], [c].[Title]
FROM [SalesLT].[Customer] AS [c]
WHERE [c].[CustomerID] = @__id_0

Quando as consultas são executadas?

As consultas são efetivamente executadas quando se acessa os dados no código e uma forma de fazer isso é percorrer os resultados da consulta usando um loop foreach uma vez que IQueryable implementa IEnumerable. Implicitamente antes de iterar pelos resultados a consulta será executada.

 

var qry = context.Customers
    .Where(c => ids.Contains(c.CustomerId))
    .Select(c => new {
        CustomerId = c.CustomerId,
        FirstName = c.FirstName,
        LastName = c.LastName
    });

foreach (var item in qry) {
    Console.WriteLine($"{item.CustomerId}, {item.LastName}, {item.FirstName}");
}

As vezes o que se deseja é trazer uma única entidade como resultado, nesses casos o melhor é utilizar Single, SingleAsync, SingleOrDefault ou SingleOrDefaultAsync:

 

var customer = context.Customers
            .Single(c => c.CustomerId == id);

Outra forma de obter os resultados de uma consulta é trazer todos os resultados de uma vez na forma de uma lista ou array usando algum dos métodos **To__**:

 

List<MinimalCustomer> customers = context.Customers
    .Select(c => new MinimalCustomer { 
        CustomerId = c.CustomerId,
        FirstName = c.FirstName,
        LastName = c.LastName })
    .ToList();

É possível carregar os resultados de uma consulta usando os métodos:

  • ToList, ToListAsync
  • ToArray, ToArrayAsync
  • ToDictionary, ToDictionaryAsync,
  • ToHashSet

Qual usar?

Para responder a essa pergunta é necessário entender o que acontece com os dados a partir do momento em que a consulta é executada e o primeiro lote de resultados chega à aplicação.

 

O uso de Single não requer muita explicação, é o que se usa quando se deseja obter uma única instância de uma entidade, normalmente em consultas utilizando a chave primária.

 

Resultados que vem na forma de conjuntos de dados (datasets ou resultsets) talvez necessitem um pouco mais de discussão.

Quando se itera através dos dados usando um loop os dados são mapeados para objetos em memória a medida em que são utilizados. Nesse caso a alocação de espaço em memória fica distribuída ao longo do processo.

 

Por outro lado quando se usa ToList, ToArray ou equivalentes, esse loop existe dentro do método e fará a carga de todos os dados resultantes da consulta de uma só vez para a memória.

 

Alguns benchmarks podem ajudar a entender melhor essas diferenças:

 

Nos meus testes usei uma instância básica do Azure SQL Database, com o banco de dados AdventureWorksLT e com os benchmarks rodando em uma máquina virtual no Azure, vários fatores podem interferir com a performance, mas nesse momento estou apenas interessado em desempenho relativo e alocação de memória. Utilizei o BenchmarkDotNet para os testes:

A primeira análise é em relação ao tempo necessário para ter acesso ao primeiro registro retornado iterando diretamente pelos resultados comparado a carregar os dados para uma lista usando ToList. Esse é o código e essa consulta retorna 542 linhas:

 

    [Benchmark(Baseline=true)]
    public void TimeToFirstRecordStream() {
        using AdvWorksDbContext context = new AdvWorksDbContext();
        var qry = context.SalesOrderHeaders
            .Include("SalesOrderDetails")
            .Include("SalesOrderDetails.Product");

        foreach (var item in qry.AsEnumerable()) {
            var tmp = item.SalesOrderId++;
            break; 
        }
    }

    [Benchmark]
    public void TimeToFirstRecordLoad() {
        using AdvWorksDbContext context = new AdvWorksDbContext();
        var qry = context.SalesOrderHeaders
            .Include("SalesOrderDetails")
            .Include("SalesOrderDetails.Product");

        foreach (var item in qry.ToList()) {
            var tmp = item.SalesOrderId++;
            break;
        }
    }

Algo que achei bem interessante é que a diferença de tempos médios para obter a primeira linha tanto ao iterar quanto ao carregar os dados se mostrou pequena em todos os meus testes, com ligeira vantagem para o ToList, indicando que o EF Core faz um trabalho de carga e mapeamento de dados eficiente e com pouco overhead. A principal diferença é vista na quantidade de memória alocada, 23kb quando se usa IEnumerable contra 356Kb quando se carrega todos os dados:

 

|                  Method |     Mean |    Error |   StdDev |   Median | Ratio | RatioSD | Allocated |
|------------------------ |---------:|---------:|---------:|---------:|------:|--------:|----------:|
| TimeToFirstRecordStream | 256.1 ms | 12.51 ms | 36.09 ms | 258.2 ms |  1.00 |    0.00 |    107 KB |
|   TimeToFirstRecordLoad | 245.0 ms |  6.88 ms | 17.99 ms | 255.8 ms |  0.98 |    0.16 |  2,543 KB |

Vamos repetir o teste com uma quantidade maior de informações, nesse exemplo vamos alterar a consulta usando um cross-join para obter um produto cartesiano das entidades SalesOrderHeaders e SalesOrderDetails (inútil em uma aplicação real, mas bom para gerar um resultado grande). Essa alteração vai retornar 17344 linhas de dados.

 

    ...
    var qry = from h in context.SalesOrderHeaders
                from d in context.SalesOrderDetails
                select new { h, d };  
    ...

Apesar da quantidade maior de informações trazidas do banco de dados, o tempo para se obter a primeira linha de dados não foi significantemente diferente e ainda se mostrou ligeiramente mais eficiente ao carregar todos os dados usando um ToList, porém é importante notar que antes de se acessar qualquer resultado 11MB de memória foram alocados:

 

|                     Method |     Mean |   Error |   StdDev |   Median | Ratio | RatioSD | Allocated |
|--------------------------- |---------:|--------:|---------:|---------:|------:|--------:|----------:|
| TimeToFirstRecordBigStream | 299.7 ms | 5.93 ms | 11.28 ms | 304.6 ms |  1.00 |    0.00 |    123 KB |
|   TimeToFirstRecordBigLoad | 293.9 ms | 5.87 ms | 12.26 ms | 289.4 ms |  0.98 |    0.06 | 11,765 KB |

Próximo passo é descobrir o que acontece quando se usa os dados, ou seja remover o break para que o loop percorra todas as linhas resultantes da consulta. Nesse caso podemos notar que os mesmos 11MB serão alocados em memória independente da forma de trazer os dados:

 

|           Method |     Mean |   Error |   StdDev | Ratio | RatioSD | Allocated |
|----------------- |---------:|--------:|---------:|------:|--------:|----------:|
| IterateBigStream | 299.3 ms | 5.94 ms | 12.26 ms |  1.00 |    0.00 |     11 MB |
|   IterateBigLoad | 295.9 ms | 5.86 ms | 11.28 ms |  0.99 |    0.05 |     11 MB |

Os números acima deixam claro que não existe mágica, em algum momento um objeto correspondente à uma linha de resultado da consulta será criado e ocupará espaço em memória até que seja destruído, a principal diferença é que quando se itera pelos resultados os dados são mapeados para objetos a medida em que são acessados fazendo com que a utilização de memória pela aplicação seja mais constante.

Conclusões

Nos cenários mais cotidianos, percorrer os resultados enquanto processa os dados individualmente ou carregar todos os resultados para a memória não deve trazer diferenças significativas no desempenho. Por cenários cotidianos entenda-se, trazer uma quantidade de dados que possa ser manipulada por um usuário final no browser.

 

A principal regra para consultas SQL continua válida, traga só os dados necessários, use projeções para limitar colunas e filtros para limitar as linhas. Use paginação para trazer o que se quer apresentar uma página por vez.

 

Cenários onde se manipula grandes volumes de dados, por exemplo integrações entre aplicações, costumam ser menos comuns. Ao se deparar com um cenário desse tipo com desempenho abaixo do esperado comece por investigar as consultas, lembre-se que a maior parte do trabalho em geral ocorre no banco de dados. Teste as consultas fora da aplicação usando Visual Studio, Azure Data Studio ou qualquer outra ferramenta que permita executar código diretamente no banco dados, considere o uso de Stored Procedures para queries complexas.

Remova ToList ou ToArray e itere diretamente pelos resultados e considere também a possibilidade de remover a camada de ORM e utilizar diretamente o ADO.NET (Microsoft.Data.SqlClient) para processar os resultados.

 

Referências

Artigos anteriores

Co-Authors
Version history
Last update:
‎Jun 08 2022 10:59 AM
Updated by: