SQL Server non ottimizza l'unione di unione parallela su due tabelle con partizioni equivalenti


21

Ci scusiamo in anticipo per la domanda molto dettagliata. Ho incluso query per generare un set di dati completo per la riproduzione del problema e sto eseguendo SQL Server 2012 su una macchina a 32 core. Tuttavia, non penso che questo sia specifico per SQL Server 2012 e ho forzato un MAXDOP di 10 per questo esempio particolare.

Ho due tabelle che sono partizionate usando lo stesso schema di partizione. Quando li ho uniti sulla colonna utilizzata per il partizionamento, ho notato che SQL Server non è in grado di ottimizzare un join di unione parallela quanto ci si potrebbe aspettare e quindi sceglie di utilizzare invece un HASH JOIN. In questo caso particolare, sono in grado di simulare manualmente un MERGE JOIN parallelo molto più ottimale suddividendo la query in 10 intervalli disgiunti in base alla funzione di partizione ed eseguendo ciascuna di queste query contemporaneamente in SSMS. Utilizzando WAITFOR per eseguirle tutte esattamente nello stesso momento, il risultato è che tutte le query vengono completate in circa il 40% del tempo totale utilizzato dal parallelo HASH JOIN originale.

Esiste un modo per convincere SQL Server a eseguire questa ottimizzazione da solo nel caso di tabelle con partizioni equivalenti? Comprendo che SQL Server può generalmente comportare un notevole sovraccarico per rendere parallelo MERGE JOIN, ma in questo caso sembra che ci sia un metodo di sharding molto naturale con un sovraccarico minimo. Forse è solo un caso specializzato che l'ottimizzatore non è ancora abbastanza intelligente da riconoscere?

Ecco l'SQL per impostare un set di dati semplificato per riprodurre questo problema:

/* Create the first test data table */
CREATE TABLE test_transaction_properties 
    ( transactionID INT NOT NULL IDENTITY(1,1)
    , prop1 INT NULL
    , prop2 FLOAT NULL
    )

/* Populate table with pseudo-random data (the specific data doesn't matter too much for this example) */
;WITH E1(N) AS (
    SELECT 1 UNION ALL SELECT 1 UNION ALL SELECT 1 UNION ALL SELECT 1 UNION ALL SELECT 1 
    UNION ALL SELECT 1 UNION ALL SELECT 1 UNION ALL SELECT 1 UNION ALL SELECT 1 UNION ALL SELECT 1
)
, E2(N) AS (SELECT 1 FROM E1 a CROSS JOIN E1 b)
, E4(N) AS (SELECT 1 FROM E2 a CROSS JOIN E2 b)
, E8(N) AS (SELECT 1 FROM E4 a CROSS JOIN E4 b)
INSERT INTO test_transaction_properties WITH (TABLOCK) (prop1, prop2)
SELECT TOP 10000000 (ABS(CAST(CAST(NEWID() AS VARBINARY) AS INT)) % 5) + 1 AS prop1
                , ABS(CAST(CAST(NEWID() AS VARBINARY) AS INT)) * rand() AS prop2
FROM E8

/* Create the second test data table */
CREATE TABLE test_transaction_item_detail
    ( transactionID INT NOT NULL
    , productID INT NOT NULL
    , sales FLOAT NULL
    , units INT NULL
    )

 /* Populate the second table such that each transaction has one or more items
     (again, the specific data doesn't matter too much for this example) */
INSERT INTO test_transaction_item_detail WITH (TABLOCK) (transactionID, productID, sales, units)
SELECT t.transactionID, p.productID, 100 AS sales, 1 AS units
FROM test_transaction_properties t
JOIN (
    SELECT 1 as productRank, 1 as productId
    UNION ALL SELECT 2 as productRank, 12 as productId
    UNION ALL SELECT 3 as productRank, 123 as productId
    UNION ALL SELECT 4 as productRank, 1234 as productId
    UNION ALL SELECT 5 as productRank, 12345 as productId
) p
    ON p.productRank <= t.prop1

/* Divides the transactions evenly into 10 partitions */
CREATE PARTITION FUNCTION [pf_test_transactionId] (INT)
AS RANGE RIGHT
FOR VALUES
(1,1000001,2000001,3000001,4000001,5000001,6000001,7000001,8000001,9000001)

CREATE PARTITION SCHEME [ps_test_transactionId]
AS PARTITION [pf_test_transactionId]
ALL TO ( [PRIMARY] )

/* Apply the same partition scheme to both test data tables */
ALTER TABLE test_transaction_properties
ADD CONSTRAINT PK_test_transaction_properties
PRIMARY KEY (transactionID)
ON ps_test_transactionId (transactionID)

ALTER TABLE test_transaction_item_detail
ADD CONSTRAINT PK_test_transaction_item_detail
PRIMARY KEY (transactionID, productID)
ON ps_test_transactionId (transactionID)

