Ogni prodotto presenta bug e SQL Server non fa eccezione. L'uso delle funzionalità del prodotto in un modo leggermente insolito (o la combinazione di funzionalità relativamente nuove insieme) è un ottimo modo per trovarle. I bug possono essere interessanti e persino educativi, ma forse alcune delle gioie vengono perse quando la scoperta fa sì che il tuo cercapersone si spenga alle 4 del mattino, forse dopo una serata particolarmente social con gli amici...
Il bug oggetto di questo post è probabilmente ragionevolmente raro in natura, ma non è un classico caso limite. Conosco almeno un consulente che l'ha riscontrato in un sistema di produzione. Su un argomento completamente non correlato, dovrei cogliere l'occasione per salutare il Grumpy Old DBA (blog).
Inizierò con alcune informazioni rilevanti sui join di unione. Se sei certo di sapere già tutto ciò che c'è da sapere sull'unione di unioni o vuoi semplicemente andare al sodo, sentiti libero di scorrere fino alla sezione intitolata "Il bug".
Unisci Unisciti
Merge join non è una cosa terribilmente complicata e può essere molto efficiente nelle giuste circostanze. Richiede che i suoi input siano ordinati sulle chiavi di unione e funzioni al meglio in modalità uno-a-molti (dove almeno dei suoi input è univoco sulle chiavi di unione). Per join uno-a-molti di dimensioni moderate, il join di unione seriale non è affatto una cattiva scelta, a condizione che i requisiti di ordinamento dell'input possano essere soddisfatti senza eseguire un ordinamento esplicito.
Evitare un ordinamento si ottiene più comunemente sfruttando l'ordinamento fornito da un indice. Unisci join può anche sfruttare l'ordinamento preservato da un ordinamento precedente e inevitabile. Un aspetto interessante di merge join è che può interrompere l'elaborazione delle righe di input non appena uno dei due input esaurisce le righe. Un'ultima cosa:merge join non si preoccupa se l'ordinamento degli input è crescente o decrescente (sebbene entrambi gli input debbano essere gli stessi). L'esempio seguente utilizza una tabella di numeri standard per illustrare la maggior parte dei punti precedenti:
CREATE TABLE #T1 (col1 integer CONSTRAINT PK1 PRIMARY KEY (col1 DESC)); CREATE TABLE #T2 (col1 integer CONSTRAINT PK2 PRIMARY KEY (col1 DESC)); INSERT #T1 SELECT n FROM dbo.Numbers WHERE n BETWEEN 10000 AND 19999; INSERT #T2 SELECT n FROM dbo.Numbers WHERE n BETWEEN 18000 AND 21999;
Si noti che gli indici che impongono le chiavi primarie su queste due tabelle sono definiti discendenti. Il piano di query per INSERT
ha una serie di caratteristiche interessanti:
Leggendo da sinistra a destra (come è ragionevole!) l'Inserimento indice cluster ha la proprietà "Ordinamento richiesta DML" impostata. Ciò significa che l'operatore richiede le righe nell'ordine delle chiavi dell'indice cluster. L'indice cluster (che applica la chiave primaria in questo caso) è definito come DESC
, quindi le righe con valori più alti devono arrivare per prime. L'indice raggruppato sulla mia tabella Numbers è ASC
, quindi Query Optimizer evita un ordinamento esplicito cercando prima la corrispondenza più alta nella tabella Numbers (21.999), quindi effettuando la scansione verso la corrispondenza più bassa (18.000) in ordine inverso. La vista "Plan Tree" in SQL Sentry Plan Explorer mostra chiaramente la scansione inversa (all'indietro):
La scansione all'indietro inverte l'ordine naturale dell'indice. Una scansione all'indietro di un ASC
la chiave dell'indice restituisce le righe in ordine decrescente delle chiavi; una scansione all'indietro di un DESC
chiave indice restituisce le righe in ordine crescente di chiave. La "direzione di scansione" non indica di per sé l'ordine delle chiavi restituite:devi sapere se l'indice è ASC
o DESC
per prendere tale determinazione.
Utilizzando queste tabelle di test e dati (T1
ha 10.000 righe numerate da 10.000 a 19.999 comprese; T2
ha 4.000 righe numerate da 18.000 a 21.999) la seguente query unisce le due tabelle e restituisce i risultati in ordine decrescente di entrambe le chiavi:
SELECT T1.col1, T2.col1 FROM #T1 AS T1 JOIN #T2 AS T2 ON T2.col1 = T1.col1 ORDER BY T1.col1 DESC, T2.col1 DESC;
La query restituisce le 2.000 righe corrispondenti corrette come ci si aspetterebbe. Il piano post-esecuzione è il seguente:
L'unione di unione non è in esecuzione in modalità molti-a-molti (l'input superiore è univoco sulle chiavi di unione) e la stima della cardinalità di 2.000 righe è esattamente corretta. La scansione dell'indice cluster della tabella T2
è ordinato (anche se dobbiamo aspettare un momento per scoprire se quell'ordine è avanti o indietro) e anche la stima della cardinalità di 4.000 righe è esattamente corretta. La scansione dell'indice cluster della tabella T1
viene anche ordinato, ma sono state lette solo 2.001 righe mentre ne sono state stimate 10.000. La vista ad albero del piano mostra che entrambe le scansioni dell'indice raggruppate sono ordinate in avanti:
Ricordalo leggendo un DESC
indice FORWARD
produrrà le righe in ordine di chiave inverso. Questo è esattamente ciò che è richiesto da ORDER BY T1.col DESC, T2.col1 DESC
clausola, quindi non è necessario alcun ordinamento esplicito. Lo pseudo-codice per Merge Join uno a molti (riprodotto dal blog Merge Join di Craig Freedman) è:
La scansione in ordine decrescente di T1
restituisce righe a partire da 19.999 e scendendo verso 10.000. La scansione in ordine decrescente di T2
restituisce righe a partire da 21.999 e scendendo verso 18.000. Tutte le 4.000 righe in T2
vengono infine letti, ma il processo di unione iterativo si interrompe quando viene letto il valore della chiave 17.999 da T1
, perché T2
esaurisce le righe. L'elaborazione dell'unione viene quindi completata senza leggere completamente T1
. Legge le righe da 19.999 fino a 17.999 comprese; un totale di 2.001 righe come mostrato nel piano di esecuzione sopra.
Sentiti libero di eseguire nuovamente il test con ASC
indici invece, modificando anche il ORDER BY
clausola da DESC
a ASC
. Il piano di esecuzione prodotto sarà molto simile e non sarà necessario alcun tipo di ordinamento.
Per riassumere i punti che saranno importanti tra un momento, Merge Join richiede input ordinati per chiavi di unione, ma non importa se le chiavi sono ordinate crescente o decrescente.
Il bug
Per riprodurre il bug, almeno una delle nostre tabelle deve essere partizionata. Per mantenere i risultati gestibili, questo esempio utilizzerà solo un piccolo numero di righe, quindi anche la funzione di partizionamento necessita di piccoli limiti:
CREATE PARTITION FUNCTION PF (integer) AS RANGE RIGHT FOR VALUES (5, 10, 15); CREATE PARTITION SCHEME PS AS PARTITION PF ALL TO ([PRIMARY]);
La prima tabella contiene due colonne ed è partizionata sulla CHIAVE PRIMARIA:
CREATE TABLE dbo.T1 ( T1ID integer IDENTITY (1,1) NOT NULL, SomeID integer NOT NULL, CONSTRAINT [PK dbo.T1 T1ID] PRIMARY KEY CLUSTERED (T1ID) ON PS (T1ID) );
La seconda tabella non è partizionata. Contiene una chiave primaria e una colonna che si unirà alla prima tabella:
CREATE TABLE dbo.T2 ( T2ID integer IDENTITY (1,1) NOT NULL, T1ID integer NOT NULL, CONSTRAINT [PK dbo.T2 T2ID] PRIMARY KEY CLUSTERED (T2ID) ON [PRIMARY] );
I dati di esempio
La prima tabella ha 14 righe, tutte con lo stesso valore in SomeID
colonna. SQL Server assegna il IDENTITY
valori di colonna, numerati da 1 a 14.
INSERT dbo.T1 (SomeID) VALUES (123), (123), (123), (123), (123), (123), (123), (123), (123), (123), (123), (123), (123), (123);
La seconda tabella è semplicemente popolata con IDENTITY
valori dalla tabella uno:
INSERT dbo.T2 (T1ID) SELECT T1ID FROM dbo.T1;
I dati nelle due tabelle si presentano così:
La query di prova
La prima query unisce semplicemente entrambe le tabelle, applicando un singolo predicato della clausola WHERE (che corrisponde a tutte le righe in questo esempio notevolmente semplificato):
SELECT T2.T2ID FROM dbo.T1 AS T1 JOIN dbo.T2 AS T2 ON T2.T1ID = T1.T1ID WHERE T1.SomeID = 123;
Il risultato contiene tutte le 14 righe, come previsto:
A causa del numero ridotto di righe, l'ottimizzatore sceglie un piano di unione di cicli nidificati per questa query:
I risultati sono gli stessi (e sempre corretti) se si forza un hash o si unisce un join:
SELECT T2.T2ID FROM dbo.T1 AS T1 JOIN dbo.T2 AS T2 ON T2.T1ID = T1.T1ID WHERE T1.SomeID = 123 OPTION (HASH JOIN); SELECT T2.T2ID FROM dbo.T1 AS T1 JOIN dbo.T2 AS T2 ON T2.T1ID = T1.T1ID WHERE T1.SomeID = 123 OPTION (MERGE JOIN);
Il Merge Join è uno a molti, con un ordinamento esplicito su T1ID
richiesto per la tabella T2
.
Il problema dell'indice discendente
Tutto va bene finché un giorno (per buoni motivi che non devono preoccuparci qui) un altro amministratore aggiunge un indice discendente su SomeID
colonna della tabella 1:
CREATE NONCLUSTERED INDEX [dbo.T1 SomeID] ON dbo.T1 (SomeID DESC);
La nostra query continua a produrre risultati corretti quando l'ottimizzatore sceglie un ciclo nidificato o un join hash, ma è una storia diversa quando viene utilizzato un join unito. Quanto segue usa ancora un suggerimento per la query per forzare il Merge Join, ma questa è solo una conseguenza del basso numero di righe nell'esempio. L'ottimizzatore sceglierà naturalmente lo stesso piano Merge Join con dati di tabella diversi.
SELECT T2.T2ID FROM dbo.T1 AS T1 JOIN dbo.T2 AS T2 ON T2.T1ID = T1.T1ID WHERE T1.SomeID = 123 OPTION (MERGE JOIN);
Il piano di esecuzione è:
L'ottimizzatore ha scelto di utilizzare il nuovo indice, ma la query ora produce solo cinque righe di output:
Che fine hanno fatto le altre 9 righe? Per essere chiari, questo risultato non è corretto. I dati non sono cambiati, quindi tutte le 14 righe dovrebbero essere restituite (poiché lo sono ancora con un piano Nested Loops o Hash Join).
Causa e spiegazione
Il nuovo indice non cluster su SomeID
non è dichiarato come univoco, quindi la chiave dell'indice cluster viene aggiunta automaticamente a tutti i livelli di indice non cluster. SQL Server aggiunge il T1ID
colonna (la chiave del cluster) all'indice non cluster proprio come se avessimo creato l'indice in questo modo:
CREATE NONCLUSTERED INDEX [dbo.T1 SomeID] ON dbo.T1 (SomeID DESC, T1ID);
Nota la mancanza di un DESC
qualificatore sul T1ID
aggiunto in modo invisibile all'utente chiave. Le chiavi di indice sono ASC
per impostazione predefinita. Questo non è un problema in sé (sebbene contribuisca). La seconda cosa che accade automaticamente al nostro indice è che è partizionato allo stesso modo della tabella di base. Quindi, la specifica completa dell'indice, se dovessimo scriverla in modo esplicito, sarebbe:
CREATE NONCLUSTERED INDEX [dbo.T1 SomeID] ON dbo.T1 (SomeID DESC, T1ID ASC) ON PS (T1ID);
Questa è ora una struttura piuttosto complessa, con chiavi in tutti i tipi di ordini diversi. È abbastanza complesso da consentire a Query Optimizer di sbagliare quando ragiona sull'ordinamento fornito dall'indice. Per illustrare, considera la seguente semplice query:
SELECT T1ID, PartitionID = $PARTITION.PF(T1ID) FROM dbo.T1 WHERE SomeID = 123 ORDER BY T1ID ASC;
La colonna extra ci mostrerà solo a quale partizione appartiene la riga corrente. Altrimenti, è solo una semplice query che restituisce T1ID
valori in ordine crescente, WHERE SomeID = 123
. Sfortunatamente, i risultati non sono quelli specificati dalla query:
La query richiede quel T1ID
i valori dovrebbero essere restituiti in ordine crescente, ma non è quello che otteniamo. Otteniamo valori in ordine crescente per partizione , ma le partizioni stesse vengono restituite in ordine inverso! Se le partizioni sono state restituite in ordine crescente (e il T1ID
i valori sono rimasti ordinati all'interno di ciascuna partizione come mostrato) il risultato sarebbe corretto.
Il piano di query mostra che l'ottimizzatore è stato confuso dal DESC
principale chiave dell'indice e ho pensato che fosse necessario leggere le partizioni in ordine inverso per ottenere risultati corretti:
La ricerca della partizione inizia dalla partizione più a destra (4) e procede all'indietro fino alla partizione 1. Potresti pensare che potremmo risolvere il problema ordinando esplicitamente il numero di partizione ASC
nel ORDER BY
clausola:
SELECT T1ID, PartitionID = $PARTITION.PF(T1ID) FROM dbo.T1 WHERE SomeID = 123 ORDER BY PartitionID ASC, -- New! T1ID ASC;
Questa query restituisce gli stessi risultati (questo non è un errore di stampa o un errore di copia/incolla):
L'ID partizione è ancora in decrescente ordine (non crescente, come specificato) e T1ID
viene ordinato solo in ordine crescente all'interno di ciascuna partizione. Tale è la confusione dell'ottimizzatore, pensa davvero (fai un respiro profondo ora) che la scansione dell'indice della chiave iniziale-discendente partizionato in una direzione in avanti, ma con le partizioni invertite, risulterà nell'ordine specificato dalla query.
Non lo biasimo se devo essere sincero, anche le varie considerazioni sull'ordinamento mi fanno male la testa.
Come ultimo esempio, considera:
SELECT T1ID FROM dbo.T1 WHERE SomeID = 123 ORDER BY T1ID DESC;
I risultati sono:
Di nuovo, il T1ID
ordinamento all'interno di ogni partizione è correttamente discendente, ma le partizioni stesse sono elencate all'indietro (va da 1 a 3 lungo le righe). Se le partizioni venissero restituite in ordine inverso, i risultati sarebbero correttamente 14, 13, 12, 11, 10, 9, … 5, 4, 3, 2, 1
.
Torna all'unione Unisci
La causa dei risultati errati con la query Unisci join è ora evidente:
SELECT T2.T2ID FROM dbo.T1 AS T1 JOIN dbo.T2 AS T2 ON T2.T1ID = T1.T1ID WHERE T1.SomeID = 123 OPTION (MERGE JOIN);
Il Merge Join richiede input ordinati. L'input da T2
è ordinato esplicitamente per T1TD
quindi va bene. L'ottimizzatore motiva erroneamente che l'indice su T1
può fornire righe in T1ID
ordine. Come abbiamo visto, non è così. Index Seek produce lo stesso output di una query che abbiamo già visto:
SELECT T1ID FROM dbo.T1 WHERE SomeID = 123 ORDER BY T1ID ASC;
Solo le prime 5 righe sono in T1ID
ordine. Il valore successivo (5) non è certamente in ordine crescente e Merge Join lo interpreta come end-of-stream piuttosto che produrre un errore (personalmente mi aspettavo un'affermazione al dettaglio qui). Ad ogni modo, l'effetto è che il Merge Join termina in modo errato l'elaborazione in anticipo. Ricordiamo che i risultati (incompleti) sono:
Conclusione
Questo è un bug molto grave a mio avviso. Una semplice ricerca dell'indice può restituire risultati che non rispettano il ORDER BY
clausola. Più precisamente, il ragionamento interno dell'ottimizzatore è completamente rotto per indici non cluster partizionati non univoci con una chiave iniziale discendente.
Sì, questo è un leggermente disposizione insolita. Ma, come abbiamo visto, i risultati corretti possono essere improvvisamente sostituiti da risultati errati solo perché qualcuno ha aggiunto un indice discendente. Ricorda che l'indice aggiunto sembrava abbastanza innocente:nessun ASC/DESC
esplicito mancata corrispondenza delle chiavi e nessun partizionamento esplicito.
Il bug non è limitato a Unisci join. Potenzialmente qualsiasi query che coinvolge una tabella partizionata e che si basa sull'ordinamento degli indici (esplicito o implicito) potrebbe cadere vittima. Questo bug esiste in tutte le versioni di SQL Server dal 2008 al 2014 CTP 1 incluso. Il database di Windows SQL Azure non supporta il partizionamento, quindi il problema non si pone. SQL Server 2005 utilizzava un modello di implementazione diverso per il partizionamento (basato su APPLY
) e non soffre nemmeno di questo problema.
Se hai un momento, considera di votare il mio articolo Connect per questo bug.
Risoluzione
La correzione di questo problema è ora disponibile e documentata in un articolo della Knowledge Base. Tieni presente che la correzione richiede un aggiornamento del codice e il flag di traccia 4199 , che abilita una serie di altre modifiche a Query Processor. È insolito che un bug con risultati errati venga corretto sotto 4199. Ho chiesto chiarimenti in merito e la risposta è stata:
Anche se questo problema comporta risultati errati come altri hotfix che coinvolgono il processore di query, abbiamo abilitato questa correzione solo con il flag di traccia 4199 per SQL Server 2008, 2008 R2 e 2012. Tuttavia, questa correzione è "attiva" da predefinito senza il flag di traccia in SQL Server 2014 RTM.