Pourquoi la sélection de toutes les colonnes résultantes de cette requête est-elle plus rapide que la sélection de la colonne qui m'intéresse?

13

J'ai une requête dans laquelle l'utilisation select *non seulement fait beaucoup moins de lectures, mais utilise également beaucoup moins de temps processeur que l'utilisation select c.Foo.

Voici la requête:

select top 1000 c.ID
from ATable a
    join BTable b on b.OrderKey = a.OrderKey and b.ClientId = a.ClientId
    join CTable c on c.OrderId = b.OrderId and c.ShipKey = a.ShipKey
where (a.NextAnalysisDate is null or a.NextAnalysisDate < @dateCutOff)
    and b.IsVoided = 0
    and c.ComplianceStatus in (3, 5)
    and c.ShipmentStatus in (1, 5, 6)
order by a.LastAnalyzedDate

Cela s'est terminé avec 2 473 658 lectures logiques, principalement dans le tableau B. Il a utilisé 26 562 CPU et avait une durée de 7 965.

Voici le plan de requête généré:

Plan à partir de la sélection de la valeur d'une seule colonne Sur PasteThePlan: https://www.brentozar.com/pastetheplan/?id=BJAp2mQIQ

Lorsque je passe c.IDà *, la requête s'est terminée avec 107 049 lectures logiques, réparties assez uniformément entre les trois tables. Il utilisait 4 266 CPU et avait une durée de 1 147.

Voici le plan de requête généré:

Plan à partir de la sélection de toutes les valeurs Sur PasteThePlan: https://www.brentozar.com/pastetheplan/?id=SyZYn7QUQ

J'ai tenté d'utiliser les indices de requête suggérés par Joe Obbish, avec ces résultats:
select c.IDsans indice: https://www.brentozar.com/pastetheplan/?id=SJfBdOELm
select c.ID avec indice: https://www.brentozar.com/pastetheplan/ ? id = B1W ___ N87
select * sans indice: https://www.brentozar.com/pastetheplan/?id=HJ6qddEIm
select * avec indice: https://www.brentozar.com/pastetheplan/?id=rJhhudNIQ

L'utilisation de l' OPTION(LOOP JOIN)indice avec select c.IDa considérablement réduit le nombre de lectures par rapport à la version sans indice, mais il fait toujours environ 4x le nombre de lectures de la select *requête sans aucun indice. L'ajout OPTION(RECOMPILE, HASH JOIN)à la select *requête l'a rendu bien pire que tout ce que j'ai essayé.

Après la mise à jour des statistiques sur les tables et leurs index à l'aide WITH FULLSCAN, la select c.IDrequête s'exécute beaucoup plus rapidement:
select c.IDavant la mise à jour: https://www.brentozar.com/pastetheplan/?id=SkiYoOEUm
select * avant la mise à jour: https://www.brentozar.com/ pastetheplan /? id = ryrvodEUX
select c.ID après la mise à jour: https://www.brentozar.com/pastetheplan/?id=B1MRoO487
select * après la mise à jour: https://www.brentozar.com/pastetheplan/?id=Hk7si_V8m

select *surpasse toujours select c.IDen termes de durée totale et de lectures totales ( select *a environ la moitié des lectures) mais il utilise plus de CPU. Dans l'ensemble, ils sont beaucoup plus proches qu'avant la mise à jour, mais les plans diffèrent toujours.

Le même comportement est observé sur 2016 en cours d'exécution en 2014 Mode de compatibilité et sur 2014. Qu'est-ce qui pourrait expliquer la disparité entre les deux plans? Se pourrait-il que les index "corrects" n'aient pas été créés? Des statistiques légèrement dépassées peuvent-elles en être la cause?

J'ai essayé de déplacer les prédicats vers la ONpartie de la jointure, de plusieurs manières, mais le plan de requête est le même à chaque fois.

Après la reconstruction d'index

J'ai reconstruit tous les index des trois tables impliquées dans la requête. c.IDfait toujours le plus de lectures (plus de deux fois plus *), mais l'utilisation du processeur représente environ la moitié de la *version. La c.IDaussi la version déversée dans tempdb sur le tri des ATable:
c.ID: https://www.brentozar.com/pastetheplan/?id=HyHIeDO87
* : https://www.brentozar.com/pastetheplan/?id=rJ4deDOIQ

J'ai également essayé de le forcer à fonctionner sans parallélisme, ce qui m'a donné la requête la plus performante: https://www.brentozar.com/pastetheplan/?id=SJn9-vuLX

Je remarque le nombre d'exécutions d'opérateurs APRÈS la recherche d'index volumineux qui effectue l'ordre seulement 1000 fois dans la version à un seul thread, mais a fait beaucoup plus dans la version parallélisée, entre 2622 et 4315 exécutions de divers opérateurs.

L. Miller
la source