Ora siamo finalmente pronti per riprodurre la query non ottimale!

/* This query produces a HASH JOIN using 20 threads without the MAXDOP hint,
    and the same behavior holds in that case.
    For simplicity here, I have limited it to 10 threads. */
SELECT COUNT(*)
FROM test_transaction_item_detail i
JOIN test_transaction_properties t
    ON t.transactionID = i.transactionID
OPTION (MAXDOP 10)

inserisci qui la descrizione dell'immagine

inserisci qui la descrizione dell'immagine

Tuttavia, l'utilizzo di un singolo thread per elaborare ciascuna partizione (esempio per la prima partizione di seguito) porterebbe a un piano molto più efficiente. Ho provato questo eseguendo una query come quella qui sotto per ciascuna delle 10 partizioni esattamente nello stesso momento, e tutte e 10 sono finite in poco più di 1 secondo:

SELECT COUNT(*)
FROM test_transaction_item_detail i
INNER MERGE JOIN test_transaction_properties t
    ON t.transactionID = i.transactionID
WHERE t.transactionID BETWEEN 1 AND 1000000
OPTION (MAXDOP 1)

inserisci qui la descrizione dell'immagine inserisci qui la descrizione dell'immagine

Risposte:


18

Hai ragione sul fatto che l'ottimizzatore di SQL Server preferisce non generare MERGEpiani di join paralleli (costa un'alternativa piuttosto elevata). Il parallelismo MERGErichiede sempre scambi di ripartizionamento su entrambi gli input di join e, cosa ancora più importante, richiede che l'ordine delle righe venga preservato in tutti gli scambi.

Il parallelismo è più efficace quando ogni thread può essere eseguito in modo indipendente; la conservazione degli ordini porta spesso a frequenti attese di sincronizzazione e, in ultima analisi, può causare lo scambio di scambi tempdbper risolvere una condizione di deadlock all'interno della query.

Questi problemi possono essere aggirati eseguendo più istanze dell'intera query su un thread ciascuno, con ogni thread che elabora un intervallo esclusivo di dati. Tuttavia, questa non è una strategia che l'ottimizzatore considera in modo nativo. Allo stato attuale, il modello originale di SQL Server per il parallelismo interrompe la query durante gli scambi ed esegue i segmenti del piano formati da tali suddivisioni su più thread.

Esistono modi per ottenere l'esecuzione di interi piani di query su più thread su intervalli di set di dati esclusivi, ma richiedono trucchi che non tutti saranno soddisfatti (e non saranno supportati da Microsoft o garantiti per funzionare in futuro). Uno di questi approcci consiste nell'iterare le partizioni di una tabella partizionata e assegnare a ciascun thread il compito di produrre un totale parziale. Il risultato è il SUMconteggio delle righe restituito da ogni thread indipendente:

Ottenere i numeri di partizione è abbastanza facile dai metadati:

DECLARE @P AS TABLE
(
    partition_number integer PRIMARY KEY
);

INSERT @P (partition_number)
SELECT
    p.partition_number
FROM sys.partitions AS p 
WHERE 
    p.[object_id] = OBJECT_ID(N'test_transaction_properties', N'U')
    AND p.index_id = 1;

Quindi usiamo questi numeri per guidare un join correlato ( APPLY) e la $PARTITIONfunzione per limitare ogni thread al numero di partizione corrente:

SELECT
    row_count = SUM(Subtotals.cnt)
FROM @P AS p
CROSS APPLY
(
    SELECT
        cnt = COUNT_BIG(*)
    FROM dbo.test_transaction_item_detail AS i
    JOIN dbo.test_transaction_properties AS t ON
        t.transactionID = i.transactionID
    WHERE 
        $PARTITION.pf_test_transactionId(t.transactionID) = p.partition_number
        AND $PARTITION.pf_test_transactionId(i.transactionID) = p.partition_number
) AS SubTotals;

Il piano di query mostra un MERGEjoin eseguito per ogni riga della tabella @P. Le proprietà di scansione dell'indice cluster confermano che per ogni iterazione viene elaborata una sola partizione:

Applica piano seriale

Sfortunatamente, ciò comporta solo l'elaborazione seriale sequenziale delle partizioni. Sul set di dati fornito, il mio laptop a 4 core (hyperthreaded a 8) restituisce il risultato corretto in 7 secondi con tutti i dati in memoria.

Per ottenere l' MERGEesecuzione simultanea dei piani secondari, è necessario un piano parallelo in cui gli ID di partizione sono distribuiti sui thread disponibili ( MAXDOP) e ciascun MERGEpiano secondario viene eseguito su un singolo thread utilizzando i dati in una partizione. Sfortunatamente, l'ottimizzatore spesso decide contro il parallelo MERGEper motivi di costo e non esiste un modo documentato per forzare un piano parallelo. C'è un modo non documentato (e non supportato), usando il flag di traccia 8649 :

