Somma di rotazione dell'intervallo di date utilizzando le funzioni della finestra


57

Devo calcolare una somma variabile su un intervallo di date. Per illustrare, utilizzando il database di esempio AdventureWorks , la seguente sintassi ipotetica farebbe esattamente ciò di cui ho bisogno:

SELECT
    TH.ProductID,
    TH.TransactionDate,
    TH.ActualCost,
    RollingSum45 = SUM(TH.ActualCost) OVER (
        PARTITION BY TH.ProductID
        ORDER BY TH.TransactionDate
        RANGE BETWEEN 
            INTERVAL 45 DAY PRECEDING
            AND CURRENT ROW)
FROM Production.TransactionHistory AS TH
ORDER BY
    TH.ProductID,
    TH.TransactionDate,
    TH.ReferenceOrderID;

Purtroppo, il RANGE estensione del frame finestra non consente attualmente un intervallo in SQL Server.

So di poter scrivere una soluzione usando una sottoquery e un aggregato regolare (senza finestra):

SELECT 
    TH.ProductID,
    TH.TransactionDate,
    TH.ActualCost,
    RollingSum45 =
    (
        SELECT SUM(TH2.ActualCost)
        FROM Production.TransactionHistory AS TH2
        WHERE
            TH2.ProductID = TH.ProductID
            AND TH2.TransactionDate <= TH.TransactionDate
            AND TH2.TransactionDate >= DATEADD(DAY, -45, TH.TransactionDate)
    )
FROM Production.TransactionHistory AS TH
ORDER BY
    TH.ProductID,
    TH.TransactionDate,
    TH.ReferenceOrderID;

Dato il seguente indice:

CREATE UNIQUE INDEX i
ON Production.TransactionHistory
    (ProductID, TransactionDate, ReferenceOrderID)
INCLUDE
    (ActualCost);

Il piano di esecuzione è:

Progetto esecutivo

Sebbene non sia terribilmente inefficiente, sembra che dovrebbe essere possibile esprimere questa query utilizzando solo le funzioni di aggregazione delle finestre e di analisi supportate in SQL Server 2012, 2014 o 2016 (finora).

Per chiarezza, sto cercando una soluzione che esegua un singolo passaggio sui dati.

In T-SQL è probabile che ciò significhi che la OVERclausola farà il lavoro e che il piano di esecuzione includerà spool di finestra e aggregati di finestre. Tutti gli elementi del linguaggio che usano la OVERclausola sono un gioco equo. Una soluzione SQLCLR è accettabile, a condizione che sia garantita produzione di risultati corretti.

Per le soluzioni T-SQL, minore è il numero di hash, ordinamenti e spool / aggregati di finestre nel piano di esecuzione, meglio è. Sentiti libero di aggiungere indici, ma le strutture separate non sono consentite (quindi nessuna tabella pre-calcolata tenuta sincronizzata con i trigger, per esempio). Le tabelle di riferimento sono consentite (tabelle di numeri, date ecc.)

Idealmente, le soluzioni produrranno esattamente gli stessi risultati nello stesso ordine della versione di subquery sopra, ma è accettabile anche qualsiasi cosa probabilmente corretta. Le prestazioni sono sempre una considerazione, quindi le soluzioni dovrebbero essere almeno ragionevolmente efficienti.

Chat room dedicata: ho creato una chat room pubblica per le discussioni relative a questa domanda e alle sue risposte. Qualsiasi utente con almeno 20 punti reputazione può partecipare direttamente. Per favore, chiamami in un commento qui sotto se hai meno di 20 rappresentanti e vorresti partecipare.

Risposte:


42

Ottima domanda, Paul! Ho usato un paio di approcci diversi, uno in T-SQL e uno in CLR.

Riepilogo rapido T-SQL

L'approccio T-SQL può essere sintetizzato come i seguenti passaggi:

  • Prendi il prodotto incrociato di prodotti / date
  • Unire i dati di vendita osservati
  • Aggrega quei dati al livello di prodotto / data
  • Calcola somme continuative negli ultimi 45 giorni in base a questi dati aggregati (che contengono eventuali giorni "mancanti" compilati)
  • Filtra questi risultati solo per gli accoppiamenti prodotto / data che hanno avuto una o più vendite

Utilizzando SET STATISTICS IO ON, questo approccio riporta Table 'TransactionHistory'. Scan count 1, logical reads 484, che conferma il "single pass" sulla tabella. Per riferimento, la query di ricerca loop originale riporta Table 'TransactionHistory'. Scan count 113444, logical reads 438366.

Come riportato da SET STATISTICS TIME ON, il tempo della CPU è 514ms. Ciò si confronta favorevolmente con2231ms la query originale.

Riepilogo rapido CLR

Il riepilogo CLR può essere sintetizzato come i seguenti passaggi:

  • Leggi i dati in memoria, ordinati per prodotto e data
  • Durante l'elaborazione di ogni transazione, aggiungere a un totale parziale dei costi. Ogni volta che una transazione è un prodotto diverso rispetto alla transazione precedente, reimpostare il totale parziale su 0.
  • Mantenere un puntatore alla prima transazione che ha lo stesso (prodotto, data) della transazione corrente. Ogni volta che viene rilevata l'ultima transazione con quel (prodotto, data), calcola la somma corrente per quella transazione e applicala a tutte le transazioni con lo stesso (prodotto, data)
  • Restituisci tutti i risultati all'utente!

