web-dev-qa-db-fra.com

Vérifiez l'existence avec EXISTS surperformez COUNT! ... Ne pas?

J'ai souvent lu quand il fallait vérifier l'existence d'une ligne toujours être fait avec EXISTS au lieu d'un COUNT.

Pourtant, dans plusieurs scénarios récents, j'ai mesuré une amélioration des performances lors de l'utilisation de count.
Le modèle se présente comme suit:

LEFT JOIN (
    SELECT
        someID
        , COUNT(*)
    FROM someTable
    GROUP BY someID
) AS Alias ON (
    Alias.someID = mainTable.ID
)

Je ne connais pas les méthodes pour dire ce qui se passe "à l'intérieur" de SQL Server, donc je me demandais s'il y avait une faille non annoncée avec EXISTS qui donnait parfaitement sens aux mesures que j'avais faites (EXISTS pourrait-il être RBAR?!).

Avez-vous une explication à ce phénomène?

ÉDITER:

Voici un script complet que vous pouvez exécuter:

SET NOCOUNT ON
SET STATISTICS IO OFF

DECLARE @tmp1 TABLE (
    ID INT UNIQUE
)


DECLARE @tmp2 TABLE (
    ID INT
    , X INT IDENTITY
    , UNIQUE (ID, X)
)

; WITH T(n) AS (
    SELECT
        ROW_NUMBER() OVER (ORDER BY (SELECT NULL))
    FROM master.dbo.spt_values AS S
) 
, tally(n) AS (
    SELECT
        T2.n * 100 + T1.n
    FROM T AS T1
    CROSS JOIN T AS T2
    WHERE T1.n <= 100
    AND T2.n <= 100
)
INSERT @tmp1
SELECT n
FROM tally AS T1
WHERE n < 10000


; WITH T(n) AS (
    SELECT
        ROW_NUMBER() OVER (ORDER BY (SELECT NULL))
    FROM master.dbo.spt_values AS S
) 
, tally(n) AS (
    SELECT
        T2.n * 100 + T1.n
    FROM T AS T1
    CROSS JOIN T AS T2
    WHERE T1.n <= 100
    AND T2.n <= 100
)
INSERT @tmp2
SELECT T1.n
FROM tally AS T1
CROSS JOIN T AS T2
WHERE T1.n < 10000
AND T1.n % 3 <> 0
AND T2.n < 1 + T1.n % 15

PRINT '
COUNT Version:
'

WAITFOR DELAY '00:00:01'

SET STATISTICS IO ON
SET STATISTICS TIME ON

SELECT
    T1.ID
    , CASE WHEN n > 0 THEN 1 ELSE 0 END AS DoesExist
FROM @tmp1 AS T1
LEFT JOIN (
    SELECT
        T2.ID
        , COUNT(*) AS n
    FROM @tmp2 AS T2
    GROUP BY T2.ID
) AS T2 ON (
    T2.ID = T1.ID
)
WHERE T1.ID BETWEEN 5000 AND 7000
OPTION (RECOMPILE) -- Required since table are filled within the same scope

SET STATISTICS TIME OFF

PRINT '

EXISTS Version:'

WAITFOR DELAY '00:00:01'

SET STATISTICS TIME ON

SELECT
    T1.ID
    , CASE WHEN EXISTS (
        SELECT 1
        FROM @tmp2 AS T2
        WHERE T2.ID = T1.ID
    ) THEN 1 ELSE 0 END AS DoesExist
FROM @tmp1 AS T1
WHERE T1.ID BETWEEN 5000 AND 7000
OPTION (RECOMPILE) -- Required since table are filled within the same scope

SET STATISTICS TIME OFF 

Sur SQL Server 2008R2 (Seven 64bits) j'obtiens ce résultat

COUNT Version:

Tableau '# 455F344D'. Nombre de balayages 1, lectures logiques 8, lectures physiques 0, lectures anticipées 0, lectures logiques 0, lob lectures physiques 0, lob lectures anticipées 0.
Tableau '# 492FC531'. Nombre de balayages 1, lectures logiques 30, lectures physiques 0, lectures anticipées 0, lectures logiques 0, lob lectures physiques 0, lob lectures anticipées 0.

Temps d'exécution de SQL Server:
Temps CPU = 0 ms, temps écoulé = 81 ms.

EXISTS Version:

Tableau '# 492FC531'. Nombre de balayages 1, lectures logiques 96, lectures physiques 0, lectures anticipées 0, lectures logiques 0, lob lectures physiques 0, lob lectures anticipées 0.
Tableau '# 455F344D'. Nombre de balayages 1, lectures logiques 8, lectures physiques 0, lectures anticipées 0, lectures logiques 0, lob lectures physiques 0, lob lectures anticipées 0.

