Scansioni impreviste durante l'operazione di eliminazione utilizzando WHERE IN


40

Ho una domanda come la seguente:

DELETE FROM tblFEStatsBrowsers WHERE BrowserID NOT IN (
    SELECT DISTINCT BrowserID FROM tblFEStatsPaperHits WITH (NOLOCK) WHERE BrowserID IS NOT NULL
)

tblFEStatsBrowsers ha 553 righe.
tblFEStatsPaperHits ha 47.974.301 righe.

tblFEStatsBrowsers:

CREATE TABLE [dbo].[tblFEStatsBrowsers](
    [BrowserID] [smallint] IDENTITY(1,1) NOT NULL,
    [Browser] [varchar](50) NOT NULL,
    [Name] [varchar](40) NOT NULL,
    [Version] [varchar](10) NOT NULL,
    CONSTRAINT [PK_tblFEStatsBrowsers] PRIMARY KEY CLUSTERED ([BrowserID] ASC)
)

tblFEStatsPaperHits:

CREATE TABLE [dbo].[tblFEStatsPaperHits](
    [PaperID] [int] NOT NULL,
    [Created] [smalldatetime] NOT NULL,
    [IP] [binary](4) NULL,
    [PlatformID] [tinyint] NULL,
    [BrowserID] [smallint] NULL,
    [ReferrerID] [int] NULL,
    [UserLanguage] [char](2) NULL
)

C'è un indice cluster su tblFEStatsPaperHits che non include BrowserID. L'esecuzione della query interna richiederà quindi una scansione completa della tabella di tblFEStatsPaperHits, il che è totalmente OK.

Attualmente, viene eseguita una scansione completa per ogni riga in tblFEStatsBrowsers, il che significa che ho 553 scansioni complete di tblFEStatsPaperHits.

La riscrittura in solo DOVE ESISTE non cambia il piano:

DELETE FROM tblFEStatsBrowsers WHERE NOT EXISTS (
    SELECT * FROM tblFEStatsPaperHits WITH (NOLOCK) WHERE BrowserID = tblFEStatsBrowsers.BrowserID
)

Tuttavia, come suggerito da Adam Machanic, l'aggiunta di un'opzione HASH JOIN porta al piano di esecuzione ottimale (solo una singola scansione di tblFEStatsPaperHits):

DELETE FROM tblFEStatsBrowsers WHERE NOT EXISTS (
    SELECT * FROM tblFEStatsPaperHits WITH (NOLOCK) WHERE BrowserID = tblFEStatsBrowsers.BrowserID
) OPTION (HASH JOIN)

Ora questa non è una domanda su come risolvere questo problema: posso usare OPTION (HASH JOIN) o creare manualmente una tabella temporanea. Mi chiedo di più perché il Query Optimizer avrebbe mai usato il piano che attualmente fa.

Dal momento che il QO non ha statistiche sulla colonna BrowserID, suppongo che stia assumendo il peggio - 50 milioni di valori distinti, quindi richiede un tavolo di lavoro in memoria / tempdb abbastanza grande. Pertanto, il modo più sicuro è eseguire scansioni per ogni riga in tblFEStatsBrowsers. Non esiste alcuna relazione di chiave esterna tra le colonne BrowserID nelle due tabelle, quindi il QO non può detrarre alcuna informazione da tblFEStatsBrowsers.

È questo, semplice come sembra, il motivo?

Aggiornamento 1
Per fornire un paio di statistiche: OPZIONE (HASH JOIN):
208.711 letture logiche (12 scansioni)

OPZIONE (LOOP JOIN, HASH GROUP):
11.008.698 letture logiche (~ scan per BrowserID (339))

Nessuna opzione:
11.008.775 letture logiche (~ scan per BrowserID (339))

Aggiornamento 2
Risposte eccellenti, tutti voi - grazie! Difficile sceglierne solo uno. Anche se Martin è stato il primo e Remus offre una soluzione eccellente, devo darlo al Kiwi per andare mentalmente sui dettagli :)


5
Puoi eseguire lo scripting delle statistiche come da Copia statistiche da un server a un altro in modo che possiamo replicare?
Mark Storey-Smith,