Utilizzando SET STATISTICS IO ON, questo approccio segnala che non si è verificato alcun I / O logico! Caspita, una soluzione perfetta! (In realtà, sembra cheSET STATISTICS IO non riporti l'I / O sostenuti all'interno del CLR. Ma dal codice, è facile vedere che viene fatta esattamente una scansione della tabella e recupera i dati in ordine dall'indice suggerito da Paul.

Come riportato da SET STATISTICS TIME ON, il tempo della CPU è ora187ms . Quindi questo è piuttosto un miglioramento rispetto all'approccio T-SQL. Sfortunatamente, il tempo complessivo trascorso di entrambi gli approcci è molto simile a circa mezzo secondo ciascuno. Tuttavia, l'approccio basato su CLR deve generare 113K righe sulla console (rispetto a solo 52K per l'approccio T-SQL che raggruppa per prodotto / data), quindi è per questo che mi sono concentrato sul tempo della CPU.

Un altro grande vantaggio di questo approccio è che produce esattamente gli stessi risultati dell'approccio loop / seek originale, inclusa una riga per ogni transazione anche nei casi in cui un prodotto viene venduto più volte nello stesso giorno. (Su AdventureWorks, ho confrontato specificamente i risultati riga per riga e ho confermato che si legavano alla query originale di Paul.)

Uno svantaggio di questo approccio, almeno nella sua forma attuale, è che legge tutti i dati in memoria. Tuttavia, l'algoritmo che è stato progettato necessita solo rigorosamente dell'attuale frame della finestra in memoria in qualsiasi momento e potrebbe essere aggiornato per funzionare con set di dati che superano la memoria. Paul ha illustrato questo punto nella sua risposta producendo un'implementazione di questo algoritmo che memorizza solo la finestra scorrevole in memoria. Ciò comporta le spese per la concessione di autorizzazioni più elevate per l'assemblaggio CLR, ma sarebbe sicuramente utile nel ridimensionare questa soluzione fino a set di dati arbitrariamente grandi.


T-SQL: una scansione, raggruppata per data

Configurazione iniziale

USE AdventureWorks2012
GO
-- Create Paul's index
CREATE UNIQUE INDEX i
ON Production.TransactionHistory (ProductID, TransactionDate, ReferenceOrderID)
INCLUDE (ActualCost);
GO
-- Build calendar table for 2000 ~ 2020
CREATE TABLE dbo.calendar (d DATETIME NOT NULL CONSTRAINT PK_calendar PRIMARY KEY)
GO
DECLARE @d DATETIME = '1/1/2000'
WHILE (@d < '1/1/2021')
BEGIN
    INSERT INTO dbo.calendar (d) VALUES (@d)
    SELECT @d =  DATEADD(DAY, 1, @d)
END
GO

La domanda

DECLARE @minAnalysisDate DATE = '2007-09-01', -- Customizable start date depending on business needs
        @maxAnalysisDate DATE = '2008-09-03'  -- Customizable end date depending on business needs
SELECT ProductID, TransactionDate, ActualCost, RollingSum45, NumOrders
FROM (
    SELECT ProductID, TransactionDate, NumOrders, ActualCost,
        SUM(ActualCost) OVER (
                PARTITION BY ProductId ORDER BY TransactionDate 
                ROWS BETWEEN 45 PRECEDING AND CURRENT ROW
            ) AS RollingSum45
    FROM (
        -- The full cross-product of products and dates, combined with actual cost information for that product/date
        SELECT p.ProductID, c.d AS TransactionDate,
            COUNT(TH.ProductId) AS NumOrders, SUM(TH.ActualCost) AS ActualCost
        FROM Production.Product p
        JOIN dbo.calendar c
            ON c.d BETWEEN @minAnalysisDate AND @maxAnalysisDate
        LEFT OUTER JOIN Production.TransactionHistory TH
            ON TH.ProductId = p.productId
            AND TH.TransactionDate = c.d
        GROUP BY P.ProductID, c.d
    ) aggsByDay
) rollingSums
WHERE NumOrders > 0
ORDER BY ProductID, TransactionDate
-- MAXDOP 1 to avoid parallel scan inflating the scan count
OPTION (MAXDOP 1)

Il piano di esecuzione

Dal piano di esecuzione, vediamo che l'indice originale proposto da Paul è sufficiente per permetterci di eseguire una singola scansione ordinata di Production.TransactionHistory, utilizzando un join di unione per combinare la cronologia delle transazioni con ogni possibile combinazione prodotto / data.

inserisci qui la descrizione dell'immagine

ipotesi

Ci sono alcuni presupposti significativi in ​​questo approccio. Suppongo che spetterà a Paul decidere se sono accettabili :)

  • Sto usando il Production.Producttavolo. Questa tabella è disponibile gratuitamente su AdventureWorks2012e la relazione è imposta da una chiave esterna daProduction.TransactionHistory , quindi ho interpretato questo come un gioco equo.
  • Questo approccio si basa sul fatto che le transazioni non hanno una componente temporale attiva AdventureWorks2012 ; in tal caso, generare l'intero set di combinazioni prodotto / data non sarebbe più possibile senza prima passare un passaggio alla cronologia delle transazioni.
  • Sto producendo un set di righe che contiene solo una riga per coppia prodotto / data. Penso che questo sia "probabilmente corretto" e in molti casi un risultato più desiderabile per tornare. Per ogni prodotto / data, ho aggiunto una NumOrderscolonna per indicare quante vendite si sono verificate. Vedere lo screenshot seguente per un confronto dei risultati della query originale rispetto alla query proposta nei casi in cui un prodotto è stato venduto più volte nella stessa data (ad es., 319/ 2007-09-05 00:00:00.000)

inserisci qui la descrizione dell'immagine


CLR - una scansione, set di risultati completo non raggruppato

Il corpo della funzione principale

Non c'è molto da vedere qui; il corpo principale della funzione dichiara gli input (che devono corrispondere alla funzione SQL corrispondente), imposta una connessione SQL e apre SQLReader.

// SQL CLR function for rolling SUMs on AdventureWorks2012.Production.TransactionHistory
[SqlFunction(DataAccess = DataAccessKind.Read,
    FillRowMethodName = "RollingSum_Fill",
    TableDefinition = "ProductId INT, TransactionDate DATETIME, ReferenceOrderID INT," +
                      "ActualCost FLOAT, PrevCumulativeSum FLOAT, RollingSum FLOAT")]
public static IEnumerable RollingSumTvf(SqlInt32 rollingPeriodDays) {
    using (var connection = new SqlConnection("context connection=true;")) {
        connection.Open();
        List<TrxnRollingSum> trxns;
        using (var cmd = connection.CreateCommand()) {
            //Read the transaction history (note: the order is important!)
            cmd.CommandText = @"SELECT ProductId, TransactionDate, ReferenceOrderID,
                                    CAST(ActualCost AS FLOAT) AS ActualCost 
                                FROM Production.TransactionHistory 
                                ORDER BY ProductId, TransactionDate";
            using (var reader = cmd.ExecuteReader()) {
                trxns = ComputeRollingSums(reader, rollingPeriodDays.Value);
            }
        }

        return trxns;
    }
}

La logica principale

Ho separato la logica principale in modo che sia più facile concentrarsi su:

// Given a SqlReader with transaction history data, computes / returns the rolling sums
private static List<TrxnRollingSum> ComputeRollingSums(SqlDataReader reader,
                                                        int rollingPeriodDays) {
    var startIndexOfRollingPeriod = 0;
    var rollingSumIndex = 0;
    var trxns = new List<TrxnRollingSum>();

    // Prior to the loop, initialize "next" to be the first transaction
    var nextTrxn = GetNextTrxn(reader, null);
    while (nextTrxn != null)
    {
        var currTrxn = nextTrxn;
        nextTrxn = GetNextTrxn(reader, currTrxn);
        trxns.Add(currTrxn);

        // If the next transaction is not the same product/date as the current
        // transaction, we can finalize the rolling sum for the current transaction
        // and all previous transactions for the same product/date
        var finalizeRollingSum = nextTrxn == null || (nextTrxn != null &&
                                (currTrxn.ProductId != nextTrxn.ProductId ||
                                currTrxn.TransactionDate != nextTrxn.TransactionDate));
        if (finalizeRollingSum)
        {
            // Advance the pointer to the first transaction (for the same product)
            // that occurs within the rolling period
            while (startIndexOfRollingPeriod < trxns.Count
                && trxns[startIndexOfRollingPeriod].TransactionDate <
                    currTrxn.TransactionDate.AddDays(-1 * rollingPeriodDays))
            {
                startIndexOfRollingPeriod++;
            }

            // Compute the rolling sum as the cumulative sum (for this product),
            // minus the cumulative sum for prior to the beginning of the rolling window
            var sumPriorToWindow = trxns[startIndexOfRollingPeriod].PrevSum;
            var rollingSum = currTrxn.ActualCost + currTrxn.PrevSum - sumPriorToWindow;
            // Fill in the rolling sum for all transactions sharing this product/date
            while (rollingSumIndex < trxns.Count)
            {
                trxns[rollingSumIndex++].RollingSum = rollingSum;
            }
        }

        // If this is the last transaction for this product, reset the rolling period
        if (nextTrxn != null && currTrxn.ProductId != nextTrxn.ProductId)
        {
            startIndexOfRollingPeriod = trxns.Count;
        }
    }

    return trxns;
}

Helpers

La seguente logica potrebbe essere scritta in linea, ma è un po 'più facile da leggere quando sono suddivisi nei propri metodi.

private static TrxnRollingSum GetNextTrxn(SqlDataReader r, TrxnRollingSum currTrxn) {
    TrxnRollingSum nextTrxn = null;
    if (r.Read()) {
        nextTrxn = new TrxnRollingSum {
            ProductId = r.GetInt32(0),
            TransactionDate = r.GetDateTime(1),
            ReferenceOrderId = r.GetInt32(2),
            ActualCost = r.GetDouble(3),
            PrevSum = 0 };
        if (currTrxn != null) {
            nextTrxn.PrevSum = (nextTrxn.ProductId == currTrxn.ProductId)
                    ? currTrxn.PrevSum + currTrxn.ActualCost : 0;
        }
    }
    return nextTrxn;
}

// Represents the output to be returned
// Note that the ReferenceOrderId/PrevSum fields are for debugging only
private class TrxnRollingSum {
    public int ProductId { get; set; }
    public DateTime TransactionDate { get; set; }
    public int ReferenceOrderId { get; set; }
    public double ActualCost { get; set; }
    public double PrevSum { get; set; }
    public double RollingSum { get; set; }
}

// The function that generates the result data for each row
// (Such a function is mandatory for SQL CLR table-valued functions)
public static void RollingSum_Fill(object trxnWithRollingSumObj,
                                    out int productId,
                                    out DateTime transactionDate, 
                                    out int referenceOrderId, out double actualCost,
                                    out double prevCumulativeSum,
                                    out double rollingSum) {
    var trxn = (TrxnRollingSum)trxnWithRollingSumObj;
    productId = trxn.ProductId;
    transactionDate = trxn.TransactionDate;
    referenceOrderId = trxn.ReferenceOrderId;
    actualCost = trxn.ActualCost;
    prevCumulativeSum = trxn.PrevSum;
    rollingSum = trxn.RollingSum;
}

Legare tutto insieme in SQL

Tutto fino a questo punto è stato in C #, quindi vediamo l'SQL reale coinvolto. (In alternativa, è possibile utilizzare questo script di distribuzione per creare l'assembly direttamente dai bit del mio assembly anziché compilare te stesso.)

USE AdventureWorks2012; /* GPATTERSON2\SQL2014DEVELOPER */
GO

-- Enable CLR
EXEC sp_configure 'clr enabled', 1;
GO
RECONFIGURE;
GO

-- Create the assembly based on the dll generated by compiling the CLR project
-- I've also included the "assembly bits" version that can be run without compiling
CREATE ASSEMBLY ClrPlayground
-- See http://pastebin.com/dfbv1w3z for a "from assembly bits" version
FROM 'C:\FullPathGoesHere\ClrPlayground\bin\Debug\ClrPlayground.dll'
WITH PERMISSION_SET = safe;
GO

--Create a function from the assembly
CREATE FUNCTION dbo.RollingSumTvf (@rollingPeriodDays INT)
RETURNS TABLE ( ProductId INT, TransactionDate DATETIME, ReferenceOrderID INT,
                ActualCost FLOAT, PrevCumulativeSum FLOAT, RollingSum FLOAT)
-- The function yields rows in order, so let SQL Server know to avoid an extra sort
ORDER (ProductID, TransactionDate, ReferenceOrderID)
AS EXTERNAL NAME ClrPlayground.UserDefinedFunctions.RollingSumTvf;
GO

-- Now we can actually use the TVF!
SELECT * 
FROM dbo.RollingSumTvf(45) 
ORDER BY ProductId, TransactionDate, ReferenceOrderId
GO

Avvertenze

L'approccio CLR offre molta più flessibilità per ottimizzare l'algoritmo e probabilmente potrebbe essere ulteriormente ottimizzato da un esperto in C #. Tuttavia, ci sono anche aspetti negativi della strategia CLR. Alcune cose da tenere a mente:

  • Questo approccio CLR mantiene una copia del set di dati in memoria. È possibile utilizzare un approccio di streaming, ma ho riscontrato difficoltà iniziali e ho riscontrato che esiste un problema Connect eccezionale che si lamenta del fatto che le modifiche in SQL 2008+ rendono più difficile l'utilizzo di questo tipo di approccio. È ancora possibile (come dimostra Paul), ma richiede un livello superiore di autorizzazioni impostando il database come TRUSTWORTHYe concedendo EXTERNAL_ACCESSall'assembly CLR. Quindi c'è qualche seccatura e potenziali implicazioni per la sicurezza, ma il payoff è un approccio di streaming che può scalare meglio a set di dati molto più grandi di quelli su AdventureWorks.
  • Il CLR potrebbe essere meno accessibile ad alcuni DBA, rendendo tale funzione più simile a una scatola nera che non è così trasparente, non così facilmente modificabile, non così facilmente distribuibile e forse non altrettanto facilmente analizzabile. Questo è uno svantaggio piuttosto grande rispetto a un approccio T-SQL.


Bonus: T-SQL # 2 - l'approccio pratico che avrei effettivamente utilizzato

Dopo aver provato a pensare al problema in modo creativo per un po ', ho pensato di pubblicare anche il modo abbastanza semplice e pratico che avrei probabilmente scelto di affrontare questo problema se si presentasse nel mio lavoro quotidiano. Fa uso della funzionalità della finestra di SQL 2012+, ma non nel tipo di modo innovativo che la domanda sperava:

-- Compute all running costs into a #temp table; Note that this query could simply read
-- from Production.TransactionHistory, but a CROSS APPLY by product allows the window 
-- function to be computed independently per product, supporting a parallel query plan
SELECT t.*
INTO #runningCosts
FROM Production.Product p
CROSS APPLY (
    SELECT t.ProductId, t.TransactionDate, t.ReferenceOrderId, t.ActualCost,
        -- Running sum of the cost for this product, including all ties on TransactionDate
        SUM(t.ActualCost) OVER (
            ORDER BY t.TransactionDate 
            RANGE UNBOUNDED PRECEDING) AS RunningCost
    FROM Production.TransactionHistory t
    WHERE t.ProductId = p.ProductId
) t
GO

-- Key the table in our output order
ALTER TABLE #runningCosts
ADD PRIMARY KEY (ProductId, TransactionDate, ReferenceOrderId)
GO

SELECT r.ProductId, r.TransactionDate, r.ReferenceOrderId, r.ActualCost,
    -- Cumulative running cost - running cost prior to the sliding window
    r.RunningCost - ISNULL(w.RunningCost,0) AS RollingSum45
FROM #runningCosts r
OUTER APPLY (
    -- For each transaction, find the running cost just before the sliding window begins
    SELECT TOP 1 b.RunningCost
    FROM #runningCosts b
    WHERE b.ProductId = r.ProductId
        AND b.TransactionDate < DATEADD(DAY, -45, r.TransactionDate)
    ORDER BY b.TransactionDate DESC
) w
ORDER BY r.ProductId, r.TransactionDate, r.ReferenceOrderId
GO

Questo in realtà produce un piano di query globale abbastanza semplice, anche quando si esaminano entrambi i due piani di query pertinenti insieme:

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

Alcuni motivi mi piace questo approccio:

  • Produce l'intero set di risultati richiesto nell'istruzione del problema (a differenza della maggior parte delle altre soluzioni T-SQL, che restituiscono una versione raggruppata dei risultati).
  • È facile da spiegare, comprendere e eseguire il debug; Non tornerò un anno dopo e mi chiedo come diavolo posso fare un piccolo cambiamento senza rovinare la correttezza o le prestazioni
  • Funziona nel 900msset di dati fornito, piuttosto che nel 2700msciclo di ricerca originale
  • Se i dati erano molto più densi (più transazioni al giorno), la complessità computazionale non cresce quadraticamente con il numero di transazioni nella finestra scorrevole (come per la query originale); Penso che questo affronti parte della preoccupazione di Paul riguardo al voler evitare scansioni multiple
  • Ciò comporta essenzialmente un I / O tempdb nei recenti aggiornamenti di SQL 2012+ a causa di nuova funzionalità di scrittura pigra tempdb
  • Per set di dati molto grandi, è banale dividere il lavoro in lotti separati per ogni prodotto se la pressione della memoria dovesse diventare un problema

Un paio di avvertenze potenziali:

  • Sebbene tecnicamente esegua la scansione di Production.TransactionHistory solo una volta, non è veramente un approccio "one scan" perché la tabella #temp di dimensioni simili e dovrà eseguire anche I / O di logica aggiuntiva su quella tabella. Tuttavia, non lo considero troppo diverso da una tabella di lavoro su cui abbiamo un maggiore controllo manuale poiché ne abbiamo definito la struttura precisa
  • A seconda del proprio ambiente, l'utilizzo di tempdb potrebbe essere visto come positivo (ad esempio, è su un set separato di unità SSD) o negativo (elevata concorrenza sul server, molte contese tempdb già)

25

Questa è una risposta lunga, quindi ho deciso di aggiungere un riepilogo qui.

  • All'inizio presento una soluzione che produce esattamente lo stesso risultato nello stesso ordine della domanda. Esegue la scansione della tabella principale 3 volte: per ottenere un elenco ProductIDscon l'intervallo di date per ciascun prodotto, per sommare i costi per ogni giorno (perché ci sono diverse transazioni con le stesse date), per unire il risultato con le righe originali.
  • Quindi confronto due approcci che semplificano l'attività ed evitano un'ultima scansione della tabella principale. Il loro risultato è un riepilogo giornaliero, ovvero se più transazioni su un Prodotto hanno la stessa data, vengono raggruppate in un'unica riga. Il mio approccio dal passaggio precedente analizza due volte la tabella. L'approccio di Geoff Patterson esegue una scansione del tavolo una volta, poiché utilizza conoscenze esterne sull'intervallo di date e sull'elenco dei prodotti.
  • Alla fine presento una soluzione a singolo passaggio che restituisce nuovamente un riepilogo giornaliero, ma non richiede conoscenze esterne sull'intervallo di date o sull'elenco di ProductIDs.

Userò AdventureWorks2014 database e SQL Server Express 2014.

Modifiche al database originale:

  • Tipo modificato da [Production].[TransactionHistory].[TransactionDate]da datetimea date. La componente temporale era comunque zero.
  • Tabella del calendario aggiunta [dbo].[Calendar]
  • Indice aggiunto a [Production].[TransactionHistory]

.

CREATE TABLE [dbo].[Calendar]
(
    [dt] [date] NOT NULL,
    CONSTRAINT [PK_Calendar] PRIMARY KEY CLUSTERED 
(
    [dt] ASC
))

CREATE UNIQUE NONCLUSTERED INDEX [i] ON [Production].[TransactionHistory]
(
    [ProductID] ASC,
    [TransactionDate] ASC,
    [ReferenceOrderID] ASC
)
INCLUDE ([ActualCost])

-- Init calendar table
INSERT INTO dbo.Calendar (dt)
SELECT TOP (50000)
    DATEADD(day, ROW_NUMBER() OVER (ORDER BY s1.[object_id])-1, '2000-01-01') AS dt
FROM sys.all_objects AS s1 CROSS JOIN sys.all_objects AS s2
OPTION (MAXDOP 1);

L'articolo di MSDN sulla OVERclausola contiene un collegamento a un eccellente post sul blog sulle funzioni delle finestre di Itzik Ben-Gan. In quel post spiega come OVERfunziona, la differenza tra ROWSe le RANGEopzioni e menziona proprio questo problema del calcolo di una somma variabile su un intervallo di date. Egli menziona che l'attuale versione di SQL Server non implementa RANGEcompletamente e non implementa i tipi di dati di intervallo temporale. La sua spiegazione della differenza tra ROWSe RANGEmi ha dato un'idea.

Date senza lacune e duplicati

Se la TransactionHistorytabella contenesse date senza spazi vuoti e senza duplicati, la query seguente produrrebbe risultati corretti:

SELECT
    TH.ProductID,
    TH.TransactionDate,
    TH.ActualCost,
    RollingSum45 = SUM(TH.ActualCost) OVER (
        PARTITION BY TH.ProductID
        ORDER BY TH.TransactionDate
        ROWS BETWEEN 
            45 PRECEDING
            AND CURRENT ROW)
FROM Production.TransactionHistory AS TH
ORDER BY
    TH.ProductID,
    TH.TransactionDate,
    TH.ReferenceOrderID;

In effetti, una finestra di 45 file coprirebbe esattamente 45 giorni.

Date con spazi vuoti senza duplicati

Sfortunatamente, i nostri dati hanno delle lacune nelle date. Per risolvere questo problema, possiamo utilizzare una Calendartabella per generare un set di date senza spazi vuoti, quindi i LEFT JOINdati originali per questo set e utilizzare la stessa query con ROWS BETWEEN 45 PRECEDING AND CURRENT ROW. Ciò produrrebbe risultati corretti solo se le date non si ripetono (all'interno della stessa ProductID).

Date con lacune con duplicati

Sfortunatamente, i nostri dati hanno entrambi lacune nelle date e le date possono ripetersi all'interno dello stesso ProductID. Per risolvere questo problema, possiamo generare GROUPdati originali ProductID, TransactionDategenerando un insieme di date senza duplicati. Quindi utilizzare la Calendartabella per generare un insieme di date senza spazi vuoti. Quindi possiamo usare la query con ROWS BETWEEN 45 PRECEDING AND CURRENT ROWper calcolare il rolling SUM. Ciò produrrebbe risultati corretti. Vedi i commenti nella query qui sotto.

WITH

-- calculate Start/End dates for each product
CTE_Products
AS
(
    SELECT TH.ProductID
        ,MIN(TH.TransactionDate) AS MinDate
        ,MAX(TH.TransactionDate) AS MaxDate
    FROM [Production].[TransactionHistory] AS TH
    GROUP BY TH.ProductID
)

-- generate set of dates without gaps for each product
,CTE_ProductsWithDates
AS
(
    SELECT CTE_Products.ProductID, C.dt
    FROM
        CTE_Products
        INNER JOIN dbo.Calendar AS C ON
            C.dt >= CTE_Products.MinDate AND
            C.dt <= CTE_Products.MaxDate
)

-- generate set of dates without duplicates for each product
-- calculate daily cost as well
,CTE_DailyCosts
AS
(
    SELECT TH.ProductID, TH.TransactionDate, SUM(ActualCost) AS DailyActualCost
    FROM [Production].[TransactionHistory] AS TH
    GROUP BY TH.ProductID, TH.TransactionDate
)

-- calculate rolling sum over 45 days
,CTE_Sum
AS
(
    SELECT
        CTE_ProductsWithDates.ProductID
        ,CTE_ProductsWithDates.dt
        ,CTE_DailyCosts.DailyActualCost
        ,SUM(CTE_DailyCosts.DailyActualCost) OVER (
            PARTITION BY CTE_ProductsWithDates.ProductID
            ORDER BY CTE_ProductsWithDates.dt
            ROWS BETWEEN 45 PRECEDING AND CURRENT ROW) AS RollingSum45
    FROM
        CTE_ProductsWithDates
        LEFT JOIN CTE_DailyCosts ON 
            CTE_DailyCosts.ProductID = CTE_ProductsWithDates.ProductID AND
            CTE_DailyCosts.TransactionDate = CTE_ProductsWithDates.dt
)

-- remove rows that were added by Calendar, which fill the gaps in dates
-- add back duplicate dates that were removed by GROUP BY
SELECT
    TH.ProductID
    ,TH.TransactionDate
    ,TH.ActualCost
    ,CTE_Sum.RollingSum45
FROM
    [Production].[TransactionHistory] AS TH
    INNER JOIN CTE_Sum ON
        CTE_Sum.ProductID = TH.ProductID AND
        CTE_Sum.dt = TH.TransactionDate
ORDER BY
    TH.ProductID
    ,TH.TransactionDate
    ,TH.ReferenceOrderID
;

Ho confermato che questa query produce gli stessi risultati dell'approccio della domanda che utilizza la subquery.

Piani di esecuzione

statistiche

La prima query utilizza la subquery, la seconda: questo approccio. Puoi vedere che la durata e il numero di letture sono molto inferiori in questo approccio. La maggior parte dei costi stimati in questo approccio è la finale ORDER BY, vedi sotto.

subquery

L'approccio di subquery ha un piano semplice con cicli nidificati e O(n*n)complessità.

al di sopra di

Pianificare questo approccio scansiona TransactionHistorypiù volte, ma non ci sono loop. Come puoi vedere, oltre il 70% del costo stimato è Sortil finale ORDER BY.

io

Risultato migliore - subquery, in basso - OVER.


Evitare scansioni extra

L'ultima scansione dell'indice, Unisci join e ordinamento nel piano sopra è causata dal finale INNER JOINcon la tabella originale per rendere il risultato finale esattamente uguale a un approccio lento con subquery. Il numero di righe restituite è lo stesso della TransactionHistorytabella. Sono presenti righe in TransactionHistorycui si sono verificate più transazioni nello stesso giorno per lo stesso prodotto. Se è OK mostrare solo un riepilogo giornaliero nel risultato, è JOINpossibile rimuovere questo finale e la query diventa un po 'più semplice e un po' più veloce. L'ultima scansione dell'indice, Unisci unione e Ordina dal piano precedente vengono sostituite con Filtro, che rimuove le righe aggiunte da Calendar.

WITH
-- two scans
-- calculate Start/End dates for each product
CTE_Products
AS
(
    SELECT TH.ProductID
        ,MIN(TH.TransactionDate) AS MinDate
        ,MAX(TH.TransactionDate) AS MaxDate
    FROM [Production].[TransactionHistory] AS TH
    GROUP BY TH.ProductID
)

-- generate set of dates without gaps for each product
,CTE_ProductsWithDates
AS
(
    SELECT CTE_Products.ProductID, C.dt
    FROM
        CTE_Products
        INNER JOIN dbo.Calendar AS C ON
            C.dt >= CTE_Products.MinDate AND
            C.dt <= CTE_Products.MaxDate
)

-- generate set of dates without duplicates for each product
-- calculate daily cost as well
,CTE_DailyCosts
AS
(
    SELECT TH.ProductID, TH.TransactionDate, SUM(ActualCost) AS DailyActualCost
    FROM [Production].[TransactionHistory] AS TH
    GROUP BY TH.ProductID, TH.TransactionDate
)

-- calculate rolling sum over 45 days
,CTE_Sum
AS
(
    SELECT
        CTE_ProductsWithDates.ProductID
        ,CTE_ProductsWithDates.dt
        ,CTE_DailyCosts.DailyActualCost
        ,SUM(CTE_DailyCosts.DailyActualCost) OVER (
            PARTITION BY CTE_ProductsWithDates.ProductID
            ORDER BY CTE_ProductsWithDates.dt
            ROWS BETWEEN 45 PRECEDING AND CURRENT ROW) AS RollingSum45
    FROM
        CTE_ProductsWithDates
        LEFT JOIN CTE_DailyCosts ON 
            CTE_DailyCosts.ProductID = CTE_ProductsWithDates.ProductID AND
            CTE_DailyCosts.TransactionDate = CTE_ProductsWithDates.dt
)

-- remove rows that were added by Calendar, which fill the gaps in dates
SELECT
    CTE_Sum.ProductID
    ,CTE_Sum.dt AS TransactionDate
    ,CTE_Sum.DailyActualCost
    ,CTE_Sum.RollingSum45
FROM CTE_Sum
WHERE CTE_Sum.DailyActualCost IS NOT NULL
ORDER BY
    CTE_Sum.ProductID
    ,CTE_Sum.dt
;

due-scan

Tuttavia, TransactionHistoryviene scansionato due volte. È necessaria una scansione extra per ottenere l'intervallo di date per ciascun prodotto. Ero interessato a vedere come si confronta con un altro approccio, in cui utilizziamo le conoscenze esterne sull'intervallo globale di date TransactionHistory, oltre a una tabella aggiuntiva Productche ha tutto ProductIDsper evitare quella scansione aggiuntiva. Ho rimosso il calcolo del numero di transazioni al giorno da questa query per rendere valido il confronto. Può essere aggiunto in entrambe le query, ma vorrei renderlo semplice per il confronto. Ho anche dovuto usare altre date, perché utilizzo la versione 2014 del database.

DECLARE @minAnalysisDate DATE = '2013-07-31', 
-- Customizable start date depending on business needs
        @maxAnalysisDate DATE = '2014-08-03'  
-- Customizable end date depending on business needs
SELECT 
    -- one scan
    ProductID, TransactionDate, ActualCost, RollingSum45
--, NumOrders
FROM (
    SELECT ProductID, TransactionDate, 
    --NumOrders, 
    ActualCost,
        SUM(ActualCost) OVER (
                PARTITION BY ProductId ORDER BY TransactionDate 
                ROWS BETWEEN 45 PRECEDING AND CURRENT ROW
            ) AS RollingSum45
    FROM (
        -- The full cross-product of products and dates, 
        -- combined with actual cost information for that product/date
        SELECT p.ProductID, c.dt AS TransactionDate,
            --COUNT(TH.ProductId) AS NumOrders, 
            SUM(TH.ActualCost) AS ActualCost
        FROM Production.Product p
        JOIN dbo.calendar c
            ON c.dt BETWEEN @minAnalysisDate AND @maxAnalysisDate
        LEFT OUTER JOIN Production.TransactionHistory TH
            ON TH.ProductId = p.productId
            AND TH.TransactionDate = c.dt
        GROUP BY P.ProductID, c.dt
    ) aggsByDay
) rollingSums
--WHERE NumOrders > 0
WHERE ActualCost IS NOT NULL
ORDER BY ProductID, TransactionDate
-- MAXDOP 1 to avoid parallel scan inflating the scan count
OPTION (MAXDOP 1);

uno-scan

Entrambe le query restituiscono lo stesso risultato nello stesso ordine.

Confronto

Ecco il tempo e le statistiche IO.

stats2

io2

La variante a due scansioni è un po 'più veloce e ha meno letture, perché la variante a una scansione deve usare molto Worktable. Inoltre, la variante a scansione singola genera più righe del necessario, come puoi vedere nei piani. Genera date per ciascuno ProductIDche è nella Producttabella, anche se a ProductIDnon ha transazioni. Ci sono 504 righe nella Producttabella, ma solo 441 prodotti hanno transazioni in TransactionHistory. Inoltre, genera lo stesso intervallo di date per ciascun prodotto, che è più del necessario. Se TransactionHistoryavesse una storia complessiva più lunga, con ogni singolo prodotto con una storia relativamente breve, il numero di file extra non necessarie sarebbe ancora più alto.

D'altra parte, è possibile ottimizzare ulteriormente la variante a due scansioni creando un altro indice più stretto su just (ProductID, TransactionDate). Questo indice verrebbe utilizzato per calcolare le date di inizio / fine per ciascun prodotto ( CTE_Products) e avrebbe meno pagine rispetto all'indice di copertura e di conseguenza causerebbe meno letture.

Quindi, possiamo scegliere, o avere una scansione extra esplicita o avere un Worktable implicito.

A proposito, se è OK avere un risultato con solo riepiloghi giornalieri, è meglio creare un indice che non includa ReferenceOrderID. Userebbe meno pagine => meno IO.

CREATE NONCLUSTERED INDEX [i2] ON [Production].[TransactionHistory]
(
    [ProductID] ASC,
    [TransactionDate] ASC
)
INCLUDE ([ActualCost])

Soluzione single pass con CROSS APPLY

Diventa una risposta davvero lunga, ma ecco un'altra variante che restituisce di nuovo solo un riepilogo giornaliero, ma esegue solo una scansione dei dati e non richiede conoscenze esterne sull'intervallo di date o sull'elenco dei ProductID. Non fa anche i tipi intermedi. Le prestazioni complessive sono simili alle varianti precedenti, anche se sembrano essere leggermente peggiori.

L'idea principale è quella di utilizzare una tabella di numeri per generare righe che riempirebbero le lacune nelle date. Per ogni data esistente utilizzare LEADper calcolare la dimensione del divario in giorni e quindi utilizzare CROSS APPLYper aggiungere il numero richiesto di righe nel set di risultati. All'inizio l'ho provato con una tabella di numeri permanente. Il piano mostrava un gran numero di letture in questa tabella, sebbene la durata effettiva fosse praticamente la stessa, come quando ho generato numeri al volo usando CTE.

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
) -- 10
,e2(n) AS (SELECT 1 FROM e1 CROSS JOIN e1 AS b) -- 10*10
,e3(n) AS (SELECT 1 FROM e1 CROSS JOIN e2) -- 10*100
,CTE_Numbers
AS
(
    SELECT ROW_NUMBER() OVER (ORDER BY n) AS Number
    FROM e3
)
,CTE_DailyCosts
AS
(
    SELECT
        TH.ProductID
        ,TH.TransactionDate
        ,SUM(ActualCost) AS DailyActualCost
        ,ISNULL(DATEDIFF(day,
            TH.TransactionDate,
            LEAD(TH.TransactionDate) 
            OVER(PARTITION BY TH.ProductID ORDER BY TH.TransactionDate)), 1) AS DiffDays
    FROM [Production].[TransactionHistory] AS TH
    GROUP BY TH.ProductID, TH.TransactionDate
)
,CTE_NoGaps
AS
(
    SELECT
        CTE_DailyCosts.ProductID
        ,CTE_DailyCosts.TransactionDate
        ,CASE WHEN CA.Number = 1 
        THEN CTE_DailyCosts.DailyActualCost
        ELSE NULL END AS DailyCost
    FROM
        CTE_DailyCosts
        CROSS APPLY
        (
            SELECT TOP(CTE_DailyCosts.DiffDays) CTE_Numbers.Number
            FROM CTE_Numbers
            ORDER BY CTE_Numbers.Number
        ) AS CA
)
,CTE_Sum
AS
(
    SELECT
        ProductID
        ,TransactionDate
        ,DailyCost
        ,SUM(DailyCost) OVER (
            PARTITION BY ProductID
            ORDER BY TransactionDate
            ROWS BETWEEN 45 PRECEDING AND CURRENT ROW) AS RollingSum45
    FROM CTE_NoGaps
)
SELECT
    ProductID
    ,TransactionDate
    ,DailyCost
    ,RollingSum45
FROM CTE_Sum
WHERE DailyCost IS NOT NULL
ORDER BY 
    ProductID
    ,TransactionDate
;

Questo piano è "più lungo", poiché la query utilizza due funzioni di finestra ( LEADe SUM).

applicare la croce

ca stats

ca io


23

Una soluzione SQLCLR alternativa che si esegue più velocemente e richiede meno memoria:

Script di distribuzione

Ciò richiede il EXTERNAL_ACCESSset di autorizzazioni perché utilizza una connessione di loopback al server e al database di destinazione anziché la connessione di contesto (lenta). Ecco come chiamare la funzione:

SELECT 
    RS.ProductID,
    RS.TransactionDate,
    RS.ActualCost,
    RS.RollingSum45
FROM dbo.RollingSum
(
    N'.\SQL2014',           -- Instance name
    N'AdventureWorks2012'   -- Database name
) AS RS 
ORDER BY
    RS.ProductID,
    RS.TransactionDate,
    RS.ReferenceOrderID;

Produce esattamente gli stessi risultati, nello stesso ordine, della domanda.

Progetto esecutivo:

Piano di esecuzione SQLCLR TVF

SQLCLR Piano di esecuzione della query di origine

Pianificare le statistiche sulle prestazioni di Explorer

Letture logiche del profiler: 481

Il vantaggio principale di questa implementazione è che è più veloce rispetto all'utilizzo della connessione di contesto e utilizza meno memoria. Mantiene in memoria solo due cose alla volta:

  1. Qualsiasi riga duplicata (stesso prodotto e data di transazione). Ciò è necessario perché fino a quando il prodotto o la data non cambiano, non sappiamo quale sarà la somma corrente finale. Nei dati di esempio, esiste una combinazione di prodotto e data con 64 righe.
  2. Una gamma scorrevole di 45 giorni di date di costo e transazione, solo per il prodotto corrente. Ciò è necessario per regolare la somma corrente semplice per le righe che escono dalla finestra scorrevole di 45 giorni.

Questa memorizzazione nella cache minima dovrebbe garantire un ridimensionamento corretto di questo metodo; sicuramente meglio di provare a mantenere l'intero set di input nella memoria CLR.

Codice sorgente


17

Se si utilizza l'edizione Enterprise, Developer o Evaluation a 64 bit di SQL Server 2014, è possibile utilizzare OLTP in memoria . La soluzione non sarà una singola scansione e difficilmente utilizzerà alcuna funzione della finestra, ma potrebbe aggiungere valore a questa domanda e l'algoritmo utilizzato potrebbe essere utilizzato come ispirazione per altre soluzioni.

Innanzitutto è necessario abilitare l'OLTP in memoria sul database AdventureWorks.

alter database AdventureWorks2014 
  add filegroup InMem contains memory_optimized_data;

alter database AdventureWorks2014 
  add file (name='AW2014_InMem', 
            filename='D:\SQL Server\MSSQL12.MSSQLSERVER\MSSQL\DATA\AW2014') 
    to filegroup InMem;

alter database AdventureWorks2014 
  set memory_optimized_elevate_to_snapshot = on;

Il parametro per la procedura è una variabile di tabella in memoria e deve essere definito come un tipo.

create type dbo.TransHistory as table
(
  ID int not null,
  ProductID int not null,
  TransactionDate datetime not null,
  ReferenceOrderID int not null,
  ActualCost money not null,
  RunningTotal money not null,
  RollingSum45 money not null,

  -- Index used in while loop
  index IX_T1 nonclustered hash (ID) with (bucket_count = 1000000),

  -- Used to lookup the running total as it was 45 days ago (or more)
  index IX_T2 nonclustered (ProductID, TransactionDate desc)
) with (memory_optimized = on);

ID non è univoco in questa tabella, è univoco per ogni combinazione di ProductIDe TransactionDate.

Ci sono alcuni commenti nella procedura che ti dicono cosa fa ma nel complesso sta calcolando il totale parziale in un ciclo e per ogni iterazione fa una ricerca per il totale parziale com'era 45 giorni fa (o più).

Il totale corrente corrente meno il totale corrente com'era 45 giorni fa è la somma mobile di 45 giorni che stiamo cercando.

create procedure dbo.GetRolling45
  @TransHistory dbo.TransHistory readonly
with native_compilation, schemabinding, execute as owner as
begin atomic with(transaction isolation level = snapshot, language = N'us_english')

  -- Table to hold the result
  declare @TransRes dbo.TransHistory;

  -- Loop variable
  declare @ID int = 0;

  -- Current ProductID
  declare @ProductID int = -1;

  -- Previous ProductID used to restart the running total
  declare @PrevProductID int;

  -- Current transaction date used to get the running total 45 days ago (or more)
  declare @TransactionDate datetime;

  -- Sum of actual cost for the group ProductID and TransactionDate
  declare @ActualCost money;

  -- Running total so far
  declare @RunningTotal money = 0;

  -- Running total as it was 45 days ago (or more)
  declare @RunningTotal45 money = 0;

  -- While loop for each unique occurence of the combination of ProductID, TransactionDate
  while @ProductID <> 0
  begin
    set @ID += 1;
    set @PrevProductID = @ProductID;

    -- Get the current values
    select @ProductID = min(ProductID),
           @TransactionDate = min(TransactionDate),
           @ActualCost = sum(ActualCost)
    from @TransHistory 
    where ID = @ID;

    if @ProductID <> 0
    begin
      set @RunningTotal45 = 0;

      if @ProductID <> @PrevProductID
      begin
        -- New product, reset running total
        set @RunningTotal = @ActualCost;
      end
      else
      begin
        -- Same product as last row, aggregate running total
        set @RunningTotal += @ActualCost;

        -- Get the running total as it was 45 days ago (or more)
        select top(1) @RunningTotal45 = TR.RunningTotal
        from @TransRes as TR
        where TR.ProductID = @ProductID and
              TR.TransactionDate < dateadd(day, -45, @TransactionDate)
        order by TR.TransactionDate desc;

      end;

      -- Add all rows that match ID to the result table
      -- RollingSum45 is calculated by using the current running total and the running total as it was 45 days ago (or more)
      insert into @TransRes(ID, ProductID, TransactionDate, ReferenceOrderID, ActualCost, RunningTotal, RollingSum45)
      select @ID, 
             @ProductID, 
             @TransactionDate, 
             TH.ReferenceOrderID, 
             TH.ActualCost, 
             @RunningTotal, 
             @RunningTotal - @RunningTotal45
      from @TransHistory as TH
      where ID = @ID;

    end
  end;

  -- Return the result table to caller
  select TR.ProductID, TR.TransactionDate, TR.ReferenceOrderID, TR.ActualCost, TR.RollingSum45
  from @TransRes as TR
  order by TR.ProductID, TR.TransactionDate, TR.ReferenceOrderID;

end;

Richiamare la procedura in questo modo.

-- Parameter to stored procedure GetRollingSum
declare @T dbo.TransHistory;

-- Load data to in-mem table
-- ID is unique for each combination of ProductID, TransactionDate
insert into @T(ID, ProductID, TransactionDate, ReferenceOrderID, ActualCost, RunningTotal, RollingSum45)
select dense_rank() over(order by TH.ProductID, TH.TransactionDate),
       TH.ProductID, 
       TH.TransactionDate, 
       TH.ReferenceOrderID,
       TH.ActualCost,
       0, 
       0
from Production.TransactionHistory as TH;

-- Get the rolling 45 days sum
exec dbo.GetRolling45 @T;

Provando questo sul mio computer, Client Statistics riporta un tempo di esecuzione totale di circa 750 millisecondi. Per i confronti, la versione della query secondaria richiede 3,5 secondi.

Inclusioni extra:

Questo algoritmo potrebbe anche essere utilizzato dal normale T-SQL. Calcola il totale parziale, usando rangenon le righe, e archivia il risultato in una tabella temporanea. Quindi è possibile eseguire una query su quella tabella con un self join per il totale parziale com'era 45 giorni fa e calcolare la somma variabile. Tuttavia, l'implementazione di rangerispetto a rowsè piuttosto lenta a causa del fatto che è necessario trattare i duplicati dell'ordine in base alla clausola in modo diverso, quindi non ho ottenuto tutte le buone prestazioni con questo approccio. Una soluzione a ciò potrebbe essere quella di utilizzare un'altra funzione della finestra come last_value()su un totale parziale calcolato usando rowsper simulare un rangetotale parziale. Un altro modo è quello di usare max() over(). Entrambi hanno avuto dei problemi. Trovare l'indice appropriato da utilizzare per evitare ordinamenti ed evitare gli spool con ilmax() over() versione. Ho rinunciato a ottimizzare quelle cose, ma se sei interessato al codice che ho finora, per favore fatemelo sapere.


13

Beh, è ​​stato divertente :) La mia soluzione è un po 'più lenta di @ GeoffPatterson, ma parte del fatto è che sto ricollegando alla tabella originale per eliminare una delle ipotesi di Geoff (ovvero una riga per coppia di prodotti / data) . Ho assunto il presupposto che questa era una versione semplificata di una query finale e potrebbe richiedere ulteriori informazioni dalla tabella originale.

