L'inclusion de ORDER BY sur une requête qui ne renvoie aucune ligne affecte considérablement les performances

15

Étant donné une simple jointure à trois tables, les performances des requêtes changent radicalement lorsque ORDER BY est inclus, même si aucune ligne n'est renvoyée. Le scénario de problème réel prend 30 secondes pour retourner zéro ligne mais est instantané lorsque ORDER BY n'est pas inclus. Pourquoi?

SELECT * 
FROM tinytable t                          /* one narrow row */
JOIN smalltable s on t.id=s.tinyId        /* one narrow row */
JOIN bigtable b on b.smallGuidId=s.GuidId /* a million narrow rows */
WHERE t.foreignId=3                       /* doesn't match */
ORDER BY b.CreatedUtc          /* try with and without this ORDER BY */

Je comprends que je pourrais avoir un index sur bigtable.smallGuidId, mais, je crois que cela aggraverait en fait dans ce cas.

Voici un script pour créer / remplir les tables à tester. Curieusement, il semble important que smalltable ait un champ nvarchar (max). Il semble également important que je me joigne à la bigtable avec un guid (ce qui donne l'impression d'utiliser la correspondance de hachage).

CREATE TABLE tinytable
  (
     id        INT PRIMARY KEY IDENTITY(1, 1),
     foreignId INT NOT NULL
  )

CREATE TABLE smalltable
  (
     id     INT PRIMARY KEY IDENTITY(1, 1),
     GuidId UNIQUEIDENTIFIER NOT NULL DEFAULT NEWID(),
     tinyId INT NOT NULL,
     Magic  NVARCHAR(max) NOT NULL DEFAULT ''
  )

CREATE TABLE bigtable
  (
     id          INT PRIMARY KEY IDENTITY(1, 1),
     CreatedUtc  DATETIME NOT NULL DEFAULT GETUTCDATE(),
     smallGuidId UNIQUEIDENTIFIER NOT NULL
  )

INSERT tinytable
       (foreignId)
VALUES(7)

INSERT smalltable
       (tinyId)
VALUES(1)

-- make a million rows 
DECLARE @i INT;

SET @i=20;

INSERT bigtable
       (smallGuidId)
SELECT GuidId
FROM   smalltable;

WHILE @i > 0
  BEGIN
      INSERT bigtable
             (smallGuidId)
      SELECT smallGuidId
      FROM   bigtable;

      SET @i=@i - 1;
  END 

J'ai testé sur SQL 2005, 2008 et 2008R2 avec les mêmes résultats.

Hafthor
la source

Réponses:

32

Je suis d'accord avec la réponse de Martin Smith, mais le problème n'est pas simplement d'ordre statistique, exactement. Les statistiques de la colonne foreignId (en supposant que les statistiques automatiques sont activées) montrent avec précision qu'aucune ligne n'existe pour une valeur de 3 (il y en a une seule, avec une valeur de 7):

DBCC SHOW_STATISTICS (tinytable, foreignId) WITH HISTOGRAM

sortie de statistiques

SQL Server sait que les choses peuvent avoir changé depuis la capture des statistiques, il peut donc y avoir une ligne pour la valeur 3 lorsque le plan est exécuté . De plus, il peut s'écouler un certain temps entre la compilation et l'exécution du plan (les plans sont mis en cache pour être réutilisés, après tout). Comme Martin le dit, SQL Server contient une logique pour détecter quand des modifications suffisantes ont été apportées pour justifier la recompilation de tout plan mis en cache pour des raisons d'optimalité.

Cependant, rien de tout cela n'a d'importance. À une exception près du cas de bord, l'optimiseur n'évaluera jamais le nombre de lignes produites par une opération de table à zéro. S'il peut déterminer statiquement que la sortie doit toujours être de zéro ligne, l'opération est redondante et sera supprimée complètement.

Le modèle de l'optimiseur estime à la place au moins une ligne. L'utilisation de cette heuristique tend à produire en moyenne de meilleurs plans que ce ne serait le cas si une estimation inférieure était possible. Un plan qui produit une estimation de zéro ligne à un certain stade serait inutile à partir de ce moment dans le flux de traitement, car il n'y aurait aucune base pour prendre des décisions basées sur les coûts (zéro lignes est zéro lignes, quoi qu'il arrive). Si l'estimation s'avère fausse, la forme du plan au-dessus de l'estimation de la ligne zéro n'a presque aucune chance d'être raisonnable.

Le deuxième facteur est une autre hypothèse de modélisation appelée hypothèse de confinement. Cela signifie essentiellement que si une requête joint une plage de valeurs à une autre plage de valeurs, c'est parce que les plages se chevauchent. Une autre façon de dire cela est de dire que la jointure est spécifiée car les lignes devraient être renvoyées. Sans ce raisonnement, les coûts seraient généralement sous-estimés, ce qui entraînerait de mauvais plans pour un large éventail de requêtes courantes.

Essentiellement, ce que vous avez ici est une requête qui ne correspond pas au modèle de l'optimiseur. Nous ne pouvons rien faire pour «améliorer» les estimations avec des index multi-colonnes ou filtrés; il n'y a aucun moyen d'obtenir une estimation inférieure à 1 ligne ici. Une vraie base de données peut avoir des clés étrangères pour garantir que cette situation ne puisse pas se produire, mais en supposant que cela ne s'applique pas ici, nous nous contentons d'utiliser des astuces pour corriger la condition hors modèle. Un certain nombre d'approches d'indices différentes fonctionneront avec cette requête. OPTION (FORCE ORDER)est celui qui fonctionne bien avec la requête telle qu'elle est écrite.

