Performance de código Entity Framework Core - Parte 4
Published Aug 08 2022 11:55 AM 2,385 Views
Microsoft

 

Como lidar com os dados relacionados?

 

Round trips

De acordo com o dicionário round trip significa uma viagem que se inicia e termina no mesmo ponto ou em outras palavras ida e volta.

Quando se fala sobre aplicações distribuídas uma das coisas que devem ser evitadas ao máximo são as idas e voltas para trazer informações. Comunicação tem um custo alto na hora de transferir dados de onde eles estão armazenados para onde eles serão utilizados e sempre que possível é melhor trazer uma quantidade maior de dados de uma vez do que pequenas quantidades de dados diversas vezes.

Uma analogia simples seria comparar os custos envolvidos em trazer uma carga de minérios ou grãos por trem em uma única viagem comparados aos custos de trazer a mesma carga em caminhões fazendo várias viagens.

Talvez o termo chatty interfaces ou interfaces conversadeiras soe familiar, tratam-se de interfaces onde são necessárias várias chamadas para realizar uma operação em oposição às batch interfaces ou interfaces em lote onde é possível enviar ou receber volumes maiores de dados em uma única chamada evitando-se as idas e vindas dos dados.

De maneira geral, um round trip é algo que deve ser evitado sempre que possível.

 

SELECT N+1

Sempre que se fala sobre otimização de ORM em algum momento surge o assunto das consultas SELECT N+1. O SELECT N+1 aqui se refere a uma situação no código onde uma primeira consulta (+1) é feita para trazer um resultado e na sequência para cada linha desse resultado se efetua uma nova consulta (N) para trazer dados relacionados.

Esse é um padrão não muito recomendado porque gera round trips, ou seja, idas e vindas ao servidor de dados que deveriam ser evitadas para reduzir os custos envolvidos no transporte pela rede.

Esse não é um problema com os frameworks ORM só que nesses frameworks essa é uma situação que nem sempre fica explicita no código escrito levando a problemas de desempenho que somente serão percebidos com a aplicação em produção.

Um exemplo sem o uso de EF Core pode ilustrar esse cenário de forma mais clara:

using Microsoft.Data.SqlClient;

string connectionString = "Data Source=(localdb)\\MSSQLLocalDB;Initial Catalog=AdventureWorksLT2016;Integrated Security=True;MultipleActiveResultSets=True";

using SqlConnection conn = new SqlConnection(connectionString);

conn.Open();

SqlCommand outerCmd = new SqlCommand("SELECT ProductCategoryID, Name FROM SalesLT.ProductCategory",conn);
SqlCommand innerCmd = new SqlCommand("SELECT ProductID, Name FROM SalesLT.Product WHERE ProductCategoryID = @id",conn);
innerCmd.Parameters.Add("@id", System.Data.SqlDbType.Int);

var outerReader = outerCmd.ExecuteReader();
while (outerReader.Read())
{
    Console.WriteLine($"{outerReader["ProductCategoryID"]}: {outerReader["Name"]}");
    innerCmd.Parameters["@id"].Value = outerReader["ProductCategoryID"];
    var innerReader = innerCmd.ExecuteReader();
    while (innerReader.Read())
    {
        Console.WriteLine($"\t{innerReader["ProductId"]}:{innerReader["Name"]}");
    }
    innerReader.Close();
}

Esse exemplo escancara de forma proposital o cenário SELECT N+1 pra deixar claro o conceito, porém não é incomum encontrar código similar a esse em aplicações e é uma situação fácil de cair quando se tem pouca experiência.

Note que existem duas consultas a primeira traz todas as categorias de produtos enquanto a segunda traz todos os produtos de uma categoria específica e são usados dois loops para varrer os resultados das consultas. A cada passagem do loop externo a segunda consulta é executada para trazer os produtos da categoria.

 

Como o EF Core mapeia os relacionamentos

O Entity Framework mapeia os relacionamentos entre entidades através de propriedades de navegação como pode ser visto neste exemplo:

public class Blog
{
    public int BlogId { get; set; }
    public string Url { get; set; }

    public List<Post> Posts { get; set; }
}

public class Post
{
    public int PostId { get; set; }
    public string Title { get; set; }
    public string Content { get; set; }

    public int BlogId { get; set; }
    public Blog Blog { get; set; }
}