Nota: sto prendendo in prestito la tabella del calendario di Geoff e in effetti ho finito con una soluzione molto simile:

-- Build calendar table for 2000 ~ 2020
CREATE TABLE dbo.calendar (d DATETIME NOT NULL CONSTRAINT PK_calendar PRIMARY KEY)
GO
DECLARE @d DATETIME = '1/1/2000'
WHILE (@d < '1/1/2021')
BEGIN
    INSERT INTO dbo.calendar (d) VALUES (@d)
    SELECT @d =  DATEADD(DAY, 1, @d)
END

Ecco la query stessa:

WITH myCTE AS (SELECT PP.ProductID, calendar.d AS TransactionDate, 
                    SUM(ActualCost) AS CostPerDate
                FROM Production.Product PP
                CROSS JOIN calendar
                LEFT OUTER JOIN Production.TransactionHistory PTH
                    ON PP.ProductID = PTH.ProductID
                    AND calendar.d = PTH.TransactionDate
                CROSS APPLY (SELECT MAX(TransactionDate) AS EndDate,
                                MIN(TransactionDate) AS StartDate
                            FROM Production.TransactionHistory) AS Boundaries
                WHERE calendar.d BETWEEN Boundaries.StartDate AND Boundaries.EndDate
                GROUP BY PP.ProductID, calendar.d),
    RunningTotal AS (
        SELECT ProductId, TransactionDate, CostPerDate AS TBE,
                SUM(myCTE.CostPerDate) OVER (
                    PARTITION BY myCTE.ProductID
                    ORDER BY myCTE.TransactionDate
                    ROWS BETWEEN 
                        45 PRECEDING
                        AND CURRENT ROW) AS RollingSum45
        FROM myCTE)
