Dans SQL Server, dois-je forcer une LOOP JOIN dans le cas suivant?

15

En règle générale, je déconseille d'utiliser des conseils de jointure pour toutes les raisons standard. Récemment, cependant, j'ai trouvé un modèle où je trouve presque toujours une jointure de boucle forcée pour mieux fonctionner. En fait, je commence à l'utiliser et je le recommande tellement que je voulais obtenir un deuxième avis pour m'assurer de ne rien manquer. Voici un scénario représentatif (le code très spécifique pour générer un exemple est à la fin):

--Case 1: NO HINT
SELECT S.*
INTO #Results
FROM #Driver AS D
JOIN SampleTable AS S ON S.ID = D.ID

--Case 2: LOOP JOIN HINT
SELECT S.*
INTO #Results
FROM #Driver AS D
INNER LOOP JOIN SampleTable AS S ON S.ID = D.ID

SampleTable a 1 million de lignes et son PK est ID.
La table temporaire #Driver a une seule colonne, un ID, aucun index et 50K lignes.

Ce que je trouve constamment est le suivant:

Cas 1: AUCUN
INDICE Balayage d'index sur la table de
hachage SampleTable Join
Durée plus élevée (moyenne 333 ms)
Processeur supérieur (moyenne 331 ms)
Lectures logiques inférieures (4714)

Cas 2: LOOP JOIN HINT
Index Seek on SampleTable
Loop Join
Durée inférieure (204 ms, 39% de moins)
CPU plus faible (206, 38% de moins)
Lectures logiques beaucoup plus élevées (160015, 34 fois plus)

Au début, les lectures beaucoup plus élevées du deuxième cas m'ont un peu effrayé car la baisse des lectures est souvent considérée comme une mesure décente des performances. Mais plus je pense à ce qui se passe réellement, cela ne me concerne pas. Voici ma pensée:

SampleTable est contenu sur 4714 pages, prenant environ 36 Mo. Le cas 1 les analyse tous, c'est pourquoi nous obtenons 4714 lectures. De plus, il doit effectuer 1 million de hachages, qui sont gourmands en ressources CPU et qui, finalement, augmentent le temps proportionnellement. C'est tout ce hachage qui semble faire gagner du temps dans le cas 1.

Considérons maintenant le cas 2. Il ne fait aucun hachage, mais au lieu de cela, il effectue 50000 recherches distinctes, ce qui stimule les lectures. Mais combien coûtent les lectures comparativement? On pourrait dire que s'il s'agit de lectures physiques, cela pourrait être assez coûteux. Mais gardez à l'esprit 1) seule la première lecture d'une page donnée peut être physique, et 2) même ainsi, le cas 1 aurait le même problème ou pire car il est garanti de frapper chaque page.

Donc, compte tenu du fait que les deux cas doivent accéder à chaque page au moins une fois, il semble que ce soit une question plus rapide, 1 million de hachages ou environ 155 000 lectures par rapport à la mémoire? Mes tests semblent dire le dernier, mais SQL Server choisit systématiquement le premier.

Question

Revenons donc à ma question: devrais-je continuer à forcer cette indication LOOP JOIN lorsque le test montre ce type de résultats, ou manque-t-il quelque chose dans mon analyse? J'hésite à aller contre l'optimiseur de SQL Server, mais il semble qu'il passe à l'utilisation d'une jointure de hachage beaucoup plus tôt qu'il ne le devrait dans des cas comme ceux-ci.

Mise à jour 2014-04-28

