In SQL Server, dovrei forzare un LOOP JOIN nel seguente caso?


15

In genere, sconsiglio di utilizzare i suggerimenti di join per tutti i motivi standard. Di recente, tuttavia, ho trovato uno schema in cui trovo quasi sempre un join ad anello forzato per ottenere prestazioni migliori. In effetti, sto iniziando a usarlo e lo consiglio così tanto che volevo ottenere una seconda opinione per assicurarmi che non mi mancasse qualcosa. Ecco uno scenario rappresentativo (alla fine il codice molto specifico per generare un esempio):

--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 ha 1 milione di righe e il suo PK è ID.
Tabella temporanea #Driver ha solo una colonna, ID, nessun indice e 50K righe.

Quello che trovo costantemente è il seguente:

Caso 1: NO SUGGERIMENTO
Scansione indice su SampleTable
Hash Unisci
durata maggiore (avg 333ms)
CPU superiore (avg 331ms)
Letture logiche inferiori (4714)

Caso 2: LOOP JOIN HINT
Index Cerca su SampleTable
Loop Partecipa a
durata inferiore (media 204 ms, 39% in meno)
CPU inferiore (media 206, 38% in meno)
Letture logiche molto più alte (160015, 34X in più)

All'inizio, le letture molto più alte del secondo caso mi spaventavano un po 'perché abbassare le letture è spesso considerata una misura decente delle prestazioni. Ma più penso a ciò che sta realmente accadendo, non mi riguarda. Ecco il mio pensiero:

La SampleTable è contenuta in 4714 pagine, impiegando circa 36 MB. Il caso 1 li analizza tutti, motivo per cui otteniamo 4714 letture. Inoltre, deve eseguire 1 milione di hash, che sono ad alta intensità di CPU e che alla fine aumentano il tempo in modo proporzionale. È tutto questo hashing che sembra aumentare il tempo nel caso 1.

Ora prendi in considerazione il caso 2. Non sta facendo alcun hash, ma sta facendo 50000 ricerche separate, che è ciò che sta guidando le letture. Ma quanto costano le letture comparativamente? Si potrebbe dire che se si tratta di letture fisiche, potrebbe essere piuttosto costoso. Ma tieni presente 1) solo la prima lettura di una determinata pagina potrebbe essere fisica, e 2) anche così, il caso 1 avrebbe lo stesso o peggio problema poiché è garantito che colpisca ogni pagina.

Quindi, tenendo conto del fatto che entrambi i casi devono accedere a ciascuna pagina almeno una volta, sembra essere una domanda di quale sia più veloce, 1 milione di hash o circa 155000 letture contro la memoria? I miei test sembrano dire quest'ultimo, ma SQL Server sceglie coerentemente il primo.

Domanda

Quindi, tornando alla mia domanda: dovrei continuare a forzare questo suggerimento LOOP JOIN quando il test mostra questo tipo di risultati o mi manca qualcosa nella mia analisi? Sono titubante nell'andare contro l'ottimizzatore di SQL Server, ma sembra che passi a usare un hash join molto prima di quanto dovrebbe in casi come questi.

Aggiornamento 28-04-2014

Ho fatto qualche altro test e ho scoperto che i risultati che stavo ottenendo sopra (su una VM con 2 CPU) non potevo replicare in altri ambienti (ho provato su 2 diverse macchine fisiche con 8 e 12 CPU). L'ottimizzatore ha fatto molto meglio negli ultimi casi fino al punto in cui non si è verificato un problema così pronunciato. Immagino che la lezione appresa, che sembra ovvia in retrospettiva, sia che l'ambiente può influenzare in modo significativo il funzionamento dell'ottimizzatore.

Piani di esecuzione

Piano di esecuzione caso 1 Piano 1 Piano di esecuzione caso 2 inserisci qui la descrizione dell'immagine

Codice per generare un caso di esempio

------------------------------------------------------------
-- 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

*/

Risposte:


13

La SampleTable è contenuta in 4714 pagine, impiegando circa 36 MB. Il caso 1 li analizza tutti, motivo per cui otteniamo 4714 letture. Inoltre, deve eseguire 1 milione di hash, che sono ad alta intensità di CPU e che alla fine aumentano il tempo in modo proporzionale. È tutto questo hashing che sembra aumentare il tempo nel caso 1.

Esiste un costo di avvio per un join hash (creazione della tabella hash, che è anche un'operazione di blocco), ma alla fine il join hash ha il costo teorico più basso per riga dei tre tipi di join fisici supportati da SQL Server, entrambi in termini di IO e CPU. L'hash join si presenta davvero con un input di build relativamente piccolo e un input di sonda di grandi dimensioni. Detto questo, nessun tipo di join fisico è "migliore" in tutti gli scenari.

Ora prendi in considerazione il caso 2. Non sta facendo alcun hash, ma sta facendo 50000 ricerche separate, che è ciò che sta guidando le letture. Ma quanto costano le letture relativamente? Si potrebbe dire che se si tratta di letture fisiche, potrebbe essere piuttosto costoso. Ma tieni presente 1) solo la prima lettura di una determinata pagina potrebbe essere fisica, e 2) anche così, il caso 1 avrebbe lo stesso o peggio problema poiché è garantito che colpisca ogni pagina.