SELECT 
    TH.ProductID,
    TH.TransactionDate,
    TH.ActualCost,
    RollingSum45
FROM Production.TransactionHistory AS TH
JOIN RunningTotal
    ON TH.ProductID = RunningTotal.ProductID
    AND TH.TransactionDate = RunningTotal.TransactionDate
WHERE RunningTotal.TBE IS NOT NULL
ORDER BY
    TH.ProductID,
    TH.TransactionDate,
    TH.ReferenceOrderID;

Fondamentalmente ho deciso che il modo più semplice per gestirlo era usare il opzione per la clausola ROWS. Ma ciò richiedeva che avessi solo una riga per ProductID, TransactionDatecombinazione e non solo, ma dovevo avere una riga per ProductIDe possible date. L'ho fatto combinando le tabelle Product, calendar e TransactionHistory in un CTE. Quindi ho dovuto creare un altro CTE per generare le informazioni a rotazione. Ho dovuto farlo perché se mi sono unito alla tabella originale direttamente ho ottenuto l'eliminazione della riga che ha buttato via i miei risultati. Dopodiché si trattava di ricollegare il mio secondo CTE alla tabella originale. Ho aggiunto la TBEcolonna (da eliminare) per eliminare le righe vuote create nei CTE. Inoltre ho usato a CROSS APPLYnel CTE iniziale per generare limiti per la mia tabella del calendario.

