Database
 sql >> Database >  >> RDS >> Database

Il problema con le funzioni e le viste della finestra

Introduzione

Dalla loro introduzione in SQL Server 2005, la finestra funziona come ROW_NUMBER e RANK hanno dimostrato di essere estremamente utili nella risoluzione di un'ampia varietà di problemi T-SQL comuni. Nel tentativo di generalizzare tali soluzioni, i progettisti di database spesso cercano di incorporarle nelle viste per promuovere l'incapsulamento e il riutilizzo del codice. Sfortunatamente, una limitazione in Query Optimizer di SQL Server spesso significa che le viste contenenti funzioni della finestra non funzionano come previsto. Questo post illustra un esempio illustrativo del problema, illustra in dettaglio i motivi e fornisce una serie di soluzioni alternative.

Questo problema può verificarsi anche in tabelle derivate, espressioni di tabelle comuni e funzioni in linea, ma lo vedo più spesso con le viste perché sono scritte intenzionalmente per essere più generiche.

Funzioni della finestra

Le funzioni della finestra si distinguono per la presenza di un OVER() clausola e sono disponibili in tre varietà:

  • Funzioni della finestra di classificazione
    • ROW_NUMBER
    • RANK
    • DENSE_RANK
    • NTILE
  • Funzioni della finestra di aggregazione
    • MIN , MAX , AVG , SUM
    • COUNT , COUNT_BIG
    • CHECKSUM_AGG
    • STDEV , STDEVP , VAR , VARP
  • Funzioni della finestra analitica
    • LAG , LEAD
    • FIRST_VALUE , LAST_VALUE
    • PERCENT_RANK , PERCENTILE_CONT , PERCENTILE_DISC , CUME_DIST

Le funzioni di classificazione e aggregazione della finestra sono state introdotte in SQL Server 2005 e notevolmente ampliate in SQL Server 2012. Le funzioni della finestra analitica sono nuove per SQL Server 2012.

Tutte le funzioni della finestra sopra elencate sono soggette alla limitazione dell'ottimizzatore descritta in dettaglio in questo articolo.

Esempio

Utilizzando il database di esempio AdventureWorks, l'attività da svolgere è scrivere una query che restituisca tutte le transazioni del prodotto n. 878 che si sono verificate nella data più recente disponibile. Esistono molti modi per esprimere questo requisito in T-SQL, ma sceglieremo di scrivere una query che utilizzi una funzione di windowing. Il primo passaggio consiste nel trovare i record delle transazioni per il prodotto n. 878 e classificarli in ordine di data decrescente:

SELECT
    th.TransactionID,
    th.ReferenceOrderID,
    th.TransactionDate,
    th.Quantity,
    rnk = RANK() OVER (
        ORDER BY th.TransactionDate DESC)
FROM Production.TransactionHistory AS th
WHERE
    th.ProductID = 878
ORDER BY
    rnk;

I risultati della query sono come previsto, con sei transazioni che si verificano nella data più recente disponibile. Il piano di esecuzione contiene un triangolo di avvertimento, che ci avverte di un indice mancante:

Come al solito per i suggerimenti sugli indici mancanti, dobbiamo ricordare che la raccomandazione non è il risultato di un'analisi approfondita della query:è più un'indicazione che dobbiamo riflettere un po' su come questa query accede ai dati di cui ha bisogno.

L'indice suggerito sarebbe sicuramente più efficiente della scansione completa della tabella, poiché consentirebbe una ricerca dell'indice per il particolare prodotto a cui siamo interessati. L'indice coprirebbe anche tutte le colonne necessarie, ma non eviterebbe l'ordinamento (per TransactionDate discendente). L'indice ideale per questa query consentirebbe una ricerca su ProductID , restituisci i record selezionati al contrario TransactionDate ordinare e coprire le altre colonne restituite:

CREATE NONCLUSTERED INDEX ix
ON Production.TransactionHistory
    (ProductID, TransactionDate DESC)
INCLUDE 
    (ReferenceOrderID, Quantity);