Réponses:

4

Il est vrai que la sélection de plusieurs colonnes implique que SQL Server peut avoir besoin de travailler plus dur pour obtenir les résultats demandés de la requête. Si l'optimiseur de requêtes était en mesure de proposer le plan de requête parfait pour les deux requêtes, il serait raisonnable de s'attendre à ce queSELECT *pour exécuter plus longtemps que la requête qui sélectionne toutes les colonnes de toutes les tables. Vous avez observé le contraire pour votre paire de requêtes. Vous devez être prudent lorsque vous comparez les coûts, mais la requête lente a un coût total estimé de 1090,08 unités d'optimisation et la requête rapide a un coût total estimé de 6823,11 unités d'optimisation. Dans ce cas, on pourrait dire que l'optimiseur fait un mauvais travail avec l'estimation des coûts totaux de requête. Il a choisi un plan différent pour votre requête SELECT * et il s'attendait à ce que ce plan soit plus cher, mais ce n'était pas le cas ici. Ce type de décalage peut se produire pour de nombreuses raisons et l'une des causes les plus courantes est les problèmes d'estimation de la cardinalité. Les coûts d'exploitation sont largement déterminés par les estimations de cardinalité. Si une estimation de cardinalité à un point clé d'un plan est inexacte, le coût total du plan peut ne pas refléter la réalité. C'est une simplification grossière mais j'espère que cela sera utile pour comprendre ce qui se passe ici.

Commençons par expliquer pourquoi une SELECT *requête peut être plus coûteuse que la sélection d'une seule colonne. La SELECT *requête peut transformer certains index de couverture en index de non-couverture, ce qui peut signifier que l'optimiseur doit effectuer des travaux supplémentaires pour obtenir toutes les colonnes dont il a besoin ou qu'il peut avoir besoin de lire à partir d'un index plus grand.SELECT *peut également entraîner des jeux de résultats intermédiaires plus importants qui doivent être traités pendant l'exécution de la requête. Vous pouvez le voir en action en examinant les tailles de lignes estimées dans les deux requêtes. Dans la requête rapide, la taille de vos lignes varie de 664 octets à 3019 octets. Dans la requête lente, la taille de vos lignes varie de 19 à 36 octets. Les opérateurs de blocage tels que les tris ou les générations de hachage auront des coûts plus élevés pour les données avec une plus grande taille de ligne car SQL Server sait qu'il est plus coûteux de trier de plus grandes quantités de données ou de les transformer en une table de hachage.

En regardant la requête rapide, l'optimiseur estime qu'il doit effectuer 2,4 millions de recherches d'index Database1.Schema1.Object5.Index3. C'est de là que vient la majeure partie du coût du plan. Pourtant, le plan actuel révèle que seules 1332 recherches d'index ont été effectuées sur cet opérateur. Si vous comparez les lignes réelles aux lignes estimées pour les parties externes de ces jointures en boucle, vous verrez de grandes différences. L'optimiseur pense que de nombreuses autres recherches d'index seront nécessaires pour trouver les 1 000 premières lignes nécessaires aux résultats de la requête. C'est pourquoi la requête a un plan de coûts relativement élevé mais se termine si rapidement: l'opérateur qui était censé être le plus cher a fait moins de 0,1% de son travail prévu.

En regardant la requête lente, vous obtenez un plan avec principalement des jointures de hachage (je crois que la jointure en boucle est là juste pour traiter la variable locale). Les estimations de cardinalité ne sont certainement pas parfaites, mais le seul véritable problème d'estimation se situe à la fin du tri. Je soupçonne que la plupart du temps est consacré à l'analyse des tables avec des centaines de millions de lignes.

Il peut être utile d'ajouter des conseils de requête aux deux versions de la requête pour forcer le plan de requête associé à l'autre version. Les conseils de requête peuvent être un bon outil pour comprendre pourquoi l'optimiseur a fait certains de ses choix. Si vous ajoutez OPTION (RECOMPILE, HASH JOIN)à la SELECT *requête, je pense que vous verrez un plan de requête similaire à la requête de jointure de hachage. Je m'attends également à ce que les coûts de requête soient beaucoup plus élevés pour le plan de jointure de hachage, car la taille de vos lignes est beaucoup plus grande. Cela pourrait donc être la raison pour laquelle la requête de jointure de hachage n'a pas été choisie pour la SELECT *requête. Si vous ajoutez OPTION (LOOP JOIN)à la requête qui sélectionne une seule colonne, je pense que vous verrez un plan de requête similaire à celui de laSELECT *requete. Dans ce cas, la réduction de la taille de la ligne ne devrait pas avoir beaucoup d'impact sur le coût global de la requête. Vous pouvez ignorer les recherches clés, mais cela représente un petit pourcentage du coût estimé.

