Métodos para otimizar consultas LINQ em C # .NET

Introdução


No presente artigo , discutimos algumas técnicas de otimização LINQ consultas .
Aqui estão mais algumas abordagens de otimização de código relacionadas a consultas LINQ .

Sabe-se que o LINQ (Consulta Integrada à Linguagem) é uma linguagem simples e conveniente para consultar uma fonte de dados.

E o LINQ to SQL é uma tecnologia de acesso a dados em um DBMS. Essa é uma ferramenta poderosa para trabalhar com dados, onde as consultas são construídas por meio de uma linguagem declarativa, que serão convertidas em consultas SQL pela plataforma e enviadas ao servidor de banco de dados para execução. No nosso caso, por DBMS queremos dizer o MS SQL Server .

No entanto, as consultas LINQ não são convertidas em consultas SQL gravadas de maneira ideal que um DBA experiente poderia escrever com todas as nuances da otimização de consultas SQL :

  1. conexões ideais ( JOIN ) e filtragem de resultados ( ONDE )
  2. muitas nuances no uso de compostos e condições de grupo
  3. muitas variações na substituição das condições IN por EXISTS e NOT IN , <> por EXISTS
  4. cache intermediário de resultados por meio de tabelas temporárias, CTE, variáveis ​​de tabela
  5. usando uma cláusula ( OPTION ) com instruções e dicas de tabela WITH (...)
  6. o uso de visualizações indexadas, como um dos meios para se livrar de leituras redundantes de dados em amostras

Os principais gargalos de desempenho das consultas SQL resultantes ao compilar consultas LINQ são:

  1. consolidação de todo o mecanismo de seleção de dados em uma solicitação
  2. duplicação de blocos de código idênticos, o que leva a várias leituras extras de dados
  3. grupos de condições multicomponentes (lógicas "e" e "ou") - AND e OR , combinando-se em condições difíceis, levam ao fato de que o otimizador, com índices não clusterizados adequados, pelos campos necessários, eventualmente começa a varrer pelo índice de cluster ( INDEX SCAN ) por grupo de condições
  4. o aninhamento profundo de subconsultas torna muito problemático analisar instruções SQL e analisar planos de consulta de desenvolvedores e DBAs

Métodos de otimização


Agora passamos diretamente para os métodos de otimização.

1) Indexação adicional


É melhor considerar filtros nas principais tabelas de amostragem, pois muitas vezes toda a consulta é criada em torno de uma ou duas tabelas principais (aplicativos-pessoas-operações) e com um conjunto de condições padrão (IsClosed, Canceled, Enabled, Status). É importante que as amostras identificadas criem os índices correspondentes.

Essa solução faz sentido ao escolher um desses campos que limita significativamente o conjunto retornado à consulta.

Por exemplo, temos 500.000 aplicativos. No entanto, existem apenas 2.000 entradas ativas. Em seguida, um índice selecionado corretamente nos salvará do INDEX SCAN em uma tabela grande e permitirá selecionar rapidamente os dados por meio de um índice não clusterizado.

A falta de índices também pode ser detectada através de prompts para analisar planos de consulta ou coletar estatísticas para visualizações do sistemaMS SQL Server :

  1. sys.dm_db_missing_index_groups
  2. sys.dm_db_missing_index_group_stats
  3. sys.dm_db_missing_index_details

Todos os dados de exibição contêm informações sobre índices ausentes, com exceção dos índices espaciais.

No entanto, índices e cache geralmente são métodos para lidar com os efeitos de consultas LINQ mal gravadas e consultas SQL .

Como a dura prática da vida mostra para os negócios, muitas vezes é importante implementar os recursos de negócios até uma certa data. E, portanto, muitas vezes consultas pesadas são colocadas em segundo plano com o cache.

Isso é parcialmente justificado, pois o usuário nem sempre precisa dos dados mais recentes e ocorre um nível aceitável de resposta da interface do usuário.

Essa abordagem permite solucionar as necessidades dos negócios, mas reduz a eficiência do sistema de informações, simplesmente atrasando a solução dos problemas.

Também é importante lembrar que, no processo de pesquisa necessário para adicionar novos índices, as propostas de otimização do MS SQL podem estar incorretas, inclusive nas seguintes condições:

  1. se já existirem índices com um conjunto semelhante de campos
  2. se os campos na tabela não puderem ser indexados devido a restrições de indexação (mais sobre isso é descrito aqui ).

2) Mesclando atributos em um novo atributo


Às vezes, alguns campos da mesma tabela pelos quais ocorre um grupo de condições podem ser substituídos pela introdução de um novo campo.

Isso é especialmente verdadeiro para os campos de estado, que por tipo são geralmente bit a bit ou número inteiro.

