Modifier la requête pour améliorer les estimations des opérateurs

14

J'ai une requête qui s'exécute dans un laps de temps acceptable, mais je veux en tirer le maximum de performances.

L'opération que j'essaie d'améliorer est la "recherche d'index" à droite du plan, à partir du nœud 17.

entrez la description de l'image ici

J'ai ajouté des indices appropriés, mais les estimations que je reçois pour cette opération représentent la moitié de ce qu'elles sont censées être.

J'ai cherché à changer mes index et à ajouter une table temporaire et à réécrire la requête, mais je n'ai pas pu la simplifier davantage pour obtenir les bonnes estimations.

Quelqu'un a-t-il des suggestions sur ce que je peux essayer d'autre?

Le plan complet et ses détails peuvent être trouvés ici .

Le plan non anonymisé peut être trouvé ici.

Mise à jour:

J'ai le sentiment que la version initiale de la question a soulevé beaucoup de confusion, donc je vais ajouter le code original avec quelques explications.

create procedure [dbo].[someProcedure] @asType int, @customAttrValIds idlist readonly
as
begin
    set nocount on;

    declare @dist_ca_id int;

    select *
    into #temp
    from @customAttrValIds
        where id is not null;

    select @dist_ca_id = count(distinct CustomAttrID) 
    from CustomAttributeValues c
        inner join #temp a on c.Id = a.id;

    select a.Id
        , a.AssortmentId 
    from Assortments a
        inner join AssortmentCustomAttributeValues acav
            on a.Id = acav.Assortment_Id
        inner join CustomAttributeValues cav 
            on cav.Id = acav.CustomAttributeValue_Id
    where a.AssortmentType = @asType
        and acav.CustomAttributeValue_Id in (select id from #temp)
    group by a.AssortmentId
        , a.Id
    having count(distinct cav.CustomAttrID) = @dist_ca_id
    option(recompile);

end

Réponses:

  1. Pourquoi la dénomination initiale étrange dans le lien pasteThePlan?

    Réponse : Parce que j'ai utilisé le plan anonymisé de SQL Sentry Plan Explorer.

  2. Pourquoi OPTION RECOMPILE?

    Réponse : Parce que je peux me permettre des recompilations afin d'éviter de renifler les paramètres (les données sont / pourraient être faussées). J'ai testé et je suis satisfait du plan que l'Optimizer génère lors de l'utilisation OPTION RECOMPILE.

  3. WITH SCHEMABINDING?

    Réponse : Je voudrais vraiment éviter cela et ne l'utiliser que lorsque j'aurai une vue indexée. Quoi qu'il en soit, il s'agit d'une fonction système ( COUNT()), donc inutile SCHEMABINDINGici.

Réponses à plus de questions possibles:

  1. Pourquoi est-ce que j'utilise INSERT INTO #temp FROM @customAttrributeValues?

    Réponse : Parce que j'ai remarqué et je sais maintenant que lorsque vous utilisez des variables connectées à une requête, toutes les estimations qui sortent du travail avec une variable sont toujours 1. Et j'ai testé de placer les données dans une table temporaire et l' estimation est alors égale aux lignes réelles .

  2. Pourquoi ai-je utilisé and acav.CustomAttributeValue_Id in (select id from #temp)?

    Réponse : J'aurais pu le remplacer par un JOIN sur #temp, mais les développeurs étaient très confus et ont proposé l' INoption. Je ne pense pas vraiment qu'il y aurait une différence même en remplaçant et de toute façon, cela ne pose aucun problème.

Radu Gheorghiu
la source
Je suppose que la #tempcréation et l'utilisation seraient un problème de performances, pas un gain. Vous enregistrez dans une table non indexée pour être utilisé une seule fois. Essayez de le supprimer complètement (et éventuellement de le changer in (select id from #temp)en existssous-requête.
ypercubeᵀᴹ
@ ypercubeᵀᴹ Vrai, environ quelques pages de moins lues en utilisant la variable au lieu d'une table temporaire.
Radu Gheorghiu
Soit dit en passant, une variable de table fournira la bonne estimation du nombre de lignes lorsqu'elle est utilisée avec l'option (recompilation) - mais n'a toujours pas de statistiques granulaires, de cardinalité, etc.
JE
@TH Eh bien, j'ai regardé dans le plan d'exécution réel les estimations, lors de l'utilisation à la select id from @customAttrValIdsplace de select id from #tempet le nombre estimé de lignes était 1pour la variable et 3pour #temp (qui correspondait au nombre réel de lignes). C'est pourquoi je l'ai remplacé @par #. Et je DOIS me souviens d' un discours (de Brent O ou Aaron Bertrand) où ils ont dit que lorsque vous utilisez une variable TBL les estimations pour ce seront toujours 1. Et comme une amélioration pour obtenir de meilleures estimations qu'ils utiliseraient une table temporaire.
Radu Gheorghiu
@RaduGheorghiu Oui, mais dans le monde de ces gars, l'option (recompiler) est rarement une option, et ils préfèrent également les tables temporaires pour d'autres raisons valides. Peut-être que l'estimation est simplement toujours incorrecte comme 1, car elle change le plan comme on le voit ici: theboreddba.com/Categories/FunWithFlags/…
TH

Réponses:

12

Le plan a été compilé sur une instance SQL Server 2008 R2 RTM (build 10.50.1600). Vous devez installer le Service Pack 3 (build 10.50.6000), suivi des derniers correctifs pour l'amener à la dernière build (actuelle) 10.50.6542. Ceci est important pour un certain nombre de raisons, notamment la sécurité, les corrections de bogues et les nouvelles fonctionnalités.

L'optimisation de l'incorporation des paramètres

Concernant la présente question, SQL Server 2008 R2 RTM ne prend pas en charge l'optimisation d'intégration de paramètres (PEO) pour OPTION (RECOMPILE). À l'heure actuelle, vous payez le coût des recompilations sans réaliser l'un des principaux avantages.

Lorsque PEO est disponible, SQL Server peut utiliser les valeurs littérales stockées dans les variables et paramètres locaux directement dans le plan de requête. Cela peut entraîner des simplifications spectaculaires et des augmentations de performances. Il y a plus d'informations à ce sujet dans mon article, Reniflage de paramètres, Incorporation et Options RECOMPILE .

Hachage, tri et échange de déversements

Ceux-ci ne sont affichés dans les plans d'exécution que lorsque la requête a été compilée sur SQL Server 2012 ou version ultérieure. Dans les versions antérieures, nous devions surveiller les déversements pendant l'exécution de la requête à l'aide du profileur ou des événements étendus. Les déversements entraînent toujours des E / S physiques vers (et à partir de) la mémoire de sauvegarde persistante tempdb , ce qui peut avoir des conséquences importantes sur les performances, en particulier si le déversement est important ou si le chemin d'E / S est sous pression.

Dans votre plan d'exécution, il existe deux opérateurs Hash Match (Aggregate). La mémoire réservée à la table de hachage est basée sur l' estimation des lignes de sortie (en d'autres termes, elle est proportionnelle au nombre de groupes trouvés lors de l'exécution). La mémoire accordée est fixée juste avant le début de l'exécution et ne peut pas augmenter pendant l'exécution, quelle que soit la quantité de mémoire libre de l'instance. Dans le plan fourni, les deux opérateurs Hash Match (Aggregate) produisent plus de lignes que l'optimiseur prévu, et peuvent donc rencontrer un déversement sur tempdb lors de l'exécution.

Il existe également un opérateur Hash Match (Inner Join) dans le plan. La mémoire réservée à la table de hachage est basée sur l' estimation des lignes d'entrée côté sonde . L'entrée de la sonde estime 847 399 lignes, mais 1 223 636 sont rencontrées au moment de l'exécution. Cet excès peut également provoquer un déversement de hachage.

Agrégat redondant

Le Hash Match (Aggregate) au nœud 8 effectue une opération de regroupement (Assortment_Id, CustomAttrID), mais les lignes d'entrée sont égales aux lignes de sortie:

Correspondance de hachage du nœud 8 (agrégat)

Cela suggère que la combinaison de colonnes est une clé (donc le regroupement est sémantiquement inutile). Le coût d'exécution de l'agrégat redondant est augmenté par la nécessité de passer deux fois 1,4 millions de lignes sur les échanges de partitionnement de hachage (les opérateurs de parallélisme de chaque côté).

Étant donné que les colonnes impliquées proviennent de tables différentes, il est plus difficile que d'habitude de communiquer ces informations d'unicité à l'optimiseur, ce qui permet d'éviter l'opération de regroupement redondant et les échanges inutiles.

Distribution inefficace des threads

Comme indiqué dans la réponse de Joe Obbish , l'échange au niveau du nœud 14 utilise le partitionnement de hachage pour répartir les lignes entre les threads. Malheureusement, le petit nombre de lignes et les planificateurs disponibles signifient que les trois lignes se retrouvent sur un seul thread. Le plan apparemment parallèle s'exécute en série (avec surcharge parallèle) jusqu'à l'échange au nœud 9.

Vous pouvez résoudre ce problème (pour obtenir un partage alterné ou un partitionnement de diffusion) en éliminant le tri distinct au nœud 13. La façon la plus simple de le faire est de créer une clé primaire en cluster sur la #temptable et d'effectuer l'opération distincte lors du chargement de la table:

CREATE TABLE #Temp
(
    id integer NOT NULL PRIMARY KEY CLUSTERED
);

INSERT #Temp
(
    id
)
SELECT DISTINCT
    CAV.id
FROM @customAttrValIds AS CAV
WHERE
    CAV.id IS NOT NULL;

Mise en cache des statistiques de table temporaire

Malgré l'utilisation de OPTION (RECOMPILE), SQL Server peut toujours mettre en cache l'objet table temporaire et ses statistiques associées entre les appels de procédure. Il s'agit généralement d'une optimisation des performances bienvenue, mais si la table temporaire est remplie avec une quantité similaire de données sur les appels de procédure adjacents, le plan recompilé peut être basé sur des statistiques incorrectes (mises en cache à partir d'une exécution précédente). Ceci est détaillé dans mes articles, Tables temporaires dans les procédures stockées et Mise en cache des tables temporaires expliquées .

Pour éviter cela, utilisez-le OPTION (RECOMPILE)avec un explicite une UPDATE STATISTICS #TempTablefois la table temporaire remplie et avant qu'elle soit référencée dans une requête.

Réécriture de requête

Cette partie suppose que les modifications apportées à la création de la #Temptable ont déjà été apportées.

Compte tenu des coûts de déversements de hachage possibles et de l'agrégat redondant (et des échanges environnants), il peut être avantageux de matérialiser l'ensemble au nœud 10:

CREATE TABLE #Temp2
(
    CustomAttrID integer NOT NULL,
    Assortment_Id integer NOT NULL,
);

INSERT #Temp2
(
    Assortment_Id,
    CustomAttrID
)
SELECT
    ACAV.Assortment_Id,
    CAV.CustomAttrID
FROM #temp AS T
JOIN dbo.CustomAttributeValues AS CAV
    ON CAV.Id = T.id
JOIN dbo.AssortmentCustomAttributeValues AS ACAV
    ON T.id = ACAV.CustomAttributeValue_Id;

ALTER TABLE #Temp2
ADD CONSTRAINT PK_#Temp2_Assortment_Id_CustomAttrID
PRIMARY KEY CLUSTERED (Assortment_Id, CustomAttrID);

Le PRIMARY KEYest ajouté dans une étape distincte pour garantir que la génération d'index contient des informations de cardinalité précises et pour éviter le problème de mise en cache des statistiques de table temporaire.

Cette matérialisation est susceptible de se produire en mémoire (en évitant les E / S tempdb ) si l'instance dispose de suffisamment de mémoire disponible. Cela est encore plus probable une fois que vous effectuez une mise à niveau vers SQL Server 2012 (SP1 CU10 / SP2 CU1 ou version ultérieure), ce qui a amélioré le comportement d'écriture désirée .

Cette action donne à l'optimiseur des informations de cardinalité précises sur l'ensemble intermédiaire, lui permet de créer des statistiques et nous permet de déclarer (Assortment_Id, CustomAttrID)comme clé.

Le plan pour la population de #Temp2devrait ressembler à ceci (notez l'analyse d'index clusterisé de #Temp, pas de tri distinct, et l'échange utilise maintenant le partitionnement en ligne à tour de rôle):

# Population Temp2

Avec cet ensemble disponible, la requête finale devient:

SELECT
    A.Id,
    A.AssortmentId
FROM
(
    SELECT
        T.Assortment_Id
    FROM #Temp2 AS T
    GROUP BY
        T.Assortment_Id
    HAVING
        COUNT_BIG(DISTINCT T.CustomAttrID) = @dist_ca_id
) AS DT
JOIN dbo.Assortments AS A
    ON A.Id = DT.Assortment_Id
WHERE
    A.AssortmentType = @asType
OPTION (RECOMPILE);

Nous pourrions réécrire manuellement le COUNT_BIG(DISTINCT...comme simple COUNT_BIG(*), mais avec les nouvelles informations clés, l'optimiseur le fait pour nous:

Plan final

Le plan final peut utiliser une jointure boucle / hachage / fusion en fonction des informations statistiques sur les données auxquelles je n'ai pas accès. Une autre petite note: j'ai supposé qu'il CREATE [UNIQUE?] NONCLUSTERED INDEX IX_ ON dbo.Assortments (AssortmentType, Id, AssortmentId);existe un indice comme .

Quoi qu'il en soit, l'important dans les plans finaux est que les estimations devraient être bien meilleures, et la séquence complexe des opérations de regroupement a été réduite à un seul agrégat de flux (qui ne nécessite pas de mémoire et ne peut donc pas se répandre sur le disque).

Il est difficile de dire que les performances seront en fait meilleures dans ce cas avec le tableau temporaire supplémentaire, mais les estimations et les choix de plan seront beaucoup plus résistants aux changements de volume et de distribution des données au fil du temps. Cela peut être plus précieux à long terme qu'une petite augmentation des performances aujourd'hui. Dans tous les cas, vous disposez désormais de bien plus d'informations sur lesquelles baser votre décision finale.

Paul White 9
la source
9

Les estimations de cardinalité de votre requête sont en fait très bonnes. Il est rare que le nombre de lignes estimées corresponde exactement au nombre de lignes réelles, surtout lorsque vous avez autant de jointures. Les estimations de cardinalité de jointure sont difficiles à obtenir pour l'optimiseur. Une chose importante à noter est que le nombre de lignes estimées pour la partie intérieure de la boucle imbriquée est par exécution de cette boucle. Ainsi, lorsque SQL Server indique que 463869 lignes seront extraites avec l'index, la véritable estimation dans ce cas est le nombre d'exécutions (2) * 463869 = 927738, ce qui n'est pas si éloigné du nombre réel de lignes, 1391608. Étonnamment, le nombre de lignes estimées est presque parfait immédiatement après la jointure de la boucle imbriquée au nœud ID 10.

Les estimations de cardinalité médiocres sont généralement un problème lorsque l'optimiseur de requête sélectionne le mauvais plan ou n'accorde pas suffisamment de mémoire au plan. Je ne vois aucun déversement sur tempdb pour ce plan, donc la mémoire semble correcte. Pour la jointure de boucle imbriquée que vous appelez, vous disposez d'une petite table externe et d'une table interne indexée. Qu'est-ce qui ne va pas avec ça? Pour être précis, qu'attendriez-vous que l'optimiseur de requêtes fasse différemment ici?

En termes d'amélioration des performances, ce qui me semble le plus important, c'est que SQL Server utilise un algorithme de hachage pour distribuer des lignes parallèles, ce qui se traduit par le fait qu'elles se trouvent toutes sur le même thread:

déséquilibre des fils

Par conséquent, un thread effectue tout le travail avec la recherche d'index:

déséquilibre de fil chercher

Cela signifie que votre requête ne s'exécute pas en parallèle jusqu'à ce que l'opérateur de répartition des flux à l'ID de nœud 9. Vous souhaitiez probablement un partitionnement à tour de rôle afin que chaque ligne se retrouve sur son propre thread. Cela permettra à deux threads de faire la recherche d'index pour l'ID de nœud 17. L'ajout d'un TOPopérateur superflu peut vous permettre un partitionnement à tour de rôle. Je peux ajouter des détails ici si vous le souhaitez.

Si vous voulez vraiment vous concentrer sur les estimations de cardinalité, vous pouvez placer les lignes après la première jointure dans une table temporaire. Si vous collectez des statistiques sur la table temporaire qui fournissent à l'optimiseur plus d'informations sur la table externe pour la jointure de boucle imbriquée que vous avez appelée. Cela pourrait également entraîner un partitionnement à tour de rôle.

Si vous n'utilisez pas d'indicateurs de trace 4199 ou 2301, vous pouvez les considérer. L'indicateur de trace 4199 offre une grande variété de correctifs d'optimisation, mais ils peuvent dégrader certaines charges de travail. L'indicateur de trace 2301 modifie certaines des hypothèses de cardinalité de jointure de l'optimiseur de requête et le rend plus difficile à travailler. Dans les deux cas, testez soigneusement avant de les activer.

Joe Obbish
la source
-2

Je crois qu'obtenir une meilleure estimation sur cette jointure ne changera pas le plan, à moins que 1,4 mill soit une partie suffisante de la table pour que l'optimiseur choisisse un scan d'index (pas de cluster) avec hachage ou fusion. Je soupçonne que ce ne serait pas le cas ici, ni réellement utile, mais vous pouvez tester les effets en remplaçant la jointure interne contre CustomAttributeValues ​​par la jointure de hachage interne et la jointure de fusion interne .

J'ai également examiné le code de manière plus large et je ne vois aucun moyen de l'améliorer - je serais bien sûr intéressé à me tromper. Et si vous avez envie de publier la logique complète de ce que vous essayez d'accomplir, je serais intéressé par un autre regard.

TH
la source
3
Il y a un très grand espace de plans pour cette requête, avec de nombreuses options pour l'ordre de jointure et l'imbrication, le parallélisme, l'agrégation locale / globale, etc., dont la plupart seraient affectés par les changements dans les statistiques dérivées (distribution ainsi que cardinalité brute) au niveau du nœud de plan 10. Notez également que les conseils de jointure doivent généralement être évités car ils sont fournis avec un silencieux OPTION(FORCE ORDER), ce qui empêche l'optimiseur de réorganiser les jointures à partir de la séquence textuelle, ainsi que de nombreuses autres optimisations.
Paul White 9
-12

Vous n'allez pas vous améliorer à partir d'une recherche d'index [non groupée]. La seule chose meilleure qu'une recherche d'index non cluster est une recherche d'index cluster.

En outre, je suis un administrateur de base de données SQL depuis dix ans et un développeur SQL depuis cinq ans auparavant, et d'après mon expérience, il est extrêmement rare de trouver une amélioration à une requête SQL en étudiant le plan d'exécution que vous ne pouviez pas. t trouver par d'autres moyens. La principale raison de générer le plan d'exécution est qu'il vous suggérera souvent des index manquants que vous pouvez ajouter pour améliorer les performances.

Les principaux gains de performances seront l'ajustement de la requête SQL elle-même, en cas d'inefficacité. Par exemple, il y a quelques mois, j'ai obtenu une fonction SQL pour exécuter 160 fois plus rapide en réécrivant un SELECT UNION SELECTtableau croisé dynamique de style pour utiliser l' PIVOTopérateur SQL standard .

insert into Variable1 values (?), (?), (?)


select *
    into Object1
    from Variable2
        where Column1 is not null;



select Variable3 = Function1(distinct Column2) 
    from Object2 Object3
        inner join Object1 Object4 on Object3.Column1 = Object4.Column1;



select Object4.Column1
        , Object4.Column3 
    from Object5 Object4
        inner join Object6 Object7
            on Object4.Column1 = Object7.Column4
        inner join Object2 Object8 
            on Object8.Column1 = Object7.Column5
    where Object4.Column6 = Variable4
        and Object7.Column5 in (select Column1 from Object1)
    group by Object4.Column3
        , Object4.Column1
    having Function1(distinct Object8.Column2) = Variable3
    option(recompile);

Voyons donc, SELECT * INTOest généralement moins efficace qu'une norme INSERT Object1 (column list) SELECT column list. Je réécrirais donc cela. Ensuite, si Function1 a été défini sans WITH SCHEMABINDING, l'ajout d'une WITH SCHEMABINDINGclause devrait lui permettre de s'exécuter plus rapidement.

Vous avez choisi de nombreux alias qui n'ont pas de sens, comme alias Object2 en Object3. Vous devez choisir de meilleurs alias qui ne brouillent pas le code. Vous avez "Object7.Column5 dans (sélectionnez Colonne1 dans Object1)".

INles clauses de cette nature sont toujours plus efficaces écrites comme EXISTS (SELECT 1 FROM Object1 o1 WHERE o1.Column1 = Object7.Column5). J'aurais peut-être dû écrire cela dans l'autre sens. EXISTSsera toujours au moins aussi bon que IN. Ce n'est pas toujours mieux, mais c'est généralement le cas.

En outre, je doute que cela option(recompile)améliore les performances des requêtes ici. Je testerais le retirer.

Matthew Sontum
la source
6
Si une recherche d'index non cluster couvre la requête, cela va presque toujours être mieux qu'une recherche d'index cluster, car par définition, l'index cluster contient toutes les colonnes, et l'index non cluster a moins de colonnes, il faudra donc moins de recherches de page (et moins d'étapes dans l'arborescence b) pour récupérer les données. Il n'est donc pas exact de dire qu'une recherche d'index cluster sera toujours meilleure.
ErikE