Compartilhar via


Consultas únicas vs. consultas divididas

Problemas de desempenho em consultas únicas

Ao trabalhar em bancos de dados relacionais, o EF carrega entidades relacionadas apresentando JUNÇÕES em uma consulta única. Embora as JUNÇÕES sejam bem padrões no uso da SQL, elas podem criar problemas de desempenho significativos se forem usadas incorretamente. Esta página descreve esses problemas de desempenho e mostra uma maneira alternativa de carregar entidades relacionadas permitindo contornar esses problemas.

Explosão cartesiana

Vamos examinar a consulta LINQ a seguir e sua equivalente de SQL traduzida:

var blogs = ctx.Blogs
    .Include(b => b.Posts)
    .Include(b => b.Contributors)
    .ToList();
SELECT [b].[Id], [b].[Name], [p].[Id], [p].[BlogId], [p].[Title], [c].[Id], [c].[BlogId], [c].[FirstName], [c].[LastName]
FROM [Blogs] AS [b]
LEFT JOIN [Posts] AS [p] ON [b].[Id] = [p].[BlogId]
LEFT JOIN [Contributors] AS [c] ON [b].[Id] = [c].[BlogId]
ORDER BY [b].[Id], [p].[Id]

Neste exemplo, já que tanto Posts quanto Contributors são navegações de coleção de Blog, elas estão no mesmo nível, os bancos de dados relacionais retornam um produto cruzado: cada linha de Posts é unida a cada linha de Contributors. Isso quer dizer que, se um determinado blog tiver 10 postagens e 10 colaboradores, o banco de dados retornará 100 linhas para esse blog individual. Esse fenômeno, às vezes chamado de explosão cartesiana, pode fazer com que grandes quantidades de dados sejam transferidas involuntariamente para o cliente, especialmente à medida que mais JUNÇÕES do mesmo tipo são adicionados à consulta e isso pode ser um importante problema de desempenho em aplicativos de banco de dados.

Observe que a explosão cartesiana não ocorre quando as duas JUNÇÕES não estão no mesmo nível:

var blogs = ctx.Blogs
    .Include(b => b.Posts)
    .ThenInclude(p => p.Comments)
    .ToList();
SELECT [b].[Id], [b].[Name], [t].[Id], [t].[BlogId], [t].[Title], [t].[Id0], [t].[Content], [t].[PostId]
FROM [Blogs] AS [b]
LEFT JOIN [Posts] AS [p] ON [b].[Id] = [p].[BlogId]
LEFT JOIN [Comment] AS [c] ON [p].[Id] = [c].[PostId]
ORDER BY [b].[Id], [t].[Id]

Nesta consulta, Comments é uma navegação de coleção de Post, diferentemente de Contributors na consulta anterior, que era uma navegação de coleção de Blog. Nesse caso, uma linha única retorna para cada comentário feito em um blog (por meio de suas postagens) e não ocorre um produto cruzado.

Duplicação de dados

As JUNÇÕES podem criar outro tipo de problema de desempenho. Vamos examinar a consulta a seguir, que carrega apenas uma navegação de coleção:

var blogs = ctx.Blogs
    .Include(b => b.Posts)
    .ToList();
SELECT [b].[Id], [b].[Name], [b].[HugeColumn], [p].[Id], [p].[BlogId], [p].[Title]
FROM [Blogs] AS [b]
LEFT JOIN [Posts] AS [p] ON [b].[Id] = [p].[BlogId]
ORDER BY [b].[Id]

Verificando as colunas projetadas, cada linha retornada por essa consulta contém propriedades das tabelas Posts e Blogs, o que significa que as propriedades do blog estão duplicadas para cada postagem feita no blog. Embora isso geralmente seja normal e não cause problemas, caso a tabela Blogs tenha uma coluna muito grande (por exemplo, com dados binários ou um texto enorme), essa coluna será duplicada e enviada de volta ao cliente várias vezes. Isso pode aumentar muito o tráfego de rede e afetar negativamente o desempenho do aplicativo.

Se você de fato não necessita da coluna enorme, é fácil simplesmente não consultar para ela:

var blogs = ctx.Blogs
    .Select(b => new
    {
        b.Id,
        b.Name,
        b.Posts
    })
    .ToList();

Ao usar uma projeção para escolher explicitamente quais colunas você deseja, é possível omitir colunas grandes e melhorar o desempenho. O que é uma boa ideia, independentemente da duplicação de dados, portanto, considere fazer isso mesmo quando não estiver carregando uma navegação de coleção. No entanto, como isso projeta o blog para um tipo anônimo, o blog não é acompanhado pelo EF e as alterações feitas nele não poderão ser salvas como de costume.

Vale a pena observar que, ao contrário da explosão cartesiana, a duplicação de dados causada pelas JUNÇÕES geralmente não é significativa, pois o tamanho dos dados duplicados é insignificante. O que normalmente é algo digno de preocupação apenas se você tiver colunas grandes em sua tabela principal.