Ho quindi aggiunto l'indice raccomandato:

CREATE NONCLUSTERED INDEX [TransactionHistory_IX1]
ON [Production].[TransactionHistory] ([TransactionDate])
INCLUDE ([ProductID],[ReferenceOrderID],[ActualCost])

E ottenuto il piano di esecuzione finale:

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

EDIT: Alla fine ho aggiunto un indice sulla tabella del calendario che ha accelerato le prestazioni con un margine ragionevole.

CREATE INDEX ix_calendar ON calendar(d)

2
La RunningTotal.TBE IS NOT NULLcondizione (e, di conseguenza, la TBEcolonna) non è necessaria. Non otterrai righe ridondanti se la lasci, perché la tua condizione di join interna include la colonna della data, quindi il set di risultati non può avere date che non erano originariamente nella fonte.
Andriy M,

2
Sì. Sono completamente d'accordo. Eppure mi ha fatto guadagnare ancora di circa 0,2 secondi. Penso che consenta all'ottimizzatore di conoscere alcune informazioni aggiuntive.
Kenneth Fisher,

4

Ho alcune soluzioni alternative che non usano indici o tabelle di riferimento. Forse potrebbero essere utili in situazioni in cui non si ha accesso a tabelle aggiuntive e non è possibile creare indici. Sembra essere possibile ottenere risultati corretti quando si raggruppa TransactionDatecon un solo passaggio dei dati e una sola funzione della finestra. Tuttavia, non sono riuscito a capire un modo per farlo con una sola funzione della finestra quando non puoi raggruppare TransactionDate.