Note que no código a classe Blog possui uma propriedade Posts do tipo List<Post> representando um relacionamento de 1 para N entre o blog e os seus respectivos posts, do outro lado podemos ver que a classe Post possui uma propriedade do tipo Blog que representa o relacionamento direto entre o post e o blog ao qual pertence.

 

Como os dados relacionados são carregados

O Entity Framework suporta as seguintes formas de carregar os dados relacionados para a aplicação:

  • Eager loading, os dados são carregados para o banco de dados como parte da consulta inicial.
  • Explicit loading, os dados relacionados são carregados de forma explícita no código quando necessário.
  • Lazy loading, os dados relacionados são carregados implicitamente quando acessados.

Começando por uma consulta simples:

    var qry = from c in _context.ProductCategories
                select c;
    
    var categories = qry.ToList();

Esta consulta gerará o comando SQL que extrai os dados da tabela ProductCategory como pode ser visto no trecho de log abaixo:

info: Microsoft.EntityFrameworkCore.Database.Command[20101]
      Executed DbCommand (15ms) [Parameters=[], CommandType='Text', CommandTimeout='30']
      SELECT [p].[ProductCategoryID], [p].[ModifiedDate], [p].[Name], [p].[ParentProductCategoryID], [p].[rowguid]
      FROM [SalesLT].[ProductCategory] AS [p]

Se tentarmos obter os produtos de uma determinada categoria não ocorrerá erro, porém obteremos uma lista vazia:

    var products = categories.First(c=>c.ProductCategoryId==5).Products.ToList();
Detalhe do Visual Studio mostrando que products possui 0 itens

Por padrão o EF Core não carregará itens relacionados de forma implícita (lazy loading), sendo necessário que os dados sejam carregados de forma explícita no código.

 

Eager loading

Uma forma de se carregar os dados explicitamente é o eager loading, ou carregamento fominha, onde os dados da entidade principal e da entidade relacionada são carregados em uma única consulta ao banco de dados. No Entity Framework usa-se o método Include nas consultas para indicar quais propriedades de navegação devem ter suas entidades carregadas ao executar.

No nosso exemplo podemos modificar a consulta para incluir os dados de produtos ao consultar as categorias:

    var qry = from c in _context.ProductCategories.Include(c=>c.Products)
                select c;

Ou também podemos passar a propriedade de navegação como string:

    var qry = from c in _context.ProductCategories.Include("Products")
                select c;

Ao verificar a query gerada nota-se que foi acrescentado um LEFT JOIN para trazer os dados tanto da tabela ProductCategories quanto Products:

      SELECT [p].[ProductCategoryID], [p].[ModifiedDate], [p].[Name], [p].[ParentProductCategoryID], [p].[rowguid], [p0].[ProductID], [p0].[Color], [p0].[DiscontinuedDate], [p0].[ListPrice], [p0].[ModifiedDate], [p0].[Name], [p0].[ProductCategoryID], [p0].[ProductModelID], [p0].[ProductNumber], [p0].[rowguid], [p0].[SellEndDate], [p0].[SellStartDate], [p0].[Size], [p0].[StandardCost], [p0].[ThumbNailPhoto], [p0].[ThumbnailPhotoFileName], [p0].[Weight]
      FROM [SalesLT].[ProductCategory] AS [p]
      LEFT JOIN [SalesLT].[Product] AS [p0] ON [p].[ProductCategoryID] = [p0].[ProductCategoryID]
      ORDER BY [p].[ProductCategoryID]

E o resultado é esse aqui:

Resultado da consulta SQL mostrando os dados de produtos e categorias

Podem ser usado mais de um Include para trazer dados de outros relacionamentos e também ThenInclude para incluir relacionamentos de objetos relacionados e assim sucessivamente:

    var qry = from c in _context.ProductCategories
                .Include(c => c.Products)
                    .ThenInclude(p => p.SalesOrderDetails)
                .Include(c => c.ParentProductCategory)
                select c;

Fica bem claro aqui que cada Include ou ThenInclude adicionado à consulta resulta em um JOIN na consulta SQL gerada o que por sua vez resulta em mais operações por parte do servidor de dados para chegar até a informação solicitada com consequente impacto no desempenho a depender da quantidade de dados relacionados e índices que possam ser utilizados. E não me entenda mal, o gerenciador de banco de dados faz esse trabalho com alto grau de eficiência apenas é importante ter em mente que aumenta a quantidade de trabalho necessária para chegar ao resultado.