En résumé, je m'attends à ce que les plus grandes tailles de lignes nécessaires pour satisfaire la SELECT *requête poussent l'optimiseur vers un plan de jointure en boucle au lieu d'un plan de jointure de hachage. Le plan de jointure de boucle est plus coûteux qu'il ne devrait l'être en raison de problèmes d'estimation de cardinalité. La réduction de la taille des lignes en sélectionnant une seule colonne réduit considérablement le coût d'un plan de jointure de hachage, mais n'aura probablement pas beaucoup d'effet sur le coût d'un plan de jointure en boucle, vous vous retrouvez donc avec le plan de jointure de hachage moins efficace. Il est difficile d'en dire plus pour un plan anonymisé.

Joe Obbish
la source
Merci beaucoup pour votre réponse expansive et informative. J'ai essayé d'ajouter les conseils que vous avez suggérés. Cela a rendu la select c.IDrequête beaucoup plus rapide, mais il fait toujours un travail supplémentaire que la select *requête, sans conseils, fait.
L. Miller
2

Les statistiques obsolètes peuvent certainement amener l'optimiseur à choisir une mauvaise méthode de recherche des données. Avez-vous essayé de faire un UPDATE STATISTICS ... WITH FULLSCANou de faire un plein REBUILDsur l'index? Essayez cela et voyez si cela vous aide.

MISE À JOUR

Selon une mise à jour du PO:

Après avoir mis à jour les statistiques sur les tables et leurs index à l'aide WITH FULLSCAN, la select c.IDrequête s'exécute beaucoup plus rapidement

Donc, maintenant, si la seule action a été UPDATE STATISTICS, essayez de faire un index REBUILD(pas REORGANIZE) comme je l'ai vu qui aide avec le nombre de lignes estimé où les deux UPDATE STATISTICSet l'index REORGANIZEne l'ont pas fait.

Solomon Rutzky
la source
J'ai pu obtenir tous les index des trois tables impliquées pour reconstruire au cours du week-end, et j'ai mis à jour mon message pour refléter ces résultats.
L. Miller
-1
  1. Pouvez-vous s'il vous plaît inclure les scripts d'index?
  2. Avez-vous éliminé les éventuels problèmes de "reniflage de paramètres"? https://www.mssqltips.com/sqlservertip/3257/different-approaches-to-correct-sql-server-parameter-sniffing/
  3. J'ai trouvé cette technique utile dans certains cas:
    a) réécrire chaque table en tant que sous-requête, en suivant ces règles:
    b) SELECT - mettre les colonnes de jointure en premier
    c) PREDICATES - aller dans leurs sous-requêtes respectives
    d) ORDER BY - aller dans leur sous-requêtes respectives, trier sur JOIN COLUMNS FIRST
    e) Ajoutez une requête wrapper pour votre tri final et SELECT.

L'idée est de pré-trier les colonnes de jointure à l'intérieur de chaque sous-sélection, en plaçant les colonnes de jointure en premier dans chaque liste de sélection.

Voici ce que je veux dire ...

SELECT ... wrapper query
FROM
(
    SELECT ...
    FROM
        (SELECT ClientID, ShipKey, NextAnalysisDate
         FROM ATABLE
         WHERE (a.NextAnalysisDate is null or a.NextAnalysisDate < @dateCutOff) -- Predicates
         ORDER BY OrderKey, ClientID, LastAnalyzedDate  ---- Pre-sort the join columns
        ) as a
        JOIN 
        (SELECT OrderKey, ClientID, OrderID, IsVoided
         FROM BTABLE
         WHERE IsVoided = 0             ---- Include all predicates
         ORDER BY OrderKey, OrderID, IsVoided       ---- Pre-sort the join columns
        ) as b ON b.OrderKey = a.OrderKey and b.ClientId = a.ClientId
        JOIN
        (SELECT OrderID, ShipKey, ComplianceStatus, ShipmentStatus, ID
         FROM CTABLE
         WHERE ComplianceStatus in (3, 5)       ---- Include all predicates
             AND ShipmentStatus in (1, 5, 6)        ---- Include all predicates
         ORDER BY OrderID, ShipKey          ---- Pre-sort the join columns
        ) as c ON c.OrderId = b.OrderId and c.ShipKey = a.ShipKey
) as d
ORDER BY d.LastAnalyzedDate
Victor Di Leo
la source
1
1. J'essaierai d'ajouter des scripts DDL d'index à la publication d'origine, ce qui peut prendre un certain temps pour les "nettoyer". 2. J'ai testé cette possibilité à la fois en effaçant le cache du plan avant l'exécution et en remplaçant le paramètre de liaison par une valeur réelle. 3. J'ai tenté cela, mais ORDER BYn'est pas valide dans une sous-requête sans TOP, FORXML, etc. Je l'ai essayé sans les ORDER BYclauses mais c'était le même plan.
L. Miller