O que está causando o alto uso da CPU deste plano de consulta / execução?

9

Eu tenho um banco de dados SQL do Azure que habilita um aplicativo da API do .NET Core. A navegação nos relatórios de visão geral de desempenho no Portal do Azure sugere que a maior parte da carga (uso de DTU) no meu servidor de banco de dados é proveniente da CPU e uma consulta especificamente:

insira a descrição da imagem aqui

Como podemos ver, a consulta 3780 é responsável por quase todo o uso da CPU no servidor.

Isso faz sentido, uma vez que a consulta 3780 (veja abaixo) é basicamente o ponto crucial do aplicativo e é chamada pelos usuários com bastante frequência. Também é uma consulta bastante complexa, com muitas associações necessárias para obter o conjunto de dados adequado. A consulta vem de um sproc que acaba assim:

-- @UserId UNIQUEIDENTIFIER

SELECT
    C.[Id],
    C.[UserId],
    C.[OrganizationId],
    C.[Type],
    C.[Data],
    C.[Attachments],
    C.[CreationDate],
    C.[RevisionDate],
    CASE
        WHEN
            @UserId IS NULL
            OR C.[Favorites] IS NULL
            OR JSON_VALUE(C.[Favorites], CONCAT('$."', @UserId, '"')) IS NULL
        THEN 0
        ELSE 1
    END [Favorite],
    CASE
        WHEN
            @UserId IS NULL
            OR C.[Folders] IS NULL
        THEN NULL
        ELSE TRY_CONVERT(UNIQUEIDENTIFIER, JSON_VALUE(C.[Folders], CONCAT('$."', @UserId, '"')))
    END [FolderId],
    CASE 
        WHEN C.[UserId] IS NOT NULL OR OU.[AccessAll] = 1 OR CU.[ReadOnly] = 0 OR G.[AccessAll] = 1 OR CG.[ReadOnly] = 0 THEN 1
        ELSE 0
    END [Edit],
    CASE 
        WHEN C.[UserId] IS NULL AND O.[UseTotp] = 1 THEN 1
        ELSE 0
    END [OrganizationUseTotp]
FROM
    [dbo].[Cipher] C
LEFT JOIN
    [dbo].[Organization] O ON C.[UserId] IS NULL AND O.[Id] = C.[OrganizationId]
LEFT JOIN
    [dbo].[OrganizationUser] OU ON OU.[OrganizationId] = O.[Id] AND OU.[UserId] = @UserId
LEFT JOIN
    [dbo].[CollectionCipher] CC ON C.[UserId] IS NULL AND OU.[AccessAll] = 0 AND CC.[CipherId] = C.[Id]
LEFT JOIN
    [dbo].[CollectionUser] CU ON CU.[CollectionId] = CC.[CollectionId] AND CU.[OrganizationUserId] = OU.[Id]
LEFT JOIN
    [dbo].[GroupUser] GU ON C.[UserId] IS NULL AND CU.[CollectionId] IS NULL AND OU.[AccessAll] = 0 AND GU.[OrganizationUserId] = OU.[Id]
LEFT JOIN
    [dbo].[Group] G ON G.[Id] = GU.[GroupId]
LEFT JOIN
    [dbo].[CollectionGroup] CG ON G.[AccessAll] = 0 AND CG.[CollectionId] = CC.[CollectionId] AND CG.[GroupId] = GU.[GroupId]
WHERE
    C.[UserId] = @UserId
    OR (
        C.[UserId] IS NULL
        AND OU.[Status] = 2
        AND O.[Enabled] = 1
        AND (
            OU.[AccessAll] = 1
            OR CU.[CollectionId] IS NOT NULL
            OR G.[AccessAll] = 1
            OR CG.[CollectionId] IS NOT NULL
        )
)

Se você se importa, a fonte completa desse banco de dados pode ser encontrada no GitHub aqui . Fontes da consulta acima:

Passei algum tempo nessa consulta ao longo dos meses, ajustando o plano de execução da melhor maneira possível, terminando com seu estado atual. As consultas com este plano de execução são rápidas em milhões de linhas (<1 s), mas, como observado acima, estão consumindo a CPU do servidor cada vez mais à medida que o aplicativo aumenta de tamanho.

Anexei o plano de consulta real abaixo (não tenho certeza de nenhuma outra maneira de compartilhar isso aqui na troca de pilhas), que mostra uma execução do sproc em produção contra um conjunto de dados retornado de ~ 400 resultados.

Estou procurando esclarecimentos sobre alguns pontos:

  • A pesquisa de índice [IX_Cipher_UserId_Type_IncludeAll]leva 57% do custo total do plano. Meu entendimento do plano é que esse custo está relacionado ao IO, o que faz com que a tabela Cipher contenha milhões de registros. No entanto, os relatórios de desempenho SQL do Azure estão me mostrando que meus problemas decorrem da CPU nesta consulta, não de E / S, portanto, não tenho certeza se isso é realmente um problema ou não. Além disso, ele já está fazendo uma busca de índice aqui, então não tenho certeza se há espaço para melhorias.

  • As operações de Hash Match de todas as junções parecem ser o que está mostrando um uso significativo da CPU no plano (eu acho?), Mas não tenho muita certeza de como isso poderia ser melhorado. A natureza complexa de como eu preciso obter os dados exige muitas junções em várias tabelas. Eu já curto-circuito muitas dessas junções, se possível (com base nos resultados de uma junção anterior) em suas ONcláusulas.

