Compartilhar via


Consultas únicas vs. consultas divididas

Problemas de desempenho em consultas únicas

Ao trabalhar nos bancos de dados relacionais, o EF carrega entidades relacionadas apresentando JOINS 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 = await ctx.Blogs
    .Include(b => b.Posts)
    .Include(b => b.Contributors)
    .ToListAsync();
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 quando mais JUNÇÕES relacionadas são adicionadas à consulta; 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 = await ctx.Blogs
    .Include(b => b.Posts)
    .ThenInclude(p => p.Comments)
    .ToListAsync();
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 é retornada para cada comentário feito em um blog (por 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 única navegação de coleção:

var blogs = await ctx.Blogs
    .Include(b => b.Posts)
    .ToListAsync();
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 Blogs e Posts, 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ê não precisar de fato da coluna enorme, é fácil simplesmente não consultá-la:

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

Ao usar uma projeção para escolher explicitamente quais colunas você deseja, é possível omitir colunas grandes e melhorar o desempenho; é importante ressaltar que isso é uma boa ideia independentemente da duplicação de dados, portanto, considere fazê-lo mesmo quando não estiver carregando navegações 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 = await context.Blogs
        .Include(blog => blog.Posts)
        .AsSplitQuery()
        .ToListAsync();
}

Será gerada a seguinte instrução 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 em versões do EF anteriores à 10, preste atenção especial para garantir que a ordenação da sua consulta seja totalmente distinta; não fazer isso pode resultar no retorno de dados incorretos. 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 = await context.Blogs
        .Include(blog => blog.Posts)
        .AsSingleQuery()
        .ToListAsync();
}

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 divisão de consultas 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. Você pode atenuar esse problema envolvendo as consultas em uma transação serializável ou de instantâneo, embora isso possa criar problemas de desempenho por si só. Para obter mais informações, consulte a documentação do banco de dados.
  • Atualmente, cada consulta implica uma viagem adicional de ida e volta da rede para o seu 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.