Con quell'indice in atto, il piano di esecuzione è molto più efficiente. La scansione dell'indice cluster è stata sostituita da una ricerca di intervallo e non è più necessario un ordinamento esplicito:

Il passaggio finale per questa query consiste nel limitare i risultati alle sole righe che si classificano n. 1. Non possiamo filtrare direttamente in WHERE clausola della nostra query perché le funzioni della finestra possono apparire solo nel SELECT e ORDER BY clausole.

È possibile aggirare questa restrizione utilizzando una tabella derivata, un'espressione di tabella comune, una funzione o una vista. In questa occasione, utilizzeremo un'espressione di tabella comune (ovvero una vista in linea):

WITH RankedTransactions AS
(
    SELECT
        th.TransactionID,
        th.ReferenceOrderID,
        th.TransactionDate,
        th.Quantity,
        rnk = RANK() OVER (
            ORDER BY th.TransactionDate DESC)
    FROM Production.TransactionHistory AS th
    WHERE
        th.ProductID = 878
)
SELECT
    TransactionID,
    ReferenceOrderID,
    TransactionDate,
    Quantity
FROM RankedTransactions
WHERE
    rnk = 1;

Il piano di esecuzione è lo stesso di prima, con un filtro aggiuntivo per restituire solo le righe classificate n. 1:

La query restituisce le sei righe equamente classificate che ci aspettiamo:

Generalizzazione della query

Si scopre che la nostra query è molto utile, quindi viene presa la decisione di generalizzarla e memorizzare la definizione in una vista. Affinché funzioni con qualsiasi prodotto, dobbiamo fare due cose:restituire il ProductID dalla vista e partiziona la funzione di ranking per prodotto:

CREATE VIEW dbo.MostRecentTransactionsPerProduct
WITH SCHEMABINDING
AS
SELECT
    sq1.ProductID,
    sq1.TransactionID,
    sq1.ReferenceOrderID,
    sq1.TransactionDate,
    sq1.Quantity
FROM 
(
    SELECT
        th.ProductID,
        th.TransactionID,
        th.ReferenceOrderID,
        th.TransactionDate,
        th.Quantity,
        rnk = RANK() OVER (
            PARTITION BY th.ProductID
            ORDER BY th.TransactionDate DESC)
    FROM Production.TransactionHistory AS th
) AS sq1
WHERE
    sq1.rnk = 1;

Selezionando tutte le righe dalla vista si ottiene il seguente piano di esecuzione e risultati corretti:

Ora possiamo trovare le transazioni più recenti per il prodotto 878 con una query molto più semplice nella vista:

SELECT
    mrt.ProductID,
    mrt.TransactionID,
    mrt.ReferenceOrderID,
    mrt.TransactionDate,
    mrt.Quantity
FROM dbo.MostRecentTransactionsPerProduct AS mrt 
WHERE
    mrt.ProductID = 878;

La nostra aspettativa è che il piano di esecuzione per questa nuova query sia esattamente lo stesso di prima della creazione della vista. Query Optimizer dovrebbe essere in grado di eseguire il push del filtro specificato in WHERE clausola in basso nella vista, risultando in una ricerca dell'indice.

Tuttavia, a questo punto dobbiamo fermarci e riflettere un po'. Query Optimizer può produrre solo piani di esecuzione garantiti per produrre gli stessi risultati della specifica della query logica:è sicuro inviare il nostro WHERE clausola nella vista?PARTITION BY clausola della funzione finestra nella vista. Il ragionamento è che l'eliminazione di gruppi completi (partizioni) dalla funzione finestra non influirà sulla classifica delle righe restituite dalla query. La domanda è:Query Optimizer di SQL Server lo sa? La risposta dipende dalla versione di SQL Server in esecuzione.

Piano di esecuzione di SQL Server 2005

Uno sguardo alle proprietà del filtro in questo piano mostra che applica due predicati:

Il ProductID = 878 il predicato non è stato inserito nella vista, risultando in un piano che esegue la scansione del nostro indice, classificando ogni riga della tabella prima di filtrare il prodotto n. 878 e le righe classificate n. 1.