Faça o download do plano de execução completo aqui: https://www.dropbox.com/s/lua1awsc0uz1lo9/CipherDetails_ReadByUserId.sqlplan?dl=0

Sinto que posso obter melhor desempenho da CPU com essa consulta, mas estou em um estágio em que não tenho certeza de como prosseguir com o ajuste do plano de execução. Que outras otimizações poderiam ser necessárias para diminuir a carga da CPU? Quais operações no plano de execução são os piores infratores do uso da CPU?

kspearrin
fonte

Respostas:

4

Você pode visualizar as métricas de CPU e tempo decorrido no nível do operador no SQL Server Management Studio, embora eu não possa dizer o quão confiáveis ​​elas são para consultas que terminam tão rapidamente quanto a sua. Seu plano possui apenas operadores no modo de linha, portanto as métricas de tempo se aplicam a esse operador e aos operadores na subárvore abaixo dele. Usando a junção de loop aninhado como exemplo, o SQL Server está lhe dizendo que toda subárvore levou 60 ms de tempo de CPU e 80 ms de tempo decorrido:

custos de subárvore

A maior parte desse tempo é usada na busca do índice. O índice procura levar a CPU também. Parece que seu índice possui exatamente as colunas necessárias, portanto não está claro como você pode reduzir os custos de CPU desse operador. Além das buscas, a maior parte do tempo da CPU no plano é gasta nas correspondências de hash que implementam suas junções.

Essa é uma grande simplificação excessiva, mas a CPU obtida por essas junções de hash dependerá do tamanho da entrada da tabela de hash e do número de linhas processadas no lado da análise. Observando algumas coisas sobre este plano de consulta:

  • No máximo 461 linhas retornadas possuem C.[UserId] = @UserId. Essas linhas não se importam com as junções.
  • Para as linhas que precisam das junções, o SQL Server não pode aplicar nenhuma filtragem antecipadamente (exceto OU.[UserId] = @UserId).
  • Quase todas as linhas processadas são eliminadas no final do plano de consulta (leitura da direita para a esquerda) pelo filtro: [vault].[dbo].[Cipher].[UserId] as [C].[UserId]=[@UserId] OR ([vault].[dbo].[OrganizationUser].[AccessAll] as [OU].[AccessAll]=(1) OR [vault].[dbo].[CollectionUser].[CollectionId] as [CU].[CollectionId] IS NOT NULL OR [vault].[dbo].[Group].[AccessAll] as [G].[AccessAll]=(1) OR [vault].[dbo].[CollectionGroup].[CollectionId] as [CG].[CollectionId] IS NOT NULL) AND [vault].[dbo].[Cipher].[UserId] as [C].[UserId] IS NULL AND [vault].[dbo].[OrganizationUser].[Status] as [OU].[Status]=(2) AND [vault].[dbo].[Organization].[Enabled] as [O].[Enabled]=(1)

Seria mais natural escrever sua consulta como a UNION ALL. A primeira metade do UNION ALLpode incluir linhas onde C.[UserId] = @UserIde a segunda metade pode incluir linhas onde C.[UserId] IS NULL. Você já está fazendo duas pesquisas de índice [dbo].[Cipher](uma para @UserIde uma para NULL), portanto, parece improvável que a UNION ALLversão seja mais lenta. Escrever as consultas separadamente permitirá que você faça parte da filtragem antecipada, tanto no lado da compilação quanto do probe. As consultas podem ser mais rápidas se eles precisarem processar dados menos intermediários.

Não sei se a sua versão do SQL Server suporta isso, mas se isso não ajudar, tente adicionar um índice columnstore à sua consulta para tornar suas associações de hash elegíveis para o modo em lote . Minha maneira preferida é criar uma tabela vazia com um CCI e deixar a junção à esquerda nessa tabela. As junções de hash podem ser muito mais eficientes quando executadas no modo de lote em comparação com o modo de linha.

Joe Obbish
fonte
Como sugerido, fui capaz de reescrever o sproc com 2 consultas que UNION ALL(uma para C.[UserId] = @UserIde uma para C.[UserId] IS NULL AND ...). Isso reduziu os conjuntos de resultados da junção e removeu completamente a necessidade de correspondências de hash (agora fazendo loops aninhados em conjuntos de junções pequenas). A consulta agora é muito melhor na CPU. Obrigado!
Kspearrin
0

Resposta do wiki da comunidade :

Você pode tentar dividir isso em duas consultas e UNION ALLjuntá-las novamente.

Sua WHEREcláusula está acontecendo no final, mas se você a dividir em:

  • Uma consulta em que C.[UserId] = @UserId
  • Outro onde C.[UserId] IS NULL AND OU.[Status] = 2 AND O.[Enabled] = 1

... cada um pode ter um plano bom o suficiente para fazer valer a pena.

Se cada consulta aplicar o predicado no início do plano, você não precisará associar tantas linhas que serão filtradas.

user126897
fonte