J'ai fait quelques tests supplémentaires et j'ai découvert que les résultats que j'obtenais ci-dessus (sur une machine virtuelle avec 2 processeurs) ne pouvaient pas être répliqués dans d'autres environnements (j'ai essayé sur 2 machines physiques différentes avec 8 et 12 processeurs). L'optimiseur a fait beaucoup mieux dans ces derniers cas au point où il n'y avait pas de problème aussi prononcé. Je suppose que la leçon apprise, qui semble évidente rétrospectivement, est que l'environnement peut affecter de manière significative le fonctionnement de l'optimiseur.

Plans d'exécution

Plan d'exécution, cas 1 Plan 1 Plan d'exécution, cas 2 entrez la description de l'image ici

Code pour générer un exemple de cas

------------------------------------------------------------
-- 1. Create SampleTable with 1,000,000 rows
------------------------------------------------------------    

CREATE TABLE SampleTable
    (  
       ID         INT NOT NULL PRIMARY KEY CLUSTERED
     , Number1    INT NOT NULL
     , Number2    INT NOT NULL
     , Number3    INT NOT NULL
     , Number4    INT NOT NULL
     , Number5    INT NOT NULL
    )

--Add 1 million rows
;WITH  
    Cte0 AS (SELECT 1 AS C UNION ALL SELECT 1), --2 rows  
    Cte1 AS (SELECT 1 AS C FROM Cte0 AS A, Cte0 AS B),--4 rows  
    Cte2 AS (SELECT 1 AS C FROM Cte1 AS A ,Cte1 AS B),--16 rows 
    Cte3 AS (SELECT 1 AS C FROM Cte2 AS A ,Cte2 AS B),--256 rows 
    Cte4 AS (SELECT 1 AS C FROM Cte3 AS A ,Cte3 AS B),--65536 rows 
    Cte5 AS (SELECT 1 AS C FROM Cte4 AS A ,Cte2 AS B),--1048576 rows 
    FinalCte AS (SELECT  ROW_NUMBER() OVER (ORDER BY C) AS Number FROM   Cte5)
INSERT INTO SampleTable
SELECT Number, Number, Number, Number, Number, Number
FROM  FinalCte
WHERE Number <= 1000000

------------------------------------------------------------
-- Create 2 SPs that join from #Driver to SampleTable.
------------------------------------------------------------    
GO
IF OBJECT_ID('JoinTest_NoHint') IS NOT NULL DROP PROCEDURE JoinTest_NoHint
GO
CREATE PROC JoinTest_NoHint
AS
    SELECT S.*
    INTO #Results
    FROM #Driver AS D
    JOIN SampleTable AS S ON S.ID = D.ID
GO
IF OBJECT_ID('JoinTest_LoopHint') IS NOT NULL DROP PROCEDURE JoinTest_LoopHint
GO
CREATE PROC JoinTest_LoopHint
AS
    SELECT S.*
    INTO #Results
    FROM #Driver AS D
    INNER LOOP JOIN SampleTable AS S ON S.ID = D.ID
GO

------------------------------------------------------------
-- Create driver table with 50K rows
------------------------------------------------------------    
GO
IF OBJECT_ID('tempdb..#Driver') IS NOT NULL DROP TABLE #Driver
SELECT ID
INTO #Driver
FROM SampleTable
WHERE ID % 20 = 0

------------------------------------------------------------
-- Run each test and run Profiler
------------------------------------------------------------    

GO
/*Reg*/  EXEC JoinTest_NoHint
GO
/*Loop*/ EXEC JoinTest_LoopHint


------------------------------------------------------------
-- Results
------------------------------------------------------------    

/*

Duration CPU   Reads    TextData
315      313   4714     /*Reg*/  EXEC JoinTest_NoHint
309      296   4713     /*Reg*/  EXEC JoinTest_NoHint
327      329   4713     /*Reg*/  EXEC JoinTest_NoHint
398      406   4715     /*Reg*/  EXEC JoinTest_NoHint
316      312   4714     /*Reg*/  EXEC JoinTest_NoHint
217      219   160017   /*Loop*/ EXEC JoinTest_LoopHint
211      219   160014   /*Loop*/ EXEC JoinTest_LoopHint
217      219   160013   /*Loop*/ EXEC JoinTest_LoopHint
190      188   160013   /*Loop*/ EXEC JoinTest_LoopHint
187      187   160015   /*Loop*/ EXEC JoinTest_LoopHint

*/
JohnnyM
la source

Réponses:

13

SampleTable est contenu sur 4714 pages, prenant environ 36 Mo. Le cas 1 les analyse tous, c'est pourquoi nous obtenons 4714 lectures. De plus, il doit effectuer 1 million de hachages, qui sont gourmands en ressources CPU et qui, finalement, augmentent le temps proportionnellement. C'est tout ce hachage qui semble faire gagner du temps dans le cas 1.

Il existe un coût de démarrage pour une jointure de hachage (création de la table de hachage, qui est également une opération de blocage), mais la jointure de hachage a finalement le coût théorique par ligne le plus bas des trois types de jointures physiques pris en charge par SQL Server, à la fois dans termes d'E / S et CPU. La jointure par hachage prend vraiment tout son sens avec une entrée de génération relativement petite et une grande entrée de sonde. Cela dit, aucun type de jointure physique n'est «meilleur» dans tous les scénarios.

Considérons maintenant le cas 2. Il ne fait aucun hachage, mais au lieu de cela, il effectue 50000 recherches distinctes, ce qui stimule les lectures. Mais combien coûtent les lectures comparativement? On pourrait dire que s'il s'agit de lectures physiques, cela pourrait être assez coûteux. Mais gardez à l'esprit 1) seule la première lecture d'une page donnée peut être physique, et 2) même ainsi, le cas 1 aurait le même problème ou pire car il est garanti de frapper chaque page.

Chaque recherche nécessite la navigation d'un arbre b vers la racine, ce qui est coûteux en calcul par rapport à une seule sonde de hachage. En outre, le modèle d'E / S général pour le côté interne d'une jointure de boucles imbriquées est aléatoire, par rapport au modèle d'accès séquentiel de l'entrée de balayage côté sonde vers une jointure de hachage. Selon le sous-système d'E / S physique sous-jacent, les lectures séquentielles peuvent être plus rapides que les lectures aléatoires. En outre, le mécanisme de lecture anticipée de SQL Server fonctionne mieux avec les E / S séquentielles, émettant des lectures plus importantes.

Donc, compte tenu du fait que les deux cas doivent accéder à chaque page au moins une fois, il semble que ce soit une question plus rapide, 1 million de hachages ou environ 155 000 lectures par rapport à la mémoire? Mes tests semblent dire le dernier, mais SQL Server choisit systématiquement le premier.