Temps d'exécution de SQL Server:
Temps CPU = 0 ms, temps écoulé = 76 ms.

36
Serge

J'ai souvent lu quand il fallait vérifier l'existence d'une ligne devrait toujours se faire avec EXISTS au lieu d'un COUNT.

Il est très rare que quoi que ce soit soit toujours vrai, en particulier en ce qui concerne les bases de données. Il existe de nombreuses façons d'exprimer la même sémantique en SQL. S'il existe une règle empirique utile, il peut s'agir d'écrire des requêtes en utilisant la syntaxe la plus naturelle disponible (et, oui, c'est subjectif) et de ne considérer les réécritures que si le plan de requête ou les performances que vous obtenez sont inacceptables.

Pour ce que ça vaut, mon propre point de vue sur le problème est que les requêtes d'existence sont le plus naturellement exprimées en utilisant EXISTS. D'après mon expérience, EXISTSa tendance à mieux optimiser que l'alternative OUTER JOIN Rejette NULL. L'utilisation de COUNT(*) et le filtrage sur =0 Est une autre alternative, qui se trouve avoir une certaine prise en charge dans l'optimiseur de requête SQL Server, mais j'ai personnellement trouvé que cela n'était pas fiable dans les requêtes plus complexes. Dans tous les cas, EXISTS semble juste beaucoup plus naturel (pour moi) que l'une ou l'autre de ces alternatives.

Je me demandais s'il y avait un défaut non annoncé avec EXISTS qui donnait parfaitement sens aux mesures que j'ai faites

Votre exemple particulier est intéressant, car il met en évidence la façon dont l'optimiseur traite les sous-requêtes dans les expressions CASE (et les tests EXISTS en particulier).

Sous-requêtes dans les expressions CASE

Considérez la requête (parfaitement légale) suivante:

DECLARE @Base AS TABLE (a integer NULL);
DECLARE @When AS TABLE (b integer NULL);
DECLARE @Then AS TABLE (c integer NULL);
DECLARE @Else AS TABLE (d integer NULL);

SELECT
    CASE
        WHEN (SELECT W.b FROM @When AS W) = 1
            THEN (SELECT T.c FROM @Then AS T)
        ELSE (SELECT E.d FROM @Else AS E)
    END
FROM @Base AS B;

La sémantique de CASE signifie que les clauses WHEN/ELSE Sont généralement évaluées dans l'ordre textuel. Dans la requête ci-dessus, il serait incorrect pour SQL Server de renvoyer une erreur si la sous-requête ELSE renvoyait plus d'une ligne, si la clause WHEN était satisfaite. Pour respecter ces sémantiques, l'optimiseur produit un plan qui utilise des prédicats d'intercommunication:

Pass-through predicates

Le côté intérieur des jointures de boucle imbriquées n'est évalué que lorsque le prédicat de passage renvoie false. L'effet global est que les expressions CASE sont testées dans l'ordre et les sous-requêtes ne sont évaluées que si aucune expression précédente n'a été satisfaite.

Expressions CASE avec une sous-requête EXISTS

Lorsqu'une sous-requête CASE utilise EXISTS, le test d'existence logique est implémenté en tant que semi-jointure, mais les lignes qui seraient normalement rejetées par la semi-jointure doivent être conservées dans le cas où une clause ultérieure en a besoin. Les lignes qui traversent ce type spécial de semi-jointure acquièrent un indicateur pour indiquer si la semi-jointure a trouvé une correspondance ou non. Ce drapeau est connu sous le nom de colonne sonde.

Les détails de l'implémentation sont que la sous-requête logique est remplacée par une jointure corrélée ("appliquer") avec une colonne de sonde. Le travail est effectué par une règle de simplification dans l'optimiseur de requête appelée RemoveSubqInPrj (supprimer la sous-requête dans la projection). Nous pouvons voir les détails en utilisant l'indicateur de trace 8606:

SELECT
    T1.ID,
    CASE
        WHEN EXISTS 
        (
            SELECT 1
            FROM #T2 AS T2
            WHERE T2.ID = T1.ID
        ) THEN 1 
    ELSE 0
    END AS DoesExist
FROM #T1 AS T1
WHERE T1.ID BETWEEN 5000 AND 7000
OPTION (QUERYTRACEON 3604, QUERYTRACEON 8606);

Une partie de l'arborescence d'entrée montrant le test EXISTS est illustrée ci-dessous:

ScaOp_Exists 
    LogOp_Project
        LogOp_Select
            LogOp_Get TBL: #T2
            ScaOp_Comp x_cmpEq
                ScaOp_Identifier [T2].ID
                ScaOp_Identifier [T1].ID

Ceci est transformé par RemoveSubqInPrj en une structure dirigée par:

LogOp_Apply (x_jtLeftSemi probe PROBE:COL: Expr1008)

Il s'agit de la demi-jointure gauche appliquée avec la sonde décrite précédemment. Cette transformation initiale est la seule disponible dans les optimiseurs de requête SQL Server à ce jour, et la compilation échouera simplement si cette transformation est désactivée.

L'une des formes de plan d'exécution possibles pour cette requête est une implémentation directe de cette structure logique:

NLJ Semi Join with Probe

Le calcul final scalaire évalue le résultat de l'expression CASE en utilisant la valeur de la colonne de sonde:

Compute Scalar expression

La forme de base de l'arborescence du plan est conservée lorsque l'optimisation prend en compte d'autres types de jointures physiques pour la semi-jointure. Seule la jointure de fusion prend en charge une colonne de sonde, donc une semi-jointure de hachage, bien que logiquement possible, n'est pas prise en compte:

Merge with probe column

Notez que la fusion génère une expression intitulée Expr1008 (Que le nom est le même qu'avant est une coïncidence) bien qu'aucune définition ne s'affiche pour aucun opérateur du plan. Ce n'est encore que la colonne de sonde. Comme précédemment, le calcul final scalaire utilise cette valeur de sonde pour évaluer le CASE.

Le problème est que l'optimiseur n'explore pas complètement les alternatives qui ne valent que par fusion (ou hachage) semi-jointure. Dans le plan des boucles imbriquées, il n'y a aucun avantage à vérifier si les lignes de T2 Correspondent à la plage à chaque itération. Avec un plan de fusion ou de hachage, cela pourrait être une optimisation utile.

Si nous ajoutons un prédicat BETWEEN correspondant à T2 Dans la requête, tout ce qui se passe est que cette vérification est effectuée pour chaque ligne en tant que résidu sur la semi-jointure de fusion (difficile à repérer dans l'exécution plan, mais il est là):

SELECT
    T1.ID,
    CASE
        WHEN EXISTS 
        (
            SELECT 1
            FROM #T2 AS T2
            WHERE T2.ID = T1.ID
            AND T2.ID BETWEEN 5000 AND 7000 -- New
        ) THEN 1 
    ELSE 0
    END AS DoesExist
FROM #T1 AS T1
WHERE T1.ID BETWEEN 5000 AND 7000;

Residual predicate

Nous espérons que le prédicat BETWEEN sera plutôt poussé vers le bas à T2 Résultant en une recherche. Normalement, l'optimiseur envisagerait de faire cela (même sans le prédicat supplémentaire dans la requête). Il reconnaît prédicats implicites (BETWEEN sur T1 Et le prédicat de jointure entre T1 Et T2 Impliquent ensemble le BETWEEN on T2) sans qu'ils soient présents dans le texte de requête d'origine. Malheureusement, le modèle de sonde d'application signifie que cela n'est pas exploré.

Il existe des moyens d'écrire la requête pour produire des recherches sur les deux entrées d'une semi-jointure de fusion. Une façon consiste à écrire la requête de manière assez peu naturelle (en contrevenant à la raison pour laquelle je préfère généralement EXISTS):

WITH T2 AS
(
    SELECT TOP (9223372036854775807) * 
    FROM #T2 AS T2 
    WHERE ID BETWEEN 5000 AND 7000
)
SELECT 
    T1.ID, 
    DoesExist = 
        CASE 
            WHEN EXISTS 
            (
                SELECT * FROM T2 
                WHERE T2.ID = T1.ID
            ) THEN 1 ELSE 0 END
FROM #T1 AS T1
WHERE T1.ID BETWEEN 5000 AND 7000;

TOP trick plan

Je ne serais pas heureux d'écrire cette requête dans un environnement de production, c'est juste pour démontrer que la forme de plan souhaitée est possible. Si la vraie requête que vous devez écrire utilise CASE de cette manière particulière et que les performances souffrent du fait qu'il n'y a pas de recherche du côté sonde d'une semi-jointure de fusion, vous pouvez envisager d'écrire la requête en utilisant une syntaxe différente qui produit les bons résultats et un plan d'exécution plus efficace.

44
Paul White 9

L'argument "COUNT (*) vs EXISTS" consiste à vérifier si un enregistrement existe. Par exemple:

WHERE (SELECT COUNT(*) FROM Table WHERE ID=@ID)>0

contre

WHERE EXISTS(SELECT ID FROM Table WHERE ID=@ID)

Votre script SQL n'utilise pas COUNT(*) en tant que vérification d'existence d'enregistrement, et donc je ne dirais pas que c'est applicable dans votre scénario.

6
Curt