Exemplo:

IsClosed = 0 AND Canceled = 0 AND Enabled = 0 é substituído por Status = 1 .

Aqui, você insere o atributo inteiro Status, que é fornecido preenchendo esses status na tabela. O próximo passo é indexar esse novo atributo.

Essa é uma solução fundamental para o problema de desempenho, porque estamos solicitando dados sem cálculos desnecessários.

3) Materialização da submissão


Infelizmente, as consultas LINQ não podem usar diretamente tabelas temporárias, CTEs e variáveis ​​de tabela.

No entanto, há outra maneira de otimizar para este caso - são as visualizações indexadas.

Um grupo de condições (do exemplo acima) IsClosed = 0 AND Canceled = 0 AND Enabled = 0 (ou um conjunto de outras condições semelhantes) se torna uma boa opção para usá-las em uma exibição indexada, armazenando em cache uma pequena fatia de dados de um conjunto grande.

Mas há várias limitações ao materializar uma exibição:

  1. usando subconsultas, as cláusulas EXISTS devem ser substituídas usando JOIN
  2. Não é possível usar as cláusulas UNION , UNION ALL , EXCEPTION , INTERSECT
  3. você não pode usar dicas de tabela e cláusulas OPTION
  4. sem capacidade de trabalhar com ciclos
  5. é impossível exibir dados em uma visualização de tabelas diferentes

É importante lembrar que os benefícios reais do uso de uma exibição indexada podem ser obtidos apenas pela indexação.

Mas, ao invocar uma visualização, esses índices não podem ser usados ​​e, para usá-los explicitamente, você deve especificar WITH (NOEXPAND) .

Como é impossível definir dicas de tabela em consultas LINQ , você deve fazer outra representação - um "invólucro" do seguinte formato:

CREATE VIEW _ AS SELECT * FROM MAT_VIEW WITH (NOEXPAND);

4) Usando funções de tabela


Geralmente, em consultas LINQ, grandes blocos de subconsulta ou blocos que usam representações com uma estrutura complexa formam a consulta final com uma estrutura de execução muito complexa e não ideal.

Principais benefícios do uso de funções de tabela em consultas LINQ :

  1. A capacidade, como no caso de visualizações, de usar e especificar como um objeto, mas você pode passar um conjunto de parâmetros de entrada:
    FROM FUNCTION (@ param1, @ param2 ...)
    no final, pode obter uma amostragem de dados flexível
  2. Ao usar uma função de tabela, não existem restrições tão fortes como no caso das visualizações indexadas descritas acima:

    1. :
      LINQ .
      .
      ,
    2. , , :

      • ( )
      • UNION EXISTS

  3. OPTION , OPTION(MAXDOP N), . :

    • OPTION (RECOMPILE)
    • , OPTION (FORCE ORDER)

    OPTION .
  4. :
    ( ), .
    , , WHERE (a, b, c).

    a = 0 and b = 0.

    , c .

    a = 0 and b = 0 , .

    Aqui, a função de tabela pode ser uma opção melhor.

    Além disso, a função de tabela é mais previsível e constante no tempo de execução.

Exemplos


Vamos considerar um exemplo de implementação usando o exemplo do banco de dados de perguntas.

Há uma consulta SELECT que combina várias tabelas e usa uma visualização (OperativeQuestions), que verifica por email a afiliação (via EXISTS ) de "Consultas ativas" ([OperativeQuestions]):

Solicitação nº 1
(@p__linq__0 nvarchar(4000))SELECT
1 AS [C1],
[Extent1].[Id] AS [Id],
[Join2].[Object_Id] AS [Object_Id],
[Join2].[ObjectType_Id] AS [ObjectType_Id],
[Join2].[Name] AS [Name],
[Join2].[ExternalId] AS [ExternalId]
FROM [dbo].[Questions] AS [Extent1]
INNER JOIN (SELECT [Extent2].[Object_Id] AS [Object_Id],
[Extent2].[Question_Id] AS [Question_Id], [Extent3].[ExternalId] AS [ExternalId],
[Extent3].[ObjectType_Id] AS [ObjectType_Id], [Extent4].[Name] AS [Name]
FROM [dbo].[ObjectQuestions] AS [Extent2]
INNER JOIN [dbo].[Objects] AS [Extent3] ON [Extent2].[Object_Id] = [Extent3].[Id]
LEFT OUTER JOIN [dbo].[ObjectTypes] AS [Extent4] 
ON [Extent3].[ObjectType_Id] = [Extent4].[Id] ) AS [Join2] 
ON [Extent1].[Id] = [Join2].[Question_Id]
WHERE ([Extent1].[AnswerId] IS NULL) AND (0 = [Extent1].[Exp]) AND ( EXISTS (SELECT
1 AS [C1]
FROM [dbo].[OperativeQuestions] AS [Extent5]
WHERE (([Extent5].[Email] = @p__linq__0) OR (([Extent5].[Email] IS NULL) 
AND (@p__linq__0 IS NULL))) AND ([Extent5].[Id] = [Extent1].[Id])
));