Query Optimizer di SQL Server 2005 non è in grado di eseguire il push di predicati appropriati oltre una funzione finestra in un ambito di query inferiore (vista, espressione di tabella comune, funzione in linea o tabella derivata). Questa limitazione si applica a tutte le build di SQL Server 2005.

Piano di esecuzione di SQL Server 2008+

Questo è il piano di esecuzione per la stessa query su SQL Server 2008 o versioni successive:

Il ProductID predicato è stato spinto con successo oltre gli operatori di ranking, sostituendo la scansione dell'indice con l'efficiente ricerca dell'indice.

Query Optimizer 2008 include una nuova regola di semplificazione SelOnSeqPrj (seleziona nel progetto di sequenza) che è in grado di spingere predicati di ambito esterno sicuri oltre le funzioni della finestra. Per produrre il piano meno efficiente per questa query in SQL Server 2008 o versioni successive, dobbiamo disabilitare temporaneamente questa funzionalità di Query Optimizer:

SELECT
    mrt.ProductID,
    mrt.TransactionID,
    mrt.ReferenceOrderID,
    mrt.TransactionDate,
    mrt.Quantity
FROM dbo.MostRecentTransactionsPerProduct AS mrt 
WHERE
    mrt.ProductID = 878
OPTION (QUERYRULEOFF SelOnSeqPrj);

Sfortunatamente, il SelOnSeqPrj regola di semplificazione funziona solo quando il predicato esegue un confronto con una costante . Per questo motivo, la query seguente produce il piano non ottimale su SQL Server 2008 e versioni successive:

DECLARE @ProductID INT = 878;
 
SELECT
    mrt.ProductID,
    mrt.TransactionID,
    mrt.ReferenceOrderID,
    mrt.TransactionDate,
    mrt.Quantity
FROM dbo.MostRecentTransactionsPerProduct AS mrt 
WHERE
    mrt.ProductID = @ProductID;

Il problema può verificarsi anche quando il predicato utilizza un valore costante. SQL ServerSQL Server può decidere di parametrizzare automaticamente le query banali (una per la quale esiste un ovvio piano migliore). Se la parametrizzazione automatica ha esito positivo, l'ottimizzatore vede un parametro invece di una costante e SelOnSeqPrj regola non viene applicata.

Per le query in cui non viene tentata la parametrizzazione automatica (o in cui si ritiene che non sia sicura), l'ottimizzazione potrebbe comunque non riuscire, se l'opzione del database per FORCED PARAMETERIZATION è acceso. La nostra query di test (con il valore costante 878) non è sicura per la parametrizzazione automatica, ma l'impostazione di parametrizzazione forzata ha la precedenza su questo, risultando in un piano inefficiente:

ALTER DATABASE AdventureWorks
SET PARAMETERIZATION FORCED;
GO
SELECT
    mrt.ProductID,
    mrt.TransactionID,
    mrt.ReferenceOrderID,
    mrt.TransactionDate,
    mrt.Quantity
FROM dbo.MostRecentTransactionsPerProduct AS mrt 
WHERE
    mrt.ProductID = 878;
GO
ALTER DATABASE AdventureWorks
SET PARAMETERIZATION SIMPLE;

Soluzione per SQL Server 2008+

Per consentire all'ottimizzatore di "vedere" un valore costante per la query che fa riferimento a una variabile o parametro locale, possiamo aggiungere un OPTION (RECOMPILE) suggerimento per la query:

DECLARE @ProductID INT = 878;
 
SELECT
    mrt.ProductID,
    mrt.TransactionID,
    mrt.ReferenceOrderID,
    mrt.TransactionDate,
    mrt.Quantity
FROM dbo.MostRecentTransactionsPerProduct AS mrt 
WHERE
    mrt.ProductID = @ProductID
OPTION (RECOMPILE);

Nota: Il piano di esecuzione pre-esecuzione ("stimato") mostra ancora una scansione dell'indice perché il valore della variabile non è ancora effettivamente impostato. Quando la query viene eseguita , tuttavia, il piano di esecuzione mostra il piano di ricerca dell'indice desiderato:

Il SelOnSeqPrj la regola non esiste in SQL Server 2005, quindi OPTION (RECOMPILE) non può aiutare lì. Nel caso ve lo stiate chiedendo, l'OPTION (RECOMPILE) la soluzione alternativa comporta una ricerca anche se l'opzione del database per la parametrizzazione forzata è attiva.

Soluzione alternativa per tutte le versioni n. 1

In alcuni casi, è possibile sostituire la vista problematica, l'espressione di tabella comune o la tabella derivata con una funzione parametrizzata con valori di tabella in linea:

CREATE FUNCTION dbo.MostRecentTransactionsForProduct
(
    @ProductID integer
)  
RETURNS TABLE
WITH SCHEMABINDING AS
RETURN
    SELECT
        sq1.ProductID,
        sq1.TransactionID,
        sq1.ReferenceOrderID,
        sq1.TransactionDate,
        sq1.Quantity
    FROM 
    (
        SELECT
            th.ProductID,
            th.TransactionID,
            th.ReferenceOrderID,
            th.TransactionDate,
            th.Quantity,
            rnk = RANK() OVER (
                PARTITION BY th.ProductID
                ORDER BY th.TransactionDate DESC)
        FROM Production.TransactionHistory AS th
        WHERE
            th.ProductID = @ProductID
    ) AS sq1
    WHERE
        sq1.rnk = 1;

Questa funzione inserisce in modo esplicito il ProductID predicato nello stesso ambito della funzione finestra, evitando la limitazione dell'ottimizzatore. Scritta per utilizzare la funzione in-line, la nostra query di esempio diventa:

SELECT
    mrt.ProductID,
    mrt.TransactionID,
    mrt.ReferenceOrderID,
    mrt.TransactionDate,
    mrt.Quantity
FROM dbo.MostRecentTransactionsForProduct(878) AS mrt;

Ciò produce il piano di ricerca dell'indice desiderato in tutte le versioni di SQL Server che supportano le funzioni della finestra. Questa soluzione alternativa produce una ricerca anche quando il predicato fa riferimento a un parametro o a una variabile locale – OPTION (RECOMPILE) non è richiesto.PARTITION BY clausola e di non restituire più il ProductID colonna. Ho lasciato la definizione uguale alla vista che ha sostituito per illustrare più chiaramente la causa delle differenze del piano di esecuzione.

Soluzione alternativa per tutte le versioni n. 2

La seconda soluzione si applica solo alle funzioni della finestra di classificazione che vengono filtrate per restituire le righe numerate o classificate n. 1 (utilizzando ROW_NUMBER , RANK o DENSE_RANK ). Tuttavia, questo è un uso molto comune, quindi vale la pena menzionarlo.

Un ulteriore vantaggio è che questa soluzione alternativa può produrre piani ancora più efficienti rispetto all'indice cerca i piani visti in precedenza. Come promemoria, il miglior piano precedente era simile al seguente:

Quel piano di esecuzione è 1.918 righe anche se alla fine restituisce solo 6 . Possiamo migliorare questo piano di esecuzione utilizzando la funzione finestra in un ORDER BY clausola invece di classificare le righe e quindi filtrare per la classifica n. 1:

SELECT TOP (1) WITH TIES
    th.TransactionID,
    th.ReferenceOrderID,
    th.TransactionDate,
    th.Quantity
FROM Production.TransactionHistory AS th
WHERE
    th.ProductID = 878
ORDER BY
    RANK() OVER (
        ORDER BY th.TransactionDate DESC);

Quella query illustra bene l'uso di una funzione di finestra nel ORDER BY clausola, ma possiamo fare anche meglio, eliminando completamente la funzione window:

SELECT TOP (1) WITH TIES
    th.TransactionID,
    th.ReferenceOrderID,
    th.TransactionDate,
    th.Quantity
FROM Production.TransactionHistory AS th
WHERE
    th.ProductID = 878
ORDER BY
    th.TransactionDate DESC;

Questo piano legge solo 7 righe dalla tabella per restituire lo stesso set di risultati a 6 righe. Perché 7 righe? L'operatore Top è in esecuzione in WITH TIES modalità:

Continua a richiedere una riga alla volta dal suo sottoalbero fino a quando non cambia TransactionDate. La settima riga è necessaria affinché il Top sia sicuro che non si qualificheranno più righe con valore pari.

Possiamo estendere la logica della query precedente per sostituire la definizione della vista problematica:

ALTER VIEW dbo.MostRecentTransactionsPerProduct
WITH SCHEMABINDING
AS
SELECT
    p.ProductID,
    Ranked1.TransactionID,
    Ranked1.ReferenceOrderID,
    Ranked1.TransactionDate,
    Ranked1.Quantity
FROM
    -- List of product IDs
    (SELECT ProductID FROM Production.Product) AS p
CROSS APPLY
(
    -- Returns rank #1 results for each product ID
    SELECT TOP (1) WITH TIES
        th.TransactionID,
        th.ReferenceOrderID,
        th.TransactionDate,
        th.Quantity
    FROM Production.TransactionHistory AS th
    WHERE
        th.ProductID = p.ProductID
    ORDER BY
        th.TransactionDate DESC
) AS Ranked1;

La vista ora usa un CROSS APPLY per combinare i risultati del nostro ORDER BY ottimizzato interrogazione per ogni prodotto. La nostra query di prova è invariata:

DECLARE @ProductID integer;
SET @ProductID = 878;
 
SELECT
    mrt.ProductID,
    mrt.TransactionID,
    mrt.ReferenceOrderID,
    mrt.TransactionDate,
    mrt.Quantity
FROM dbo.MostRecentTransactionsPerProduct AS mrt 
WHERE
    mrt.ProductID = @ProductID;

Sia i piani pre che quelli successivi all'esecuzione mostrano una ricerca dell'indice senza bisogno di un OPTION (RECOMPILE) suggerimento per la query. Quello che segue è un piano post-esecuzione ("reale"):

Se la vista avesse utilizzato ROW_NUMBER invece di RANK , la vista sostitutiva avrebbe semplicemente omesso il WITH TIES clausola sul TOP (1) . La nuova vista potrebbe anche essere scritta come una funzione parametrizzata in linea con valori di tabella, ovviamente.

Si potrebbe obiettare che l'indice originale cerca un piano con rnk = 1 il predicato potrebbe anche essere ottimizzato per testare solo 7 righe. Dopotutto, l'ottimizzatore dovrebbe sapere che le classifiche vengono prodotte dall'operatore Sequence Project in rigoroso ordine crescente, quindi l'esecuzione potrebbe terminare non appena viene visualizzata una riga con un grado maggiore di uno. L'ottimizzatore non contiene questa logica oggi, tuttavia.

Pensieri finali

Le persone sono spesso deluse dalle prestazioni delle viste che incorporano le funzioni della finestra. Il motivo può essere spesso ricondotto alla limitazione dell'ottimizzatore descritta in questo post (o forse perché il designer della vista non ha apprezzato il fatto che i predicati applicati alla vista debbano apparire nel PARTITION BY clausola da respingere in sicurezza).

Voglio sottolineare che questa limitazione non si applica solo alle visualizzazioni e nemmeno a ROW_NUMBER , RANK e DENSE_RANK . Dovresti essere consapevole di questa limitazione quando usi qualsiasi funzione con un OVER clausola in una vista, un'espressione di tabella comune, una tabella derivata o una funzione con valori di tabella in linea.

Gli utenti di SQL Server 2005 che riscontrano questo problema devono scegliere di riscrivere la vista come funzione con valori di tabella in linea parametrizzata o di utilizzare APPLY tecnica (ove applicabile).

Gli utenti di SQL Server 2008 hanno la possibilità aggiuntiva di utilizzare un'opzione OPTION (RECOMPILE) suggerimento di query se il problema può essere risolto consentendo all'ottimizzatore di visualizzare una costante anziché un riferimento a una variabile o a un parametro. Ricorda però di controllare i piani post-esecuzione quando utilizzi questo suggerimento:il piano pre-esecuzione generalmente non può mostrare il piano ottimale.