Outra situação que pode ser causada ao carregar os dados desta maneira é uma situação conhecida por “explosão cartesiana”. Tomando a nossa consulta como exemplo, cada linha de produto traz também dados da categoria a qual o produto pertence o que gera dados redundantes e dependendo da quantidade de linhas retornadas esta duplicidade pode ter impacto significante em desempenho devido ao tráfego dos dados.

O EF Core tem um recurso para esse cenário que é chamado de split queries que como o nome diz ao invés de uma única consulta serão utilizadas consultas para cada entidade. Para usar o recurso basta incluir o método AsSplitQuery como pode ser visto abaixo:

    var qry = from c in _context.ProductCategories
                .Include(c => c.Products)
                .AsSplitQuery()
                select c;

Pelo log pode-se ver que ao invés de uma consulta agora são geradas duas, uma para cada entidade na consulta reduzindo a redundância de dados:

info: Microsoft.EntityFrameworkCore.Database.Command[20101]
      Executed DbCommand (17ms) [Parameters=[], CommandType='Text', CommandTimeout='30']
      SELECT [p].[ProductCategoryID], [p].[ModifiedDate], [p].[Name], [p].[ParentProductCategoryID], [p].[rowguid]
      FROM [SalesLT].[ProductCategory] AS [p]
      ORDER BY [p].[ProductCategoryID]
info: Microsoft.EntityFrameworkCore.Database.Command[20101]
      Executed DbCommand (3ms) [Parameters=[], CommandType='Text', CommandTimeout='30']
      SELECT [p0].[ProductID], [p0].[Color], [p0].[DiscontinuedDate], [p0].[ListPrice], [p0].[ModifiedDate], [p0].[Name], [p0].[ProductCategoryID], [p0].[ProductModelID], [p0].[ProductNumber], [p0].[rowguid], [p0].[SellEndDate], [p0].[SellStartDate], [p0].[Size], [p0].[StandardCost], [p0].[ThumbNailPhoto], [p0].[ThumbnailPhotoFileName], [p0].[Weight], [p].[ProductCategoryID]
      FROM [SalesLT].[ProductCategory] AS [p]
      INNER JOIN [SalesLT].[Product] AS [p0] ON [p].[ProductCategoryID] = [p0].[ProductCategoryID]
      ORDER BY [p].[ProductCategoryID]

É possível definir o comportamento padrão como split query e escolher single query em casos excepcionais:

...
    services.AddDbContext<AdvWorksDbContext>(options => {
        options.UseSqlServer(Configuration["ConnectionStrings:Default"],
            o=>o.UseQuerySplittingBehavior(QuerySplittingBehavior.SplitQuery));
    });
...
    var qry = from c in _context.ProductCategories
                .Include(c => c.Products)
                .AsSingleQuery()
                select c;
...

Espera, espera, espera! Não mude o código da sua aplicação ainda, se depois de ler tudo o que eu escrevi até agora você ainda espera uma solução mágica para todos os problemas é um sinal de que ainda tem muita coisa pra aprender sobre esse nosso ofício.

Dividir as consultas é uma opção para evitar joins e explosões cartesianas, mas também tem os seus “veja bem”. Recomendo a leitura atenta da documentação para maiores detalhes, mas podemos citar alguns poréns:

  • O banco de dados não garante a consistência entre múltiplas queries, pode-se utilizar transações serializadas ou snapshot para garantir essa integridade, porém isso também tem impacto sobre a performance.

  • Cada consulta exige um round trip, ou seja um caminhão indo buscar a informação ao invés de um trem.

  • Nem todos os bancos de dados conseguem tratar os resultados de forma concorrente (Ex.: SQL Server usando Multiple Active Resultsets - MARS) o que implica em maior consumo de memória devido a necessidade de manter os resultados em buffer.

Não me canso de dizer isso, não tem solução única para todos os problemas e cada cenário tem a sua melhor solução.

 

Explicit loading

Diferentemente do eager loading ao invés de trazer todos os dados de uma única vez é possível carregas os dados relacionados apenas quando forem utilizados:

    var qry = from c in _context.ProductCategories
                select c;
    
    var categories = qry.ToList();
    var category = categories.First(c => c.ProductCategoryId == 5);
    _context.Entry(category)
        .Collection(c => c.Products)
        .Load(); 
    var products = category.Products.ToList(); 

No exemplo de código acima é usado DbContext.Entry para carregar os dados de produtos relacionados à uma determinada categoria.

O problema óbvio em utilizar o carregamento explícito é que isso leva ao SELECT N+1 citado acima onde os nossos caminhões farão várias viagens até trazer todos os dados.

 