A visualização possui uma estrutura bastante complicada: possui junções de subconsulta e o uso da classificação DISTINCT , que geralmente é uma operação que consome muitos recursos.

Uma seleção de cerca de dez mil registros de OperativeQuestions.

O principal problema dessa consulta é que, para registros de uma consulta externa, é realizada uma subconsulta interna na visualização [OperativeQuestions], que deve limitar a amostra de saída (via EXISTS ) a centenas de registros para [Email] = @ p__linq__0 .

E pode parecer que a subconsulta calcule os registros uma vez por [Email] = @ p__linq__0 e, em seguida, essas duas centenas de registros deverão ser conectadas pelas Id Id Questions, e a consulta será rápida.

De fato, todas as tabelas são conectadas em série: as perguntas de identificação e a identificação de OperativeQuestions são verificadas quanto à conformidade e o email é filtrado.

De fato, a solicitação funciona com todas as dezenas de milhares de registros OperativeQuestions e você só precisa de dados de interesse no Email.

OperativeQuestions exibir texto:

Solicitação nº 2
 
CREATE VIEW [dbo].[OperativeQuestions]
AS
SELECT DISTINCT Q.Id, USR.email AS Email
FROM            [dbo].Questions AS Q INNER JOIN
                         [dbo].ProcessUserAccesses AS BPU ON BPU.ProcessId = CQ.Process_Id 
OUTER APPLY
                     (SELECT   1 AS HasNoObjects
                      WHERE   NOT EXISTS
                                    (SELECT   1
                                     FROM     [dbo].ObjectUserAccesses AS BOU
                                     WHERE   BOU.ProcessUserAccessId = BPU.[Id] AND BOU.[To] IS NULL)
) AS BO INNER JOIN
                         [dbo].Users AS USR ON USR.Id = BPU.UserId
WHERE        CQ.[Exp] = 0 AND CQ.AnswerId IS NULL AND BPU.[To] IS NULL 
AND (BO.HasNoObjects = 1 OR
              EXISTS (SELECT   1
                           FROM   [dbo].ObjectUserAccesses AS BOU INNER JOIN
                                      [dbo].ObjectQuestions AS QBO 
                                                  ON QBO.[Object_Id] =BOU.ObjectId
                               WHERE  BOU.ProcessUserAccessId = BPU.Id 
                               AND BOU.[To] IS NULL AND QBO.Question_Id = CQ.Id));


Representação de mapeamento original no DbContext (EF Core 2)
public class QuestionsDbContext : DbContext
{
    //...
    public DbQuery<OperativeQuestion> OperativeQuestions { get; set; }
    //...
    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.Query<OperativeQuestion>().ToView("OperativeQuestions");
    }
}


Consulta LINQ original
var businessObjectsData = await context
    .OperativeQuestions
    .Where(x => x.Email == Email)
    .Include(x => x.Question)
    .Select(x => x.Question)
    .SelectMany(x => x.ObjectQuestions,
                (x, bo) => new
                {
                    Id = x.Id,
                    ObjectId = bo.Object.Id,
                    ObjectTypeId = bo.Object.ObjectType.Id,
                    ObjectTypeName = bo.Object.ObjectType.Name,
                    ObjectExternalId = bo.Object.ExternalId
                })
    .ToListAsync();


Nesse caso em particular, uma solução para esse problema é considerada sem alterações na infraestrutura, sem a introdução de uma tabela separada com resultados prontos ("Consultas ativas"), para a qual seria necessário um mecanismo para preencher seus dados e mantê-los atualizados.

Embora essa seja uma boa solução, há outra opção para otimizar essa tarefa.

O objetivo principal é armazenar em cache as entradas por [Email] = @ p__linq__0 na visualização OperativeQuestions.

Entramos na função de tabela [dbo]. [OperativeQuestionsUserMail] no banco de dados.

Enviando email como parâmetro de entrada, retornamos a tabela de valores:

Solicitação nº 3

CREATE FUNCTION [dbo].[OperativeQuestionsUserMail]
(
    @Email  nvarchar(4000)
)
RETURNS
@tbl TABLE
(
    [Id]           uniqueidentifier,
    [Email]      nvarchar(4000)
)
AS
BEGIN
        INSERT INTO @tbl ([Id], [Email])
        SELECT Id, @Email
        FROM [OperativeQuestions]  AS [x] WHERE [x].[Email] = @Email;
     
    RETURN;
