Qu'est-ce qui cause une utilisation élevée du processeur à partir de ce plan de requête / exécution?

9

J'ai une base de données Azure SQL qui alimente une application API .NET Core. La navigation dans les rapports de présentation des performances dans le portail Azure suggère que la majorité de la charge (utilisation des DTU) sur mon serveur de base de données provient du processeur, et une requête en particulier:

entrez la description de l'image ici

Comme nous pouvons le voir, la requête 3780 est responsable de la quasi-totalité de l'utilisation du processeur sur le serveur.

Cela a un certain sens, car la requête 3780 (voir ci-dessous) est essentiellement le nœud de l'application et est appelée assez souvent par les utilisateurs. Il s'agit également d'une requête assez complexe avec de nombreuses jointures nécessaires pour obtenir le jeu de données approprié. La requête provient d'un sproc qui finit par ressembler à ceci:

-- @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
        )
)

Si vous vous en souciez, la source complète de cette base de données peut être trouvée sur GitHub ici . Sources de la requête ci-dessus:

J'ai passé du temps sur cette requête au cours des mois à ajuster le plan d'exécution du mieux que je sais, pour finir avec son état actuel. Les requêtes avec ce plan d'exécution sont rapides sur des millions de lignes (<1 sec), mais comme indiqué ci-dessus, consomment de plus en plus de CPU du serveur à mesure que l'application grandit.

J'ai joint le plan de requête réel ci-dessous (je ne suis pas sûr d'une autre façon de partager cela ici sur l'échange de pile), qui montre une exécution du sproc en production contre un ensemble de données renvoyé de ~ 400 résultats.

Quelques points sur lesquels je souhaite des éclaircissements:

  • Index Seek on [IX_Cipher_UserId_Type_IncludeAll]prend 57% du coût total du plan. Ma compréhension du plan est que ce coût est lié aux E / S, ce qui fait que la table Cipher contient des millions d'enregistrements. Cependant, les rapports de performances d'Azure SQL me montrent que mes problèmes proviennent du processeur de cette requête, pas des E / S, donc je ne sais pas si c'est réellement un problème ou non. De plus, il effectue déjà une recherche d'index ici, donc je ne suis pas vraiment sûr qu'il y ait une marge d'amélioration.

  • Les opérations Hash Match de toutes les jointures semblent être ce qui montre une utilisation significative du processeur dans le plan (je pense?), Mais je ne suis pas vraiment sûr de savoir comment cela pourrait être amélioré. La nature complexe de la façon dont j'ai besoin d'obtenir les données nécessite de nombreuses jointures sur plusieurs tables. Je court-circuite déjà plusieurs de ces jointures si possible (sur la base des résultats d'une jointure précédente) dans leurs ONclauses.

Téléchargez le plan d'exécution complet ici: https://www.dropbox.com/s/lua1awsc0uz1lo9/CipherDetails_ReadByUserId.sqlplan?dl=0

Je sens que je peux obtenir de meilleures performances CPU de cette requête, mais je suis à un stade où je ne suis pas sûr de savoir comment continuer le réglage du plan d'exécution. Quelles autres optimisations pourraient être nécessaires pour réduire la charge du processeur? Quelles opérations du plan d'exécution sont les pires contrevenants à l'utilisation du processeur?

kspearrin
la source

Réponses:

4

Vous pouvez afficher le processeur de niveau opérateur et les métriques de temps écoulé dans SQL Server Management Studio, bien que je ne puisse pas dire à quel point ils sont fiables pour les requêtes qui se terminent aussi rapidement que la vôtre. Votre plan ne comporte que des opérateurs en mode ligne, de sorte que les mesures de temps s'appliquent à cet opérateur ainsi qu'aux opérateurs de la sous-arborescence en dessous. En utilisant la jointure de boucle imbriquée à titre d'exemple, SQL Server vous indique que la sous-arborescence entière a pris 60 ms de temps CPU et 80 ms de temps écoulé:

coûts de sous-arbre

La plupart de ce temps de sous-arbre est consacré à la recherche d'index. L'index cherche également à prendre le CPU. Il semble que votre index ait exactement les colonnes nécessaires, il n'est donc pas clair comment vous pourriez réduire les coûts de processeur de cet opérateur. À part les recherches, la plupart du temps CPU du plan est consacré aux correspondances de hachage qui implémentent vos jointures.

Il s'agit d'une énorme simplification, mais le processeur utilisé par ces jointures de hachage dépendra de la taille de l'entrée pour la table de hachage et du nombre de lignes traitées du côté sonde. Observant quelques éléments à propos de ce plan de requête:

  • Au plus, 461 lignes retournées l'ont été C.[UserId] = @UserId. Ces lignes ne se soucient pas du tout des jointures.
  • Pour les lignes qui nécessitent les jointures, SQL Server n'est pas en mesure d'appliquer le filtrage tôt (sauf pour OU.[UserId] = @UserId).
  • Presque toutes les lignes traitées sont éliminées vers la fin du plan de requête (lecture de droite à gauche) par le filtre: [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)

Il serait plus naturel d'écrire votre requête en tant que UNION ALL. La première moitié du UNION ALLpeut inclure des lignes où C.[UserId] = @UserIdet la seconde moitié peut inclure des lignes où C.[UserId] IS NULL. Vous effectuez déjà deux recherches d'index [dbo].[Cipher](une pour @UserIdet une pour NULL), il semble donc peu probable que la UNION ALLversion soit plus lente. L'écriture séparée des requêtes vous permettra d'effectuer une partie du filtrage tôt, à la fois du côté de la construction et du côté de la sonde. Les requêtes peuvent être plus rapides si elles doivent traiter moins de données intermédiaires.

Je ne sais pas si votre version de SQL Server prend en charge cela, mais si cela ne vous aide pas, vous pouvez essayer d'ajouter un index columnstore à votre requête pour rendre vos jointures de hachage éligibles pour le mode batch . Ma façon préférée est de créer une table vide avec une CCI dessus et de joindre la gauche à cette table. Les jointures de hachage peuvent être beaucoup plus efficaces lorsqu'elles s'exécutent en mode batch par rapport au mode ligne.

Joe Obbish
la source
Comme suggéré, j'ai pu réécrire le sproc avec 2 requêtes UNION ALL(une pour C.[UserId] = @UserIdet une pour C.[UserId] IS NULL AND ...). Cela a réduit les ensembles de résultats de jointure et supprimé le besoin de correspondances de hachage (faisant désormais des boucles imbriquées sur les petits ensembles de jointures). La requête est désormais bien meilleure sur CPU. Je vous remercie!
kspearrin
0

Réponse du wiki communautaire :

Vous pouvez essayer de diviser cela en deux requêtes et de les UNION ALLré-assembler.

Votre WHEREclause se produit tout à la fin, mais si vous la divisez en:

  • Une requête où C.[UserId] = @UserId
  • Un autre où C.[UserId] IS NULL AND OU.[Status] = 2 AND O.[Enabled] = 1

... chacun pourrait avoir un plan suffisamment bon pour que cela en vaille la peine.

Si chaque requête applique le prédicat au début du plan, vous n'auriez pas à joindre autant de lignes qui sont finalement filtrées.

utilisateur126897
la source