2
@ MarkStorey-Smith Sicuro - pastebin.com/9HHRPFgK Supponendo che tu esegua lo script in un database vuoto, questo mi consente di riprodurre le domande problematiche quando includo la visualizzazione del piano di esecuzione. Entrambe le query sono incluse alla fine dello script.
Mark S. Rasmussen,

Risposte:


61

"Mi chiedo di più perché lo Strumento per ottimizzare le query abbia mai usato il piano attualmente in uso."

Per dirla in altro modo, la domanda è: perché il seguente piano sembra più economico per l'ottimizzatore, rispetto alle alternative (di cui ce ne sono molte ).

Piano originale

Il lato interno del join esegue essenzialmente una query del seguente modulo per ciascun valore correlato di BrowserID:

DECLARE @BrowserID smallint;

SELECT 
    tfsph.BrowserID 
FROM dbo.tblFEStatsPaperHits AS tfsph 
WHERE 
    tfsph.BrowserID = @BrowserID 
OPTION (MAXDOP 1);

Paper Hits Scan

Si noti che il numero stimato di righe è 185.220 (non 289.013 ) poiché il confronto di uguaglianza esclude implicitamente NULL(a meno che non lo ANSI_NULLSsia OFF). Il costo stimato del piano sopra è di 206,8 unità.

Ora aggiungiamo una TOP (1)clausola:

DECLARE @BrowserID smallint;

SELECT TOP (1)
    tfsph.BrowserID 
FROM dbo.tblFEStatsPaperHits AS tfsph 
WHERE 
    tfsph.BrowserID = @BrowserID 
OPTION (MAXDOP 1);

Con TOP (1)

Il costo stimato è ora 0,00452 unità. L'aggiunta dell'operatore fisico Top imposta un obiettivo di riga di 1 riga sull'operatore Top. La domanda diventa quindi come derivare un "obiettivo di riga" per la scansione dell'indice cluster; cioè, quante righe dovrebbe essere elaborata dalla scansione prima che una riga corrisponda al BrowserIDpredicato?

Le informazioni statistiche disponibili mostrano 166BrowserID valori distinti (1 / [All Density] = 1 / 0.006024096 = 166). Il calcolo dei costi presuppone che i valori distinti siano distribuiti uniformemente sulle righe fisiche, quindi l'obiettivo di riga nella scansione dell'indice cluster è impostato su 166.302 (tenendo conto del cambiamento nella cardinalità della tabella da quando sono state raccolte le statistiche campionate).

Il costo stimato della scansione delle 166 righe previste non è molto elevato (anche eseguito 339 volte, una volta per ogni modifica di BrowserID): la scansione dell'indice cluster mostra un costo stimato di 1,3219 unità, che mostra l'effetto di ridimensionamento dell'obiettivo della riga. I costi dell'operatore non scalato per I / O e CPU sono indicati rispettivamente come 153.931 e 52.8698 :

Costi stimati scalati obiettivo di riga

In pratica, è molto improbabile che le prime 166 righe scansionate dall'indice (nell'ordine in cui vengono restituite) conterranno uno dei possibili BrowserIDvalori. Tuttavia, il DELETEpiano ha un costo di 1.40921 unità totali e viene selezionato dall'ottimizzatore per tale motivo. Bart Duncan mostra un altro esempio di questo tipo in un recente post intitolato Row Goals Gone Rogue .

È anche interessante notare che l'operatore Top nel piano di esecuzione non è associato all'Anti Semi Join (in particolare le menzioni di Martin "in cortocircuito"). Possiamo iniziare a vedere da dove proviene il Top disabilitando prima una regola di esplorazione chiamata GbAggToConstScanOrTop :

DBCC RULEOFF ('GbAggToConstScanOrTop');
GO
DELETE FROM tblFEStatsBrowsers 
WHERE BrowserID NOT IN 
(
    SELECT DISTINCT BrowserID 
    FROM tblFEStatsPaperHits WITH (NOLOCK) 
    WHERE BrowserID IS NOT NULL
) OPTION (MAXDOP 1, LOOP JOIN, RECOMPILE);
GO
DBCC RULEON ('GbAggToConstScanOrTop');