END


Isso retorna uma tabela de valores com uma estrutura de dados predefinida.

Para que as consultas para OperativeQuestionsUserMail sejam ideais, tenham planos de consulta ideais, é necessária uma estrutura rigorosa, e não RETURNS TABLE AS RETURN ...

Nesse caso, a Solicitação 1 desejada é convertida na Solicitação 4:

Solicitação nº 4
(@p__linq__0 nvarchar(4000))SELECT
1 AS [C1],
[Extent1].[Id] AS [Id],
[Join2].[Object_Id] AS [Object_Id],
[Join2].[ObjectType_Id] AS [ObjectType_Id],
[Join2].[Name] AS [Name],
[Join2].[ExternalId] AS [ExternalId]
FROM (
    SELECT Id, Email FROM [dbo].[OperativeQuestionsUserMail] (@p__linq__0)
) AS [Extent0]
INNER JOIN [dbo].[Questions] AS [Extent1] ON([Extent0].Id=[Extent1].Id)
INNER JOIN (SELECT [Extent2].[Object_Id] AS [Object_Id], [Extent2].[Question_Id] AS [Question_Id], [Extent3].[ExternalId] AS [ExternalId], [Extent3].[ObjectType_Id] AS [ObjectType_Id], [Extent4].[Name] AS [Name]
FROM [dbo].[ObjectQuestions] AS [Extent2]
INNER JOIN [dbo].[Objects] AS [Extent3] ON [Extent2].[Object_Id] = [Extent3].[Id]
LEFT OUTER JOIN [dbo].[ObjectTypes] AS [Extent4] 
ON [Extent3].[ObjectType_Id] = [Extent4].[Id] ) AS [Join2] 
ON [Extent1].[Id] = [Join2].[Question_Id]
WHERE ([Extent1].[AnswerId] IS NULL) AND (0 = [Extent1].[Exp]);


Mapeando visões e funções no DbContext (EF Core 2)
public class QuestionsDbContext : DbContext
{
    //...
    public DbQuery<OperativeQuestion> OperativeQuestions { get; set; }
    //...
    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.Query<OperativeQuestion>().ToView("OperativeQuestions");
    }
}
 
public static class FromSqlQueries
{
    public static IQueryable<OperativeQuestion> GetByUserEmail(this DbQuery<OperativeQuestion> source, string Email)
        => source.FromSql($"SELECT Id, Email FROM [dbo].[OperativeQuestionsUserMail] ({Email})");
}


Consulta LINQ final
var businessObjectsData = await context
    .OperativeQuestions
    .GetByUserEmail(Email)
    .Include(x => x.Question)
    .Select(x => x.Question)
    .SelectMany(x => x.ObjectQuestions,
                (x, bo) => new
                {
                    Id = x.Id,
                    ObjectId = bo.Object.Id,
                    ObjectTypeId = bo.Object.ObjectType.Id,
                    ObjectTypeName = bo.Object.ObjectType.Name,
                    ObjectExternalId = bo.Object.ExternalId
                })
    .ToListAsync();


A ordem do tempo de execução diminuiu de 200-800 ms, para 2-20 ms. Etc., ou seja, dez vezes mais rápido.

Se levarmos mais em média, em vez de 350 ms, temos 8 ms.

Das vantagens óbvias, também obtemos:

  1. redução geral na carga de leitura,
  2. Probabilidade de bloqueio significativamente reduzida
  3. redução do tempo médio de bloqueio para valores aceitáveis

Conclusão


A otimização e o ajuste fino de chamadas para o banco de dados MS SQL por meio do LINQ é um problema que pode ser resolvido.

Neste trabalho, cuidado e consistência são muito importantes.

No início do processo:

  1. é necessário verificar os dados com os quais a consulta funciona (valores, tipos de dados selecionados)
  2. indexar corretamente esses dados
  3. verifique a correção das condições de conexão entre as tabelas

Na próxima iteração, a otimização revela:

  1. a base da solicitação e o filtro principal da solicitação são definidos
  2. repetindo blocos de consulta semelhantes e condições de interseção
  3. no SSMS ou outra GUI do SQL Server , a própria consulta SQL é otimizada (alocação de um repositório de dados intermediário, criando a consulta resultante usando esse repositório (pode haver vários))
  4. No último estágio, tendo como base a consulta SQL resultante , a estrutura da consulta LINQ é reconstruída

Como resultado, a consulta LINQ resultante deve se tornar idêntica em estrutura à consulta SQL ideal identificada do parágrafo 3.

Agradecimentos


Muito obrigado aos colegas jobgemws e alex_ozrda Fortis por ajudar com este artigo.

All Articles