L'optimiseur de requêtes SQL Server fait un certain nombre d'hypothèses. La première est que le premier accès à une page effectuée par une requête entraînera une E / S physique («l'hypothèse du cache froid»). La probabilité qu'une lecture ultérieure provienne d'une page déjà lue en mémoire par la même requête est modélisée, mais ce n'est qu'une supposition éclairée.

La raison pour laquelle le modèle de l'optimiseur fonctionne de cette façon est qu'il est généralement préférable d'optimiser pour le pire des cas (des E / S physiques sont nécessaires). De nombreuses lacunes peuvent être masquées par le parallélisme et l'exécution de choses en mémoire. Les plans de requête que l'optimiseur produirait s'il supposait que toutes les données étaient en mémoire pourraient très mal fonctionner si cette hypothèse s'avérait invalide.

Le plan produit en utilisant l'hypothèse de cache froid peut ne pas fonctionner aussi bien que si un cache chaud était supposé à la place, mais ses performances dans le pire des cas seront généralement supérieures.

Dois-je continuer à forcer cette indication LOOP JOIN lorsque le test montre ce type de résultats, ou ai-je oublié quelque chose dans mon analyse? J'hésite à aller contre l'optimiseur de SQL Server, mais il semble qu'il passe à l'utilisation d'une jointure de hachage beaucoup plus tôt qu'il ne le devrait dans des cas comme ceux-ci.

Vous devez être très prudent lorsque vous effectuez cette opération pour deux raisons. Tout d'abord, les indications de jointure forcent également silencieusement l'ordre de jointure physique à correspondre à l'ordre écrit de la requête (comme si vous l'aviez également spécifié OPTION (FORCE ORDER). Cela limite considérablement les alternatives disponibles pour l'optimiseur et peut ne pas toujours être ce que vous voulez. OPTION (LOOP JOIN)Force les boucles imbriquées jointures pour la requête, mais n'applique pas l'ordre de jointure écrit.

Deuxièmement, vous supposez que la taille de l'ensemble de données restera petite et que la plupart des lectures logiques proviendront du cache. Si ces hypothèses deviennent invalides (peut-être avec le temps), les performances se dégradent. L'optimiseur de requêtes intégré est assez bon pour réagir aux circonstances changeantes; supprimer cette liberté est une chose à laquelle vous devriez réfléchir sérieusement.

Dans l'ensemble, à moins qu'il n'y ait une raison impérieuse de forcer les jointures de boucles, je l'éviterais. Les plans par défaut sont généralement assez proches de l'optimum et ont tendance à être plus résilients face à l'évolution des circonstances.

Paul White 9
la source
Merci Paul. Excellente analyse détaillée. Sur la base de quelques tests supplémentaires que j'ai faits, je pense que ce qui se passe, c'est que les suppositions éclairées de l'optimiseur sont systématiquement désactivées pour cet exemple particulier lorsque la taille de la table temporaire se situe entre 5K et 100K. Étant donné que nos exigences garantissent que la table de température sera <50K, cela me semble sûr. Je suis curieux, voudriez-vous toujours éviter tout indice de jointure en sachant cela?
JohnnyM
1
@JohnnyM Les indices existent pour une raison. Il est bon de les utiliser lorsque vous avez de bonnes raisons de le faire. Cela dit, j'utilise rarement les indices de jointure en raison de l'implication FORCE ORDER. À l'occasion étrange, j'utilise un indice de jointure, j'ajoute souvent OPTION (FORCE ORDER)un commentaire pour expliquer pourquoi.
Paul White 9
0

50 000 lignes jointes à une table d'un million de lignes semblent être beaucoup pour toute table sans index.

Il est difficile de vous dire exactement quoi faire dans ce cas, car il est tellement isolé du problème que vous essayez réellement de le résoudre. J'espère certainement que ce n'est pas un modèle général dans votre code où vous vous joignez à de nombreuses tables temporaires non indexées avec des quantités importantes de lignes.

Prenant l'exemple juste pour ce qu'il dit, pourquoi ne pas simplement mettre un index sur #Driver? D.ID est-il vraiment unique? Si tel est le cas, c'est sémantiquement équivalent à une instruction EXISTS, qui indiquera au moins à SQL Server que vous ne voulez pas continuer à rechercher dans S les valeurs en double de D:

SELECT S.*
INTO #Results
FROM SampleTable S
WHERE EXISTS (SELECT * #Driver D WHERE S.ID = D.ID);

En bref, pour ce modèle, je n'utiliserais pas un indice LOOP. Je n'utiliserais tout simplement pas ce modèle. Je ferais l'une des actions suivantes, par ordre de priorité si possible:

  • Utilisez un CTE au lieu d'une table temporaire pour #Driver si possible
  • Utilisez un index unique non cluster sur #Driver sur ID s'il est unique (en supposant que c'est la seule fois que vous utilisez #Driver et que vous ne voulez pas de données de la table elle-même - si vous avez réellement besoin de données de cette table, vous pourrait bien en faire un index clusterisé)
Dave Markle
la source