Per fornire un quadro di riferimento, sulla mia macchina la soluzione originale pubblicata nella domanda ha un tempo di CPU di 2808 ms senza l'indice di copertura e 1950 ms con l'indice di copertura. Sto testando con il database AdventureWorks2014 e SQL Server Express 2014.

Cominciamo con una soluzione per quando possiamo raggruppare TransactionDate. Una somma corrente negli ultimi X giorni può essere espressa anche nel modo seguente:

Somma corrente per una riga = somma corrente di tutte le righe precedenti - somma corrente di tutte le righe precedenti per le quali la data è al di fuori della finestra della data.

In SQL, un modo per esprimerlo consiste nel creare due copie dei dati e per la seconda copia, moltiplicando il costo per -1 e aggiungendo X + 1 giorni alla colonna della data. Il calcolo di una somma corrente su tutti i dati implementerà la formula sopra. Lo mostrerò per alcuni dati di esempio. Di seguito è riportata una data di esempio per un singolo ProductID. Rappresento le date come numeri per facilitare i calcoli. Dati di partenza:

╔══════╦══════╗
 Date  Cost 
╠══════╬══════╣
    1     3 
    2     6 
   20     1 
   45    -4 
   47     2 
   64     2 
╚══════╩══════╝

Aggiungi una seconda copia dei dati. La seconda copia ha aggiunto 46 giorni alla data e il costo moltiplicato per -1:

╔══════╦══════╦═══════════╗
 Date  Cost  CopiedRow 
╠══════╬══════╬═══════════╣
    1     3          0 
    2     6          0 
   20     1          0 
   45    -4          0 
   47    -3          1 
   47     2          0 
   48    -6          1 
   64     2          0 
   66    -1          1 
   91     4          1 
   93    -2          1 
  110    -2          1 
╚══════╩══════╩═══════════╝

Prendi la somma corrente ordinata in ordine Datecrescente o CopiedRowdecrescente:

╔══════╦══════╦═══════════╦════════════╗
 Date  Cost  CopiedRow  RunningSum 
╠══════╬══════╬═══════════╬════════════╣
    1     3          0           3 
    2     6          0           9 
   20     1          0          10 
   45    -4          0           6 
   47    -3          1           3 
   47     2          0           5 
   48    -6          1          -1 
   64     2          0           1 
   66    -1          1           0 
   91     4          1           4 
   93    -2          1           0 
  110    -2          1           0 
╚══════╩══════╩═══════════╩════════════╝

Filtra le righe copiate per ottenere il risultato desiderato:

╔══════╦══════╦═══════════╦════════════╗
 Date  Cost  CopiedRow  RunningSum 
╠══════╬══════╬═══════════╬════════════╣
    1     3          0           3 
    2     6          0           9 
   20     1          0          10 
   45    -4          0           6 
   47     2          0           5 
   64     2          0           1 