SELECT
    row_count = SUM(Subtotals.cnt)
FROM @P AS p
CROSS APPLY
(
    SELECT
        cnt = COUNT_BIG(*)
    FROM dbo.test_transaction_item_detail AS i
    JOIN dbo.test_transaction_properties AS t ON
        t.transactionID = i.transactionID
    WHERE 
        $PARTITION.pf_test_transactionId(t.transactionID) = p.partition_number
        AND $PARTITION.pf_test_transactionId(i.transactionID) = p.partition_number
) AS SubTotals
OPTION (QUERYTRACEON 8649);

Ora il piano di query mostra che i numeri di partizione @Pvengono distribuiti tra i thread su base round robin. Ogni thread esegue il lato interno dei loop nidificati per una singola partizione, raggiungendo il nostro obiettivo di elaborare contemporaneamente dati disgiunti. Lo stesso risultato viene ora restituito in 3 secondi sui miei 8 hyper-core, con tutti e otto al 100% di utilizzo.

Parallelo APPLICARE

Non ti consiglio di usare questa tecnica necessariamente - vedi i miei avvertimenti precedenti - ma risponde alla tua domanda.

Vedi il mio articolo Miglioramento delle prestazioni della tabella partizionata per ulteriori dettagli.

columnstore

Visto che stai usando SQL Server 2012 (e supponendo che sia Enterprise) hai anche la possibilità di usare un indice columnstore. Ciò mostra il potenziale dei join hash in modalità batch in cui è disponibile memoria sufficiente:

CREATE NONCLUSTERED COLUMNSTORE INDEX cs 
ON dbo.test_transaction_properties (transactionID);

CREATE NONCLUSTERED COLUMNSTORE INDEX cs 
ON dbo.test_transaction_item_detail (transactionID);

Con questi indici in atto, la query ...

SELECT
    COUNT_BIG(*)
FROM dbo.test_transaction_properties AS ttp
JOIN dbo.test_transaction_item_detail AS ttid ON
    ttid.transactionID = ttp.transactionID;

... risulta nel seguente piano di esecuzione dall'ottimizzatore senza alcun trucco:

Piano archivio colonne 1

Risultati corretti in 2 secondi , ma l'eliminazione dell'elaborazione in modalità riga per l'aggregato scalare aiuta ancora di più:

SELECT
    COUNT_BIG(*)
FROM dbo.test_transaction_properties AS ttp
JOIN dbo.test_transaction_item_detail AS ttid ON
    ttid.transactionID = ttp.transactionID
GROUP BY
    ttp.transactionID % 1;

Columnstore ottimizzato

La query dell'archivio di colonne ottimizzata viene eseguita in 851ms .

Geoff Patterson ha creato il bug report Partition Wise Joins ma è stato chiuso come non risolto.


5
Ottima esperienza di apprendimento qui. grazie. +1
Edward Dortland,

1
Grazie Paolo! Grandi informazioni qui, e certamente affronta la questione in dettaglio.
Geoff Patterson,

2
Grazie Paolo! Grandi informazioni qui, e certamente affronta la questione in dettaglio. Siamo in un ambiente misto SQL 2008/2012, ma prenderò in considerazione l'esplorazione del negozio di colonne per il futuro. Ovviamente, desidero ancora che SQL Server possa effettivamente sfruttare un join di unione parallela - e i requisiti di memoria molto più bassi che potrebbe avere - nel mio caso d'uso :) Ho presentato il seguente problema di Connect nel caso in cui qualcuno si preoccupasse di dare un'occhiata e commentare o vota su di esso: connect.microsoft.com/SQLServer/feedback/details/759266/…
Geoff Patterson

0

Il modo per far funzionare l'ottimizzatore nel modo in cui pensi meglio è tramite suggerimenti di query.

In questo caso, OPTION (MERGE JOIN)

Oppure puoi fare tutto il maiale e usare USE PLAN


Non lo farei personalmente: il suggerimento sarà utile solo per il volume e la distribuzione dei dati attuali.
gbn

La cosa interessante è che l'uso di OPTION (MERGE JOIN) porta a un piano molto peggiore. L'ottimizzatore non è abbastanza intelligente da capire che MERGE JOIN può essere suddiviso in partizioni dalla funzione di partizione e l'applicazione di questo suggerimento richiede una query di circa 46 secondi. Molto frustrante!

@gbn che è presumibilmente il motivo per cui l'ottimizzatore sta andando per l'hash join in primo luogo?

@gpatterson Che fastidio! :)

Cosa succede se si forza il partizionamento manualmente tramite un'unione (ovvero: la tua query breve unita ad altre query simili)?
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.