Paul White 9
la source
21

Le problème fondamental ici est celui des statistiques.

Pour les deux requêtes, le nombre de lignes estimé montre qu'il pense que la finale SELECTretournera 1 048 580 lignes (le même nombre de lignes estimé à exister bigtable) plutôt que le 0 qui s'ensuit.

Vos deux JOINconditions correspondent et conserveraient toutes les lignes. Ils finissent par être éliminés car la ligne unique tinytablene correspond pas au t.foreignId=3prédicat.

Si vous courez

SELECT * 
FROM tinytable t  
WHERE t.foreignId=3  AND id=1 

et regardez le nombre estimé de lignes qu'il est 1plutôt que 0et cette erreur se propage dans tout le plan. tinytablecontient actuellement 1 ligne. Les statistiques ne seraient pas recompilées pour cette table tant que 500 modifications de ligne n'auraient pas eu lieu, de sorte qu'une ligne correspondante pourrait être ajoutée et qu'elle ne déclencherait pas de recompilation.

La raison pour laquelle l'ordre de jointure change lorsque vous ajoutez la ORDER BYclause et qu'il y a une varchar(max)colonne smalltableest parce qu'il estime que les varchar(max)colonnes augmenteront la taille des lignes de 4 000 octets en moyenne. Multipliez cela par 1048580 lignes et cela signifie que l'opération de tri nécessiterait environ 4 Go pour qu'il décide judicieusement de faire l' SORTopération avant le JOIN.

Vous pouvez forcer la ORDER BYrequête à adopter la ORDER BYstratégie de non- jointure en utilisant des astuces comme ci-dessous.

SELECT *
FROM   tinytable t /* one narrow row */
       INNER MERGE JOIN smalltable s /* one narrow row */
                        INNER LOOP JOIN bigtable b
                          ON b.smallGuidId = s.GuidId /* a million narrow rows */
         ON t.id = s.tinyId
WHERE  t.foreignId = 3 /* doesn't match */
ORDER  BY b.CreatedUtc
OPTION (MAXDOP 1) 

Le plan montre un opérateur de tri avec un coût sous-arborescence estimé de presque 12,000et nombre de lignes estimé erroné et la taille estimée des données.

Plan

BTW Je n'ai pas trouvé de remplacer les UNIQUEIDENTIFIERcolonnes par des entiers modifiés dans mon test.

Martin Smith
la source
2

Activez votre bouton Afficher le plan d'exécution et vous pouvez voir ce qui se passe. Voici le plan de la requête "lente": entrez la description de l'image ici

Et voici la requête "rapide": entrez la description de l'image ici

Regardez cela - exécutez ensemble, la première requête est environ 33 fois plus "chère" (ratio 97: 3). SQL optimise la première requête pour ordonner la BigTable par date et heure, puis exécute une petite boucle de «recherche» sur SmallTable et TinyTable, en les exécutant 1 million de fois chacune (vous pouvez survoler l'icône «Clustered Index Seek» pour obtenir plus de statistiques). Ainsi, le tri (27%) et 2 x 1 million de "recherches" sur de petites tables (23% et 46%) constituent l'essentiel de la requête coûteuse. En comparaison, la non- ORDER BYrequête effectue un grand total de 3 analyses.

Fondamentalement, vous avez trouvé un trou dans la logique de l'optimiseur SQL pour votre scénario particulier. Mais comme indiqué par TysHTTP, si vous ajoutez un index (ce qui ralentit votre insertion / met à jour certains), votre numérisation devient rapidement rapide.

jklemmack
la source
2

Ce qui se passe, c'est que SQL décide d'exécuter la commande avant la restriction.

Essaye ça:

SELECT *
(
SELECT * 
FROM tinytable t
    INNER JOIN smalltable s on t.id=s.tinyId
    INNER JOIN bigtable b on b.smallGuidId=s.GuidId
WHERE t.foreignId=3
) X
ORDER BY b.CreatedUtc

Cela vous donne des performances améliorées (dans ce cas où le nombre de résultats renvoyés est très faible), sans que les performances soient réellement atteintes en ajoutant un autre index. Bien qu'il soit étrange que l'optimiseur SQL décide d'exécuter la commande avant la jointure, c'est probablement parce que si vous aviez des données de retour, le tri après les jointures prendrait plus de temps que le tri sans.

Enfin, essayez d'exécuter le script suivant et vérifiez si les statistiques et index mis à jour corrigent le problème que vous rencontrez:

EXEC [sp_MSforeachtable] @command1="RAISERROR('UPDATE STATISTICS(''?'') ...',10,1) WITH NOWAIT UPDATE STATISTICS ? "

EXEC [sp_MSforeachtable] @command1="RAISERROR('DBCC DBREINDEX(''?'') ...',10,1) WITH NOWAIT DBCC DBREINDEX('?')"

EXEC [sp_MSforeachtable] @command1="RAISERROR('UPDATE STATISTICS(''?'') ...',10,1) WITH NOWAIT UPDATE STATISTICS ? "
Seph
la source