╚══════╩══════╩═══════════╩════════════╝

Il seguente SQL è un modo per implementare l'algoritmo sopra:

WITH THGrouped AS 
(
    SELECT
    ProductID,
    TransactionDate,
    SUM(ActualCost) ActualCost
    FROM Production.TransactionHistory
    GROUP BY ProductID,
    TransactionDate
)
SELECT
ProductID,
TransactionDate,
ActualCost,
RollingSum45
FROM
(
    SELECT
    TH.ProductID,
    TH.ActualCost,
    t.TransactionDate,
    SUM(t.ActualCost) OVER (PARTITION BY TH.ProductID ORDER BY t.TransactionDate, t.OrderFlag) AS RollingSum45,
    t.OrderFlag,
    t.FilterFlag -- define this column to avoid another sort at the end
    FROM THGrouped AS TH
    CROSS APPLY (
        VALUES
        (TH.ActualCost, TH.TransactionDate, 1, 0),
        (-1 * TH.ActualCost, DATEADD(DAY, 46, TH.TransactionDate), 0, 1)
    ) t (ActualCost, TransactionDate, OrderFlag, FilterFlag)
) tt
WHERE tt.FilterFlag = 0
ORDER BY
tt.ProductID,
tt.TransactionDate,
tt.OrderFlag
OPTION (MAXDOP 1);

Sulla mia macchina ci sono voluti 702 ms di tempo CPU con l'indice di copertura e 734 ms di tempo CPU senza l'indice. Il piano di query è disponibile qui: https://www.brentozar.com/pastetheplan/?id=SJdCsGVSl

Un aspetto negativo di questa soluzione è che sembra esserci un ordinamento inevitabile quando si ordina dalla nuova TransactionDatecolonna. Non penso che questo ordinamento possa essere risolto aggiungendo indici perché dobbiamo combinare due copie dei dati prima di effettuare l'ordinamento. Sono stato in grado di sbarazzarmi di un tipo alla fine della query aggiungendo una colonna diversa a ORDER BY. Se avessi ordinato da, FilterFlagavrei scoperto che SQL Server avrebbe ottimizzato quella colonna dall'ordinamento ed avrebbe eseguito un ordinamento esplicito.

Le soluzioni per quando dobbiamo restituire un set di risultati con TransactionDatevalori duplicati per lo stesso ProductIderano molto più complicate. Riassumo il problema in quanto contemporaneamente è necessario partizionare e ordinare per la stessa colonna. La sintassi fornita da Paul risolve tale problema, quindi non sorprende che sia così difficile da esprimere con le funzioni della finestra corrente disponibili in SQL Server (se non fosse difficile da esprimere non sarebbe necessario espandere la sintassi).

Se utilizzo la query sopra senza raggruppare, ottengo valori diversi per la somma progressiva quando ci sono più righe con lo stesso ProductIde TransactionDate. Un modo per risolverlo è eseguire lo stesso calcolo della somma corrente come sopra ma anche contrassegnare l'ultima riga nella partizione. Questo può essere fatto con LEAD(supponendo che ProductIDnon sia mai NULL) senza un ordinamento aggiuntivo. Per il valore della somma parziale finale, utilizzo MAXcome funzione di finestra per applicare il valore nell'ultima riga della partizione a tutte le righe della partizione.

SELECT
ProductID,
TransactionDate,
ReferenceOrderID,
ActualCost,
MAX(CASE WHEN LasttRowFlag = 1 THEN RollingSum ELSE NULL END) OVER (PARTITION BY ProductID, TransactionDate) RollingSum45
FROM
(
    SELECT
    TH.ProductID,
    TH.ActualCost,
    TH.ReferenceOrderID,
    t.TransactionDate,
    SUM(t.ActualCost) OVER (PARTITION BY TH.ProductID ORDER BY t.TransactionDate, t.OrderFlag, TH.ReferenceOrderID) RollingSum,
    CASE WHEN LEAD(TH.ProductID) OVER (PARTITION BY TH.ProductID, t.TransactionDate ORDER BY t.OrderFlag, TH.ReferenceOrderID) IS NULL THEN 1 ELSE 0 END LasttRowFlag,
    t.OrderFlag,
    t.FilterFlag -- define this column to avoid another sort at the end
    FROM Production.TransactionHistory AS TH
    CROSS APPLY (
        VALUES
        (TH.ActualCost, TH.TransactionDate, 1, 0),
        (-1 * TH.ActualCost, DATEADD(DAY, 46, TH.TransactionDate), 0, 1)
    ) t (ActualCost, TransactionDate, OrderFlag, FilterFlag)
) tt
WHERE tt.FilterFlag = 0
ORDER BY
tt.ProductID,
tt.TransactionDate,
tt.OrderFlag,
tt.ReferenceOrderID
OPTION (MAXDOP 1);  

Sulla mia macchina ci sono voluti 2464ms di tempo CPU senza l'indice di copertura. Come prima sembra esserci un tipo inevitabile. Il piano di query è disponibile qui: https://www.brentozar.com/pastetheplan/?id=HyWxhGVBl

Penso che ci siano margini di miglioramento nella query sopra. Esistono sicuramente altri modi per utilizzare le funzioni di Windows per ottenere il risultato desiderato.

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.