Lazy loading

Assim como no carregamento explicito o lazy loading também traz os dados apenas quando necessários porém de forma implícita. Dada essa característica muitos problemas de desempenho encontrados com frameworks ORM vêm justamente de que esse carregamento tardio dos dados ocorre de forma transparente gerando vários round trips que serão sentidos apenas em produção.

Nas versões mais novas do EF Core o lazy loading só ocorre de forma proposital e é necessário não apenas acrescentar a referência explícita ao pacote Microsoft.EntityFrameworkCore.Proxies, como também configurar explicitamente o DbContext para utilizar os proxies de lazy loading:

    services.AddDbContext<AdvWorksDbContext>(options => {
        options.UseLazyLoadingProxies();
        options.UseSqlServer(Configuration["ConnectionStrings:Default"]);
    });

Como lazy loading por padrão utiliza proxies para carregar os dados apenas quando solicitados é necessário que as propriedades de navegação sejam declaradas como virtual dentro de classes que possam ser herdadas, leia-se não declaradas como sealed:

    public partial class ProductCategory
    {
        ...
        public virtual ProductCategory? ParentProductCategory { get; set; }
        public virtual ICollection<ProductCategory> InverseParentProductCategory { get; set; }
        public virtual ICollection<Product> Products { get; set; }
    }

O exemplo abaixo usa lazy load para iterar pelas categorias e respectivos produtos. Note que o carregamento é feito de forma implícita sem a necessidade de nenhum comando para carregar os dados de produtos:

    var qry = from c in _context.ProductCategories
                select c;

    var categories = qry.ToList();

    foreach (var category in categories) {
        Console.WriteLine($"{category.ProductCategoryId}: {category.Name}");
        var products = category.Products.ToList();
        foreach (var product in products) {
            Console.WriteLine($"\t{product.ProductId}: {product.Name}");
        }
    }

Muito cuidado é necessário ao utilizar lazy loading, visto que há um risco de gerar um número excessivo de queries ao banco de dados devido as propriedades de navegação em objetos relacionados, inclusive com a possibilidade de referências circulares.

 

Como identificar e melhorar consultas relacionadas?

O primeiro passo é ter bem claro qual o tipo de aplicação que se está desenvolvendo. Aplicações Web tem como principal característica a ausência de estado diferentemente de aplicações desktop onde o estado é mantido no computador do usuário.

Aplicações Web de maneira geral apresentam melhor desempenho quando se obtém os dados em lotes, essas aplicações usam os dados para gerar uma saída em outro formato (HTML, XML, JSON entre outros) e esse resultado é transmitido em uma única resposta.

Algumas aplicações desktop (vale para o código executado no browser também) podem se beneficiar de cenários que chamamos de drill down onde o nível mais genérico de dados é carregado primeiro e os demais níveis de informação são trazidos a medida em que o usuário navega pelos dados.

Ambos são modelos válidos para aplicações e trazem seus prós e contras.

Tendo bem claras as características desejadas para a solução o próximo passo é saber quais queries são geradas e enviadas para o banco de dados. Como visto no primeiro artigo dessa série devemos habilitar o log do EF Core em desenvolvimento, utilizar ferramentas que coletem telemetria em produção e também ferramentas de monitoração do banco de dados para saber o que está chegando até o servidor. A parceria com o especialista em banco de dados é fundamental nestas análises.

Eu sempre recomendo aos clientes o uso de Application Insights para coleta de telemetria e análise de desempenho em aplicações. O Application Insights consegue evidenciar de forma clara todos os acessos a dados feitos em uma mesma operação deixando evidentes os problemas causados pelo uso de lazy loading em aplicações.

Detalhe do Application Insights mostrando uma operação com um número excessívo de consultas ao banco de dados.

Se o log do EF Core estiver ativo em desenvolvimento é possível identificar a ocorrência de múltiplas consultas como pode ser visto no trecho abaixo que mostra uma consulta à tabela ProductCategory seguida de varias consultas à tabela Product:

info: Microsoft.EntityFrameworkCore.Database.Command[20101]
      Executed DbCommand (15ms) [Parameters=[], CommandType='Text', CommandTimeout='30']
      SELECT [p].[ProductCategoryID], [p].[ModifiedDate], [p].[Name], [p].[ParentProductCategoryID], [p].[rowguid]
      FROM [SalesLT].[ProductCategory] AS [p]

info: Microsoft.EntityFrameworkCore.Database.Command[20101]
      Executed DbCommand (16ms) [Parameters=[@__p_0='?' (DbType = Int32)], CommandType='Text', CommandTimeout='30']
      SELECT [p].[ProductID], [p].[Color], [p].[DiscontinuedDate], [p].[ListPrice], [p].[ModifiedDate], [p].[Name], [p].[ProductCategoryID], [p].[ProductModelID], [p].[ProductNumber], [p].[rowguid], [p].[SellEndDate], [p].[SellStartDate], [p].[Size], [p].[StandardCost], [p].[ThumbNailPhoto], [p].[ThumbnailPhotoFileName], [p].[Weight]
      FROM [SalesLT].[Product] AS [p]
      WHERE [p].[ProductCategoryID] = @__p_0

info: Microsoft.EntityFrameworkCore.Database.Command[20101]
      Executed DbCommand (0ms) [Parameters=[@__p_0='?' (DbType = Int32)], CommandType='Text', CommandTimeout='30']
      SELECT [p].[ProductID], [p].[Color], [p].[DiscontinuedDate], [p].[ListPrice], [p].[ModifiedDate], [p].[Name], [p].[ProductCategoryID], [p].[ProductModelID], [p].[ProductNumber], [p].[rowguid], [p].[SellEndDate], [p].[SellStartDate], [p].[Size], [p].[StandardCost], [p].[ThumbNailPhoto], [p].[ThumbnailPhotoFileName], [p].[Weight]
      FROM [SalesLT].[Product] AS [p]
      WHERE [p].[ProductCategoryID] = @__p_0

info: Microsoft.EntityFrameworkCore.Database.Command[20101]
      Executed DbCommand (0ms) [Parameters=[@__p_0='?' (DbType = Int32)], CommandType='Text', CommandTimeout='30']
      SELECT [p].[ProductID], [p].[Color], [p].[DiscontinuedDate], [p].[ListPrice], [p].[ModifiedDate], [p].[Name], [p].[ProductCategoryID], [p].[ProductModelID], [p].[ProductNumber], [p].[rowguid], [p].[SellEndDate], [p].[SellStartDate], [p].[Size], [p].[StandardCost], [p].[ThumbNailPhoto], [p].[ThumbnailPhotoFileName], [p].[Weight]
      FROM [SalesLT].[Product] AS [p]
      WHERE [p].[ProductCategoryID] = @__p_0
...

O benchmark evidencia a diferença entre trazer todos os dados de uma única vez em comparação a múltiplas consultas (SELECT N+1):

Method Mean Error StdDev Median Ratio RatioSD Allocated
EagerLoadingSingleQuery 28.36 ms 3.297 ms 9.721 ms 23.81 ms 1.00 0.00 3 MB
EagerLoadingSplitQuery 27.35 ms 3.481 ms 10.209 ms 22.70 ms 1.08 0.57 3 MB
ExplicitLoading 195.82 ms 17.570 ms 51.806 ms 189.61 ms 7.64 3.17 4 MB
LazyLoading 169.49 ms 14.930 ms 43.786 ms 153.55 ms 6.73 2.96 4 MB

Nestes resultados o eager loading é usado como baseline (Ratio=1), o cenário testado foi carregar todas as categorias e os respectivos produtos usando ToList, olhando os resultados fica evidente a diferença de tempos causada pelas idas e vindas até a base de dados (ExplicitLoading e LazyLoading) em contraste com trazer os dados de uma só vez (EagerLoadingSingleQuery e EagerLoadingSplitQuery).

No benchmark o uso de split query se mostrou um pouco mais lento do que utilizar uma única query o que reforça que não existe resposta única para todas as situações, neste teste a redundância de dados teve um impacto menor do que a execução de duas consultas separadas.

 

Conclusões

De maneira geral, sempre que se souber de antemão que os dados retornados serão utilizados de uma única vez o ideal é trazê-los no menor número de consultas possível (preferivelmente uma consulta).

Evite o uso de lazy loading, pois isso pode trazer resultados inesperados em hierarquias de dados profundas além de esconder problemas que ficarão evidentes apenas em produção.

Entenda o que faz mais sentido para a aplicação, aplicações Web têm características diferentes de aplicações desktop.

Na dúvida meça, não é possível comparar performance de soluções possíveis para um dado problema sem números.

 

Referências

 

Artigos anteriores

1 Comment
Co-Authors
Version history
Last update:
‎Aug 08 2022 11:55 AM
Updated by: