Pourquoi une sous-requête réduit-elle l'estimation de ligne à 1?

26

Considérez la requête artificielle mais simple suivante:

SELECT 
  ID
, CASE
    WHEN ID <> 0 
    THEN (SELECT TOP 1 ID FROM X_OTHER_TABLE) 
    ELSE (SELECT TOP 1 ID FROM X_OTHER_TABLE_2) 
  END AS ID2
FROM X_HEAP;

Je m'attendrais à ce que l'estimation finale des lignes pour cette requête soit égale au nombre de lignes de la X_HEAPtable. Tout ce que je fais dans la sous-requête ne devrait pas avoir d'importance pour l'estimation de ligne car elle ne peut filtrer aucune ligne. Cependant, sur SQL Server 2016, je vois l'estimation de ligne réduite à 1 en raison de la sous-requête:

mauvaise requête

Pourquoi cela arrive-t-il? Que puis-je faire à ce sujet?

Il est très facile de reproduire ce problème avec la bonne syntaxe. Voici un ensemble de définitions de table qui le fera:

CREATE TABLE dbo.X_HEAP (ID INT NOT NULL)
CREATE TABLE dbo.X_OTHER_TABLE (ID INT NOT NULL);
CREATE TABLE dbo.X_OTHER_TABLE_2 (ID INT NOT NULL);

INSERT INTO dbo.X_HEAP WITH (TABLOCK)
SELECT TOP (1000) ROW_NUMBER() OVER (ORDER BY (SELECT NULL))
FROM master..spt_values;

CREATE STATISTICS X_HEAP__ID ON X_HEAP (ID) WITH FULLSCAN;

lien de violon db .

Joe Obbish
la source

Réponses:

22

Cette estimation de cardinalité (CE) émet des surfaces lorsque:

  1. La jointure est une externe rejoindre avec un pass-through prédicat
  2. La sélectivité du prédicat d'intercommunication est estimée à exactement 1 .

Remarque: La calculatrice particulière utilisée pour déterminer la sélectivité n'est pas importante.


Détails

Le CE calcule la sélectivité de la jointure externe comme la somme de:

  • La sélectivité de jointure interne avec le même prédicat
  • La sélectivité anti-jointure avec le même prédicat

La seule différence entre une jointure externe et interne est qu'une jointure externe renvoie également des lignes qui ne correspondent pas au prédicat de jointure. L'anti jointure fournit exactement cette différence. L'estimation de la cardinalité pour la jointure interne et anti est plus facile que pour la jointure externe directement.

Le processus d'estimation de la sélectivité de jointure est très simple:

  • Tout d'abord, la sélectivité du prédicat d'intercommunication est évaluée. SPT
    • Cela se fait à l'aide de la calculatrice appropriée aux circonstances.
    • Le prédicat est le tout, y compris tout IsFalseOrNullcomposant négatif .
  • Sélectivité de jointure interne: = 1 - SPT
  • Sélectivité anti-jointure: = SPT

L'anti jointure représente les lignes qui «traverseront» la jointure. La jointure interne représente des lignes qui ne «passeront pas». Notez que «passer à travers» signifie des lignes qui traversent la jointure sans courir du tout du côté intérieur. Pour souligner: toutes les lignes seront retournées par la jointure, la distinction est entre les lignes qui exécutent le côté intérieur de la jointure avant d'émerger et celles qui ne le font pas.

De toute évidence, l'ajout à devrait toujours donner une sélectivité totale de 1, ce qui signifie que toutes les lignes sont renvoyées par la jointure, comme prévu.1 - SPTSPT

En effet, le calcul ci-dessus fonctionne exactement comme décrit pour toutes les valeurs sauf 1 .SPT

Lorsque = 1, les sélectivités de jointure interne et d'anti-jointure sont estimées à zéro, ce qui donne une estimation de cardinalité (pour la jointure dans son ensemble) d'une ligne. Pour autant que je sache, cela n'est pas intentionnel et devrait être signalé comme un bug.SPT


Un problème connexe

Ce bogue est plus susceptible de se manifester qu'on ne le pense, en raison d'une limitation CE distincte. Cela se produit lorsque l' CASEexpression utilise une EXISTSclause (comme cela est courant). Par exemple, la requête modifiée suivante de la question ne rencontre pas l'estimation de cardinalité inattendue:

-- This is fine
SELECT 
    CASE
        WHEN XH.ID = 1
        THEN (SELECT TOP (1) XOT.ID FROM dbo.X_OTHER_TABLE AS XOT) 
    END
FROM dbo.X_HEAP AS XH;

L'introduction d'un élément trivial EXISTSfait apparaître le problème:

-- This is not fine
SELECT 
    CASE
        WHEN EXISTS (SELECT 1 WHERE XH.ID = 1)
        THEN (SELECT TOP (1) XOT.ID FROM dbo.X_OTHER_TABLE AS XOT) 
    END
FROM dbo.X_HEAP AS XH;

L'utilisation EXISTSintroduit une semi jointure (mise en évidence) dans le plan d'exécution:

Plan semi-joint

L'estimation de la semi-jointure est correcte. Le problème est que le CE traite la colonne de sonde associée comme une simple projection, avec une sélectivité fixe de 1:

Semijoin with probe column treated as a Project.

Selectivity of probe column = 1

Cela remplit automatiquement l'une des conditions requises pour que ce problème CE se manifeste, quel que soit le contenu de la EXISTSclause.


Pour des informations générales importantes, voir SousCASE - requêtes dans les expressions par Craig Freedman.

Paul White dit GoFundMonica
la source
22

Cela ressemble définitivement à un comportement involontaire. Il est vrai que les estimations de cardinalité n'ont pas besoin d'être cohérentes à chaque étape d'un plan, mais il s'agit d'un plan de requête relativement simple et l'estimation de cardinalité finale n'est pas cohérente avec ce que fait la requête. Une telle estimation de cardinalité faible pourrait entraîner de mauvais choix pour les types de jointures et les méthodes d'accès pour d'autres tables en aval dans un plan plus compliqué.

Par essais et erreurs, nous pouvons proposer quelques requêtes similaires pour lesquelles le problème n'apparaît pas:

SELECT 
  ID
, CASE
    WHEN ID <> 0 
    THEN (SELECT TOP 1 ID FROM dbo.X_OTHER_TABLE) 
    ELSE (SELECT -1) 
  END AS ID2
FROM dbo.X_HEAP;

SELECT 
  ID
, CASE
    WHEN ID < 500 
    THEN (SELECT TOP 1 ID FROM dbo.X_OTHER_TABLE) 
    WHEN ID >= 500 
    THEN (SELECT TOP 1 ID FROM dbo.X_OTHER_TABLE_2) 
  END AS ID2
FROM dbo.X_HEAP;

Nous pouvons également proposer plus de requêtes pour lesquelles le problème apparaît:

SELECT 
  ID
, CASE
    WHEN ID < 500 
    THEN (SELECT TOP 1 ID FROM dbo.X_OTHER_TABLE) 
    WHEN ID >= 500 
    THEN (SELECT TOP 1 ID FROM dbo.X_OTHER_TABLE_2) 
    ELSE (SELECT TOP 1 ID FROM X_OTHER_TABLE) 
  END AS ID2
FROM dbo.X_HEAP;

SELECT 
  ID
, CASE
    WHEN ID = 0 
    THEN (SELECT TOP 1 ID FROM dbo.X_OTHER_TABLE) 
    ELSE (SELECT -1) 
  END AS ID2
FROM dbo.X_HEAP;

SELECT 
  ID
, CASE
    WHEN ID = 0 
    THEN (SELECT TOP 1 ID FROM dbo.X_OTHER_TABLE) 
    ELSE (SELECT TOP 1 ID FROM dbo.X_OTHER_TABLE_2) 
  END AS ID2
FROM dbo.X_HEAP;

Il semble y avoir un modèle: s'il y a une expression dans le CASEqui ne devrait pas être exécutée et que l'expression résultante est une sous-requête sur une table, alors l'estimation de ligne tombe à 1 après cette expression.

Si j'écris la requête sur une table avec un index clusterisé, les règles changent quelque peu. Nous pouvons utiliser les mêmes données:

CREATE TABLE dbo.X_CI (ID INT NOT NULL, PRIMARY KEY (ID))

INSERT INTO dbo.X_CI WITH (TABLOCK)
SELECT * FROM dbo.X_HEAP;

UPDATE STATISTICS X_CI WITH FULLSCAN;

Cette requête a une estimation finale de 1000 lignes:

SELECT 
  ID
, CASE
    WHEN ID = 0 
    THEN (SELECT TOP 1 ID FROM dbo.X_OTHER_TABLE_2) 
    ELSE (SELECT TOP 1 ID FROM dbo.X_OTHER_TABLE) 
  END
FROM dbo.X_CI;

Mais cette requête a une estimation finale sur 1 ligne:

SELECT 
  ID
, CASE
    WHEN ID <> 0 
    THEN (SELECT TOP 1 ID FROM dbo.X_OTHER_TABLE) 
    ELSE (SELECT TOP 1 ID FROM dbo.X_OTHER_TABLE_2) 
  END
FROM dbo.X_CI;

Pour approfondir cela, nous pouvons utiliser l' indicateur de trace non documenté 2363 pour obtenir des informations sur la façon dont l'optimiseur de requête a effectué des calculs de sélectivité. J'ai trouvé utile de coupler cet indicateur de trace avec l' indicateur de trace non documenté 8606 . TF 2363 semble donner des calculs de sélectivité à la fois pour l'arbre simplifié et l'arbre après normalisation du projet. L'activation des deux indicateurs de trace indique clairement quels calculs s'appliquent à quel arbre.

Essayons-le pour la requête d'origine publiée dans la question:

SELECT 
  ID
, CASE
    WHEN ID <> 0 
    THEN (SELECT TOP 1 ID FROM X_OTHER_TABLE) 
    ELSE (SELECT TOP 1 ID FROM X_OTHER_TABLE_2) 
  END AS ID2
FROM X_HEAP
OPTION (QUERYTRACEON 3604, QUERYTRACEON 2363, QUERYTRACEON 8606);

Voici une partie de la partie de la sortie qui, je pense, est pertinente avec quelques commentaires:

Plan for computation:

  CSelCalcColumnInInterval -- this is the type of calculator used

      Column: QCOL: [SE_DB].[dbo].[X_HEAP].ID -- this is the column used for the calculation

Pass-through selectivity: 0 -- all rows are expected to have a true value for the case expression

Stats collection generated: 

  CStCollOuterJoin(ID=8, CARD=1000 x_jtLeftOuter) -- the row estimate after the join will still be 1000

      CStCollBaseTable(ID=1, CARD=1000 TBL: X_HEAP)

      CStCollBaseTable(ID=2, CARD=1 TBL: X_OTHER_TABLE)

...

Plan for computation:

  CSelCalcColumnInInterval

      Column: QCOL: [SE_DB].[dbo].[X_HEAP].ID

Pass-through selectivity: 1 -- no rows are expected to have a true value for the case expression

Stats collection generated: 

  CStCollOuterJoin(ID=9, CARD=1 x_jtLeftOuter) -- the row estimate after the join will still be 1

      CStCollOuterJoin(ID=8, CARD=1000 x_jtLeftOuter) -- here is the row estimate after the previous join

          CStCollBaseTable(ID=1, CARD=1000 TBL: X_HEAP)

          CStCollBaseTable(ID=2, CARD=1 TBL: X_OTHER_TABLE)

      CStCollBaseTable(ID=3, CARD=1 TBL: X_OTHER_TABLE_2)

Essayons maintenant pour une requête similaire qui n'a pas le problème. Je vais utiliser celui-ci:

SELECT 
  ID
, CASE
    WHEN ID <> 0 
    THEN (SELECT TOP 1 ID FROM dbo.X_OTHER_TABLE) 
    ELSE (SELECT -1) 
  END AS ID2
FROM dbo.X_HEAP
OPTION (QUERYTRACEON 3604, QUERYTRACEON 2363, QUERYTRACEON 8606);

Déboguer la sortie à la toute fin:

Plan for computation:

  CSelCalcColumnInInterval

      Column: QCOL: [SE_DB].[dbo].[X_HEAP].ID

Pass-through selectivity: 1

Stats collection generated: 

  CStCollOuterJoin(ID=9, CARD=1000 x_jtLeftOuter)

      CStCollOuterJoin(ID=8, CARD=1000 x_jtLeftOuter)

          CStCollBaseTable(ID=1, CARD=1000 TBL: dbo.X_HEAP)

          CStCollBaseTable(ID=2, CARD=1 TBL: dbo.X_OTHER_TABLE)

      CStCollConstTable(ID=4, CARD=1) -- this is different than before because we select a constant instead of from a table

Essayons une autre requête pour laquelle la mauvaise estimation de ligne est présente:

SELECT 
  ID
, CASE
    WHEN ID < 500 
    THEN (SELECT TOP 1 ID FROM dbo.X_OTHER_TABLE) 
    WHEN ID >= 500 
    THEN (SELECT TOP 1 ID FROM dbo.X_OTHER_TABLE_2) 
    ELSE (SELECT TOP 1 ID FROM X_OTHER_TABLE) 
  END AS ID2
FROM dbo.X_HEAP
OPTION (QUERYTRACEON 3604, QUERYTRACEON 2363, QUERYTRACEON 8606);

À la toute fin, l'estimation de la cardinalité tombe à 1 ligne, à nouveau après la sélectivité pass-through = 1. L'estimation de la cardinalité est conservée après une sélectivité de 0,501 et 0,499.

Plan for computation:

 CSelCalcColumnInInterval

      Column: QCOL: [SE_DB].[dbo].[X_HEAP].ID

Pass-through selectivity: 0.501

...

Plan for computation:

  CSelCalcColumnInInterval

      Column: QCOL: [SE_DB].[dbo].[X_HEAP].ID

Pass-through selectivity: 0.499

...

Plan for computation:

  CSelCalcColumnInInterval

      Column: QCOL: [SE_DB].[dbo].[X_HEAP].ID

Pass-through selectivity: 1

Stats collection generated: 

  CStCollOuterJoin(ID=12, CARD=1 x_jtLeftOuter) -- this is associated with the ELSE expression

      CStCollOuterJoin(ID=11, CARD=1000 x_jtLeftOuter)

          CStCollOuterJoin(ID=10, CARD=1000 x_jtLeftOuter)

              CStCollBaseTable(ID=1, CARD=1000 TBL: dbo.X_HEAP)

              CStCollBaseTable(ID=2, CARD=1 TBL: dbo.X_OTHER_TABLE)

          CStCollBaseTable(ID=3, CARD=1 TBL: dbo.X_OTHER_TABLE_2)

      CStCollBaseTable(ID=4, CARD=1 TBL: X_OTHER_TABLE)

Passons à nouveau à une autre requête similaire qui n'a pas le problème. Je vais utiliser celui-ci:

SELECT 
  ID
, CASE
    WHEN ID < 500 
    THEN (SELECT TOP 1 ID FROM dbo.X_OTHER_TABLE) 
    WHEN ID >= 500 
    THEN (SELECT TOP 1 ID FROM dbo.X_OTHER_TABLE_2) 
  END AS ID2
FROM dbo.X_HEAP
OPTION (QUERYTRACEON 3604, QUERYTRACEON 2363, QUERYTRACEON 8606);

Dans la sortie de débogage, il n'y a jamais d'étape qui a une sélectivité pass-through de 1. L'estimation de cardinalité reste à 1000 lignes.

Plan for computation:

  CSelCalcColumnInInterval

      Column: QCOL: [SE_DB].[dbo].[X_HEAP].ID

Pass-through selectivity: 0.499

Stats collection generated: 

  CStCollOuterJoin(ID=9, CARD=1000 x_jtLeftOuter)

      CStCollOuterJoin(ID=8, CARD=1000 x_jtLeftOuter)

          CStCollBaseTable(ID=1, CARD=1000 TBL: dbo.X_HEAP)

          CStCollBaseTable(ID=2, CARD=1 TBL: dbo.X_OTHER_TABLE)

      CStCollBaseTable(ID=3, CARD=1 TBL: dbo.X_OTHER_TABLE_2)

End selectivity computation

Qu'en est-il de la requête lorsqu'elle implique une table avec un index clusterisé? Considérez la requête suivante avec le problème d'estimation de ligne:

SELECT 
  ID
, CASE
    WHEN ID <> 0 
    THEN (SELECT TOP 1 ID FROM dbo.X_OTHER_TABLE) 
    ELSE (SELECT TOP 1 ID FROM dbo.X_OTHER_TABLE_2) 
  END
FROM dbo.X_CI
OPTION (QUERYTRACEON 3604, QUERYTRACEON 2363, QUERYTRACEON 8606);

La fin de la sortie de débogage est similaire à ce que nous avons déjà vu:

Plan for computation:

  CSelCalcColumnInInterval

      Column: QCOL: [SE_DB].[dbo].[X_CI].ID

Pass-through selectivity: 1

Stats collection generated: 

  CStCollOuterJoin(ID=9, CARD=1 x_jtLeftOuter)

      CStCollOuterJoin(ID=8, CARD=1000 x_jtLeftOuter)

          CStCollBaseTable(ID=1, CARD=1000 TBL: dbo.X_CI)

          CStCollBaseTable(ID=2, CARD=1 TBL: dbo.X_OTHER_TABLE)

      CStCollBaseTable(ID=3, CARD=1 TBL: dbo.X_OTHER_TABLE_2)

Cependant, la requête sur le CI sans le problème a une sortie différente. En utilisant cette requête:

SELECT 
  ID
, CASE
    WHEN ID = 0 
    THEN (SELECT TOP 1 ID FROM dbo.X_OTHER_TABLE_2) 
    ELSE (SELECT TOP 1 ID FROM dbo.X_OTHER_TABLE) 
  END
FROM dbo.X_CI
OPTION (QUERYTRACEON 3604, QUERYTRACEON 2363, QUERYTRACEON 8606);

Résultats dans l'utilisation de différentes calculatrices. CSelCalcColumnInIntervaln'apparaît plus:

Plan for computation:

  CSelCalcFixedFilter (0.559)

Pass-through selectivity: 0.559

Stats collection generated: 

  CStCollOuterJoin(ID=8, CARD=1000 x_jtLeftOuter)

      CStCollBaseTable(ID=1, CARD=1000 TBL: dbo.X_CI)

      CStCollBaseTable(ID=2, CARD=1 TBL: dbo.X_OTHER_TABLE_2)

...

Plan for computation:

  CSelCalcUniqueKeyFilter

Pass-through selectivity: 0.001

Stats collection generated: 

  CStCollOuterJoin(ID=9, CARD=1000 x_jtLeftOuter)

      CStCollOuterJoin(ID=8, CARD=1000 x_jtLeftOuter)

          CStCollBaseTable(ID=1, CARD=1000 TBL: dbo.X_CI)

          CStCollBaseTable(ID=2, CARD=1 TBL: dbo.X_OTHER_TABLE_2)

      CStCollBaseTable(ID=3, CARD=1 TBL: dbo.X_OTHER_TABLE)

En conclusion, nous semblons obtenir une mauvaise estimation de ligne après la sous-requête dans les conditions suivantes:

  1. Le CSelCalcColumnInIntervalcalculateur de sélectivité est utilisé. Je ne sais pas exactement quand cela est utilisé mais cela semble apparaître beaucoup plus souvent lorsque la table de base est un tas.

  2. Sélectivité pass-through = 1. En d'autres termes, une des CASEexpressions devrait être évaluée à false pour toutes les lignes. Il n'importe pas si la première CASEexpression a la valeur true pour toutes les lignes.

  3. Il existe une jointure externe vers CStCollBaseTable. En d'autres termes, l' CASEexpression de résultat est une sous-requête sur une table. Une valeur constante ne fonctionnera pas.

Dans ces conditions, l'optimiseur de requête applique peut-être involontairement la sélectivité directe à l'estimation de ligne de la table externe au lieu du travail effectué sur la partie interne de la boucle imbriquée. Cela réduirait l'estimation de la ligne à 1.

J'ai pu trouver deux solutions de contournement. Je n'ai pas pu reproduire le problème lors de l'utilisation APPLYau lieu d'une sous-requête. La sortie de l'indicateur de trace 2363 était très différente avec APPLY. Voici une façon de réécrire la requête d'origine dans la question:

SELECT 
  h.ID
, a.ID2
FROM X_HEAP h
OUTER APPLY
(
SELECT CASE
    WHEN ID <> 0 
    THEN (SELECT TOP 1 ID FROM X_OTHER_TABLE) 
    ELSE (SELECT TOP 1 ID FROM X_OTHER_TABLE_2) 
  END
) a(ID2);

bonne requête 1

L'héritage CE semble également éviter le problème.

SELECT 
  ID
, CASE
    WHEN ID <> 0 
    THEN (SELECT TOP 1 ID FROM X_OTHER_TABLE) 
    ELSE (SELECT TOP 1 ID FROM X_OTHER_TABLE_2) 
  END AS ID2
FROM X_HEAP
OPTION (USE HINT('FORCE_LEGACY_CARDINALITY_ESTIMATION'));

bonne requête 2

Un élément de connexion a été soumis pour ce problème (avec certains des détails que Paul White a fournis dans sa réponse).

Joe Obbish
la source