GbAggToConstScanOrTop disabilitato

Tale piano ha un costo stimato di 364.912 e mostra che la parte superiore ha sostituito un gruppo per aggregato (raggruppando per la colonna correlata BrowserID). L'aggregato non è dovuto alla ridondanza DISTINCTnel testo della query: è un'ottimizzazione che può essere introdotta da due regole di esplorazione, LASJNtoLASJNonDist e LASJOnLclDist . Disabilitare anche questi due produce questo piano:

DBCC RULEOFF ('LASJNtoLASJNonDist');
DBCC RULEOFF ('LASJOnLclDist');
DBCC RULEOFF ('GbAggToConstScanOrTop');
GO
DELETE FROM tblFEStatsBrowsers 
WHERE BrowserID NOT IN 
(
    SELECT DISTINCT BrowserID 
    FROM tblFEStatsPaperHits WITH (NOLOCK) 
    WHERE BrowserID IS NOT NULL
) OPTION (MAXDOP 1, LOOP JOIN, RECOMPILE);
GO
DBCC RULEON ('LASJNtoLASJNonDist');
DBCC RULEON ('LASJOnLclDist');
DBCC RULEON ('GbAggToConstScanOrTop');

Piano di spool

Tale piano ha un costo stimato di 40729,3 unità.

Senza la trasformazione da Group By to Top, l'ottimizzatore "naturalmente" sceglie un piano di hash join con BrowserIDaggregazione prima dell'anti semi join:

DBCC RULEOFF ('GbAggToConstScanOrTop');
GO
DELETE FROM tblFEStatsBrowsers 
WHERE BrowserID NOT IN 
(
    SELECT DISTINCT BrowserID 
    FROM tblFEStatsPaperHits WITH (NOLOCK) 
    WHERE BrowserID IS NOT NULL
) OPTION (MAXDOP 1, RECOMPILE);
GO
DBCC RULEON ('GbAggToConstScanOrTop');

Nessun piano superiore DOP 1

E senza la limitazione MAXDOP 1, un piano parallelo:

Nessun piano parallelo superiore

Un altro modo per "correggere" la query originale sarebbe quello di creare l'indice mancante su BrowserIDcui riporta il piano di esecuzione. I loop nidificati funzionano meglio quando il lato interno è indicizzato. Stimare la cardinalità per i semi join è impegnativo nel migliore dei casi. Non avere una corretta indicizzazione (la tabella di grandi dimensioni non ha nemmeno una chiave univoca!) Non aiuterà affatto.

Ho scritto di più su questo in Row Goals, Part 4: The Anti Join Anti Pattern .


3
Mi inchino a te, mi hai appena presentato diversi nuovi concetti che non ho mai incontrato prima. Proprio quando senti di sapere qualcosa, qualcuno là fuori ti metterà giù - in un buon modo :) L'aggiunta dell'indice sarebbe sicuramente di aiuto. Tuttavia, oltre a questa operazione una tantum, il campo non è mai accessibile / aggregato dalla colonna BrowserID e quindi preferirei salvare quei byte poiché la tabella è piuttosto grande (questo è solo uno dei tanti database identici). Non esiste una chiave univoca sul tavolo in quanto non presenta unicità univoca. Tutte le selezioni sono di PaperID e facoltativamente un punto.
Mark S. Rasmussen,

22

Quando eseguo il tuo script per creare un database di sole statistiche e la query nella domanda ottengo il seguente piano.

Piano

Le Cardinalità del Tavolo mostrate nel piano sono

  • tblFEStatsPaperHits: 48063400
  • tblFEStatsBrowsers : 339

Quindi stima che dovrà eseguire la scansione su tblFEStatsPaperHits339 volte. Ogni scansione ha il predicato correlato tblFEStatsBrowsers.BrowserID=tblFEStatsPaperHits.BrowserID AND tblFEStatsPaperHits.BrowserID IS NOT NULLche viene inserito nell'operatore di scansione.