Ogni ricerca richiede la navigazione di un b-tree alla radice, che è computazionalmente costoso rispetto a una singola sonda di hash. Inoltre, il pattern IO generale per il lato interno di un join di loop nidificato è casuale, rispetto al pattern di accesso sequenziale dell'ingresso di scansione sul lato della sonda a un join hash. A seconda del sottosistema IO fisico sottostante, le letture sequenziali possono essere più veloci delle letture casuali. Inoltre, il meccanismo di lettura anticipata di SQL Server funziona meglio con l'IO sequenziale, generando letture più grandi.

Quindi, tenendo conto del fatto che entrambi i casi devono accedere a ciascuna pagina almeno una volta, sembra essere una domanda di quale sia più veloce, 1 milione di hash o circa 155000 letture contro la memoria? I miei test sembrano dire quest'ultimo, ma SQL Server sceglie coerentemente il primo.

Query Optimizer di SQL Server formula una serie di ipotesi. Uno è che il primo accesso a una pagina creato da una query si tradurrà in un IO fisico (il "presupposto della cache fredda"). La possibilità che una lettura successiva provenga da una pagina già letta in memoria dalla stessa query è modellata, ma questo non è altro che un'ipotesi istruita.

Il motivo per cui il modello dell'ottimizzatore funziona in questo modo è che in genere è meglio ottimizzare nel caso peggiore (è necessario un IO fisico). Molte carenze possono essere coperte dal parallelismo e dalla gestione delle cose nella memoria. I piani di query che l'ottimizzatore produrrebbe se presupponesse che tutti i dati fossero in memoria potrebbero avere prestazioni molto scarse se tale presupposto si rivelasse non valido.

Il piano prodotto utilizzando il presupposto della cache fredda potrebbe non funzionare altrettanto bene come se si ipotizzasse invece una cache calda, ma le sue prestazioni nel caso peggiore saranno generalmente superiori.

Devo continuare a forzare questo suggerimento LOOP JOIN quando il test mostra questo tipo di risultati o mi manca qualcosa nella mia analisi? Sono titubante nell'andare contro l'ottimizzatore di SQL Server, ma sembra che passi a usare un hash join molto prima di quanto dovrebbe in casi come questi.

Dovresti stare molto attento a farlo per due motivi. Innanzitutto, i suggerimenti di join forzano anche silenziosamente l'ordine di join fisico in modo che corrisponda all'ordine scritto della query (proprio come se avessi specificato ancheOPTION (FORCE ORDER) . Ciò limita fortemente le alternative disponibili per l'ottimizzatore e potrebbe non essere sempre quello che desideri. OPTION (LOOP JOIN)Forza i cicli annidati join per la query, ma non impone l'ordine di join scritto.

In secondo luogo, si presume che le dimensioni del set di dati rimarranno piccole e che la maggior parte delle letture logiche provengono dalla cache. Se questi presupposti diventano non validi (forse nel tempo), le prestazioni diminuiranno. Query Optimizer integrato è abbastanza bravo a reagire alle mutevoli circostanze; rimuovere quella libertà è qualcosa a cui dovresti pensare intensamente.

Nel complesso, a meno che non ci sia una ragione convincente per forzare i join dei loop, lo eviterei. I piani di default sono in genere abbastanza vicini all'ottimale e tendono ad essere più resistenti di fronte alle mutevoli circostanze.


Grazie Paul. Eccellente analisi dettagliata. Sulla base di ulteriori test che ho fatto, penso che ciò che sta accadendo sia che le ipotesi formulate dall'ottimizzatore sono costantemente off per questo esempio particolare quando la dimensione della tabella temporanea è compresa tra 5K e 100K. Dato che i nostri requisiti garantiscono che la tabella temporanea sarà <50K, mi sembra sicuro. Sono curioso, eviteresti comunque qualsiasi tipo di suggerimento di partecipazione sapendolo?
JohnnyM,

1
@JohnnyM I suggerimenti esistono per un motivo. Va bene usarli dove hai valide ragioni per farlo. Detto questo, uso raramente i suggerimenti di join a causa dell'implicito FORCE ORDER. Nella strana occasione utilizzo un suggerimento di join, spesso aggiungo OPTION (FORCE ORDER)un commento per spiegare il perché.
Paul White Ripristina Monica

0

50.000 righe unite contro una tabella da un milione di righe sembrano essere molte per qualsiasi tabella senza un indice.

È difficile dirti esattamente cosa fare in questo caso, dal momento che è così isolato dal problema che stai effettivamente cercando di risolvere. Spero sicuramente che non sia un modello generale all'interno del codice in cui ti unisci a molte tabelle temporanee non indicizzate con quantità significative di righe.

Prendendo l'esempio solo per quello che dice, perché non mettere semplicemente un indice su #Driver? D.ID è davvero unico? In tal caso, ciò equivale semanticamente a un'istruzione EXISTS, che consentirà almeno a SQL Server di sapere che non si desidera continuare a cercare S per valori duplicati di D:

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

In breve, per questo modello, non vorrei usare un suggerimento LOOP. Semplicemente non userei questo modello. Farei una delle seguenti operazioni, in ordine di priorità, se fattibile:

  • Utilizzare un CTE anziché una tabella temporanea per #Driver, se possibile
  • Usa un indice non cluster univoco su #Driver su ID se è univoco (supponendo che questa sia l'unica volta che usi #Driver e che non desideri alcun dato dalla tabella stessa - se in realtà hai bisogno di dati da quella tabella, potrebbe farlo bene per renderlo un indice cluster)
Utilizzando il nostro sito, riconosci di aver letto e compreso le nostre Informativa sui cookie e Informativa sulla privacy.
Licensed under cc by-sa 3.0 with attribution required.