Dividir consultas

Para contornar os problemas de desempenho descritos acima, o EF permite especificar que uma determinada consulta LINQ deve ser dividida em várias consultas SQL. Em vez de JUNÇÕES, as consultas divididas geram uma consulta SQL adicional para cada navegação de coleção incluída:

using (var context = new BloggingContext())
{
    var blogs = context.Blogs
        .Include(blog => blog.Posts)
        .AsSplitQuery()
        .ToList();
}

Elas produzirão a seguinte SQL:

SELECT [b].[BlogId], [b].[OwnerId], [b].[Rating], [b].[Url]
FROM [Blogs] AS [b]
ORDER BY [b].[BlogId]

SELECT [p].[PostId], [p].[AuthorId], [p].[BlogId], [p].[Content], [p].[Rating], [p].[Title], [b].[BlogId]
FROM [Blogs] AS [b]
INNER JOIN [Posts] AS [p] ON [b].[BlogId] = [p].[BlogId]
ORDER BY [b].[BlogId]

Aviso

Ao usar consultas divididas com Skip/Take, preste atenção especial para tornar a ordem de consulta totalmente exclusiva, pois se você não fizer isso dados incorretos podem retornar. Por exemplo, se os resultados forem ordenados apenas por data, mas houver vários resultados com a mesma data, cada uma das consultas divididas poderá obter resultados diferentes do banco de dados. A ordenação por data e ID (ou qualquer outra propriedade individual ou combinação de propriedades) torna a ordenação totalmente exclusiva e evita esse problema. Observe que os bancos de dados relacionais não aplicam nenhuma ordenação por padrão, mesmo na chave primária.

Observação

Entidades relacionadas uma a uma são sempre carregadas por meio de JUNÇÕES na mesma consulta, pois elas não afetam o desempenho.

Habilitar consultas divididas globalmente

Também é possível configurar consultas divididas como o padrão para o contexto do aplicativo:

protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
    optionsBuilder
        .UseSqlServer(
            @"Server=(localdb)\mssqllocaldb;Database=EFQuerying;Trusted_Connection=True;ConnectRetryCount=0",
            o => o.UseQuerySplittingBehavior(QuerySplittingBehavior.SplitQuery));
}

Quando as consultas divididas são configuradas como padrão, ainda é possível configurar consultas específicas para serem executadas como consultas únicas:

using (var context = new SplitQueriesBloggingContext())
{
    var blogs = context.Blogs
        .Include(blog => blog.Posts)
        .AsSingleQuery()
        .ToList();
}

O EF Core usa o modo de consulta única por padrão na ausência de outra configuração. Como pode causar problemas de desempenho, o EF Core gera um aviso sempre que as seguintes condições são encontradas:

  • O EF Core detecta que a consulta carrega várias coleções.
  • O usuário não configurou o modo de consultas divididas globalmente.
  • O usuário não usou o operador AsSingleQuery/AsSplitQuery na consulta.

Para desativar o aviso, configure o modo de consultas divididas globalmente ou no nível da consulta para um valor apropriado.

Características de consultas divididas

Embora a consulta dividida evite os problemas de desempenho associados às JUNÇÕES e à explosão cartesiana, ela também tem algumas desvantagens:

  • Embora a maioria dos bancos de dados garanta consistência de dados em consultas únicas, não existem garantias em consultas múltiplas. Se a atualização do banco de dados ocorrer simultaneamente à execução de suas consultas, poderá não haver consistência nos dados resultantes. Isso pode ser reduzido ao quebrar a linha das consultas em uma transação serializável ou de instantâneo, embora isso possa criar problemas de desempenho próprios. Para obter mais informações, consulte a documentação do banco de dados.
  • Atualmente, cada consulta envolve uma viagem de ida e volta de rede adicional ao banco de dados. Várias viagens de ida e volta de rede podem prejudicar o desempenho, especialmente onde a latência para o banco de dados é alta (por exemplo, em serviços de nuvem).
  • Embora alguns bancos de dados permitam consumir os resultados de várias consultas ao mesmo tempo (SQL Server com MARS, Sqlite), a maioria permite que apenas uma consulta esteja ativa em um determinado momento. Portanto, todos os resultados de consultas anteriores devem ser armazenados em buffer na memória do aplicativo antes de executar demais consultas, levando a um aumento dos requisitos de memória.
  • Ao incluir navegações de referência, bem como navegações de coleção, cada uma das consultas divididas incluirá junções às navegações de referência. Isso pode prejudicar o desempenho, principalmente se houver muitas navegações de referência. Dê um voto positivo em #29182 se isso for algo que você gostaria de ver corrigido.

Infelizmente, não há uma estratégia para carregar entidades relacionadas que atenda a todos os cenários. Analise cuidadosamente as vantagens e desvantagens de consultas únicas e divididas para selecionar aquela que atenda melhor às suas necessidades.