Il piano non significa che ci saranno 339 scansioni complete comunque. Poiché è sotto un operatore anti semi join non appena viene trovata la prima riga corrispondente su ogni scansione, può cortocircuitare il resto. Il costo della sottostruttura stimato per questo nodo è 1.32603e l'intero piano è costato 1.41337.

Per l'Hash Join fornisce il seguente piano

Hash Join

Il piano complessivo è costato 418.415(circa 300 volte più costoso del piano di cicli nidificati) con la sola scansione dell'indice cluster completo su tblFEStatsPaperHitscosto da 206.8solo. Confrontarlo con la 1.32603stima di 339 scansioni parziali fornite in precedenza (costo stimato medio della scansione parziale = 0.003911592).

Quindi ciò indicherebbe che ogni scansione parziale costa 53.000 volte meno costosa di una scansione completa. Se i costi dovessero ridimensionarsi linearmente con il conteggio delle righe, ciò significherebbe che presuppone che in media dovrebbe elaborare solo 900 righe su ogni iterazione prima che trovi una riga corrispondente e possa cortocircuitare.

Non credo che i costi si ridimensionino in quel modo lineare comunque. Penso che includano anche alcuni elementi del costo di avvio fisso. Prova di vari valori TOPnella seguente query

SELECT TOP 147 BrowserID 
FROM [dbo].[tblFEStatsPaperHits] 

147fornisce il costo di sottostruttura stimato più vicino a 0.003911592a 0.0039113. In entrambi i casi è chiaro che sta basando i costi sul presupposto che ogni scansione dovrà elaborare solo una piccola parte della tabella, nell'ordine di centinaia di righe anziché di milioni.

Non sono sicuro esattamente su quali basi matematiche si basa questa ipotesi e in realtà non si sommano con le stime del conteggio delle righe nel resto del piano (le 236 righe stimate che escono dai loop dei cicli nidificati implicano che ci fossero 236 casi in cui non è stata trovata alcuna riga corrispondente ed è stata richiesta una scansione completa). Presumo che questo sia solo un caso in cui le ipotesi di modellazione fatte cadono in qualche modo e lasciano il piano dei cicli nidificati significativamente sotto costo.


20

Nel mio libro anche una scansione di 50 milioni di righe è inaccettabile ... Il mio solito trucco è materializzare i valori distinti e delegare il motore per tenerlo aggiornato:

create view [dbo].[vwFEStatsPaperHitsBrowserID]
with schemabinding
as
select BrowserID, COUNT_BIG(*) as big_count
from [dbo].[tblFEStatsPaperHits]
group by [BrowserID];
go

create unique clustered index [cdxVwFEStatsPaperHitsBrowserID] 
  on [vwFEStatsPaperHitsBrowserID]([BrowserID]);
go

Questo ti dà un indice materializzato di una riga per BrowserID, eliminando la necessità di scansionare 50 milioni di righe. Il motore lo manterrà per te e il QO lo userà "così com'è" nell'istruzione che hai pubblicato (senza alcun suggerimento o riscrittura di query).

Il rovescio della medaglia è ovviamente la contesa. Qualsiasi operazione di inserimento o cancellazione in tblFEStatsPaperHits(e suppongo sia una tabella di registrazione con inserti pesanti) dovrà serializzare l'accesso a un determinato ID browser. Ci sono modi per renderlo realizzabile (aggiornamenti ritardati, 2 registri graduali, ecc.) Se sei disposto ad acquistarlo.


Ti sento, ogni scansione così grande è generalmente inaccettabile. In questo caso è per alcune operazioni di pulizia dei dati una tantum, quindi sto optando per non creare indici aggiuntivi (e non posso farlo temporaneamente in quanto interromperebbe il sistema). Non ho EE ma dato che questo è una volta, i suggerimenti sarebbero a posto. La mia principale curiosità è stata su come il QO si sia alzato con il piano :) La tabella è una tabella di registrazione e ci sono inserti pesanti. Esiste una tabella di registrazione asincrona separata, che in seguito aggiorna le righe in tblFEStatsPaperHits in modo da poterlo gestire da solo, se necessario.
Mark S. Rasmussen,
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.