Soma rolante do período usando as funções da janela

57

Preciso calcular uma soma rolante em um período. Para ilustrar, usando o banco de dados de exemplo AdventureWorks , a seguinte sintaxe hipotética faria exatamente o que eu preciso:

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;

Infelizmente, RANGEatualmente , a extensão do quadro da janela não permite um intervalo no SQL Server.

Eu sei que posso escrever uma solução usando uma subconsulta e um agregado regular (sem janela):

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;

Dado o seguinte índice:

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

O plano de execução é:

Plano de execução

Embora não seja terrivelmente ineficiente, parece que deve ser possível expressar essa consulta usando apenas funções agregadas e analíticas de janela suportadas no SQL Server 2012, 2014 ou 2016 (até agora).

Para maior clareza, estou procurando uma solução que execute uma única passagem pelos dados.

No T-SQL, é provável que isso signifique que a OVERcláusula fará o trabalho e o plano de execução apresentará Spools e Agregados de Janelas. Todos os elementos de linguagem que usam a OVERcláusula são justos. Uma solução SQLCLR é aceitável, desde que seja garantido que produza resultados corretos.

Para soluções T-SQL, quanto menos Hashes, Sortes e Spoils / Agregados de Janelas no plano de execução, melhor. Sinta-se à vontade para adicionar índices, mas estruturas separadas não são permitidas (portanto, nenhuma tabela pré-calculada é mantida sincronizada com gatilhos, por exemplo). Tabelas de referência são permitidas (tabelas de números, datas etc.)

Idealmente, as soluções produzirão exatamente os mesmos resultados na mesma ordem que a versão da subconsulta acima, mas qualquer coisa que esteja discutivelmente correta também é aceitável. O desempenho é sempre uma consideração, portanto as soluções devem ser pelo menos razoavelmente eficientes.

Sala de bate-papo dedicada: criei uma sala de bate-papo pública para discussões relacionadas a esta pergunta e suas respostas. Qualquer usuário com pelo menos 20 pontos de reputação pode participar diretamente. Por favor, envie-me um comentário abaixo se você tiver menos de 20 representantes e gostaria de participar.

Paul White
fonte

Respostas:

42

Ótima pergunta, Paul! Eu usei algumas abordagens diferentes, uma no T-SQL e outra no CLR.

Resumo Rápido do T-SQL

A abordagem T-SQL pode ser resumida como as seguintes etapas:

  • Obtenha o produto cruzado de produtos / datas
  • Mesclar nos dados de vendas observados
  • Agregue esses dados ao nível do produto / data
  • Calcular somas contínuas nos últimos 45 dias com base nesses dados agregados (que contêm os dias "ausentes" preenchidos)
  • Filtre esses resultados apenas para os pares de produtos / datas que tiveram uma ou mais vendas

Usando SET STATISTICS IO ON, essa abordagem relata Table 'TransactionHistory'. Scan count 1, logical reads 484, o que confirma a "passagem única" sobre a tabela. Para referência, os relatórios de consulta de busca de loop original Table 'TransactionHistory'. Scan count 113444, logical reads 438366.

Conforme relatado por SET STATISTICS TIME ON, o tempo da CPU é 514ms. Isso se compara favoravelmente com 2231msa consulta original.

Resumo rápido do CLR

O resumo do CLR pode ser resumido como as seguintes etapas:

  • Leia os dados na memória, ordenados por produto e data
  • Ao processar cada transação, adicione um total dos custos em execução. Sempre que uma transação for um produto diferente da transação anterior, redefina o total em execução para 0.
  • Mantenha um ponteiro para a primeira transação que tenha o mesmo (produto, data) que a transação atual. Sempre que a última transação com esse (produto, data) for encontrada, calcule a soma rolante dessa transação e aplique-a a todas as transações com o mesmo (produto, data)
  • Retorne todos os resultados ao usuário!

Usando SET STATISTICS IO ON, essa abordagem relata que nenhuma E / S lógica ocorreu! Uau, uma solução perfeita! (Na verdade, parece que SET STATISTICS IOnão informa E / S incorridas no CLR. Mas, a partir do código, é fácil ver que exatamente uma varredura da tabela é feita e recupera os dados em ordem pelo índice sugerido por Paul.

Conforme relatado por SET STATISTICS TIME ON, o tempo da CPU é agora 187ms. Portanto, isso é uma melhoria em relação à abordagem T-SQL. Infelizmente, o tempo total decorrido de ambas as abordagens é muito semelhante em cerca de meio segundo cada. No entanto, a abordagem baseada em CLR precisa gerar 113K linhas no console (contra apenas 52K na abordagem T-SQL que agrupa por produto / data), por isso, concentrei-me no tempo da CPU.

Outra grande vantagem dessa abordagem é que ela produz exatamente os mesmos resultados da abordagem de loop / busca original, incluindo uma linha para cada transação, mesmo nos casos em que um produto é vendido várias vezes no mesmo dia. (No AdventureWorks, comparei especificamente os resultados linha a linha e confirmei que eles estavam relacionados à consulta original de Paul.)

Uma desvantagem dessa abordagem, pelo menos em sua forma atual, é que ela lê todos os dados na memória. No entanto, o algoritmo que foi projetado precisa estritamente apenas do quadro de janela atual na memória a qualquer momento e pode ser atualizado para funcionar com conjuntos de dados que excedem a memória. Paul ilustrou esse ponto em sua resposta produzindo uma implementação desse algoritmo que armazena apenas a janela deslizante na memória. Isso ocorre às custas da concessão de permissões mais altas ao assembly CLR, mas definitivamente valeria a pena escalar essa solução para conjuntos de dados arbitrariamente grandes.


T-SQL - uma varredura, agrupada por data

Configuração inicial

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

A pergunta

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)

O plano de execução

No plano de execução, vemos que o índice original proposto por Paul é suficiente para nos permitir executar uma única varredura ordenada de Production.TransactionHistory, usando uma junção de mesclagem para combinar o histórico de transações com cada combinação possível de produto / data.

insira a descrição da imagem aqui

Premissas

Existem algumas suposições significativas incorporadas a essa abordagem. Suponho que caberá a Paul decidir se são aceitáveis ​​:)

  • Eu estou usando a Production.Productmesa Essa tabela está disponível gratuitamente AdventureWorks2012e o relacionamento é imposto por uma chave estrangeira Production.TransactionHistory, então eu interpretei isso como um jogo justo.
  • Essa abordagem baseia-se no fato de que as transações não têm um componente de tempo AdventureWorks2012; se o fizessem, a geração do conjunto completo de combinações de produto / data não seria mais possível sem primeiro passar pelo histórico da transação.
  • Estou produzindo um conjunto de linhas que contém apenas uma linha por par de produto / data. Eu acho que isso é "discutivelmente correto" e, em muitos casos, um resultado mais desejável para retornar. Para cada produto / data, adicionei uma NumOrderscoluna para indicar quantas vendas ocorreram. Consulte a captura de tela a seguir para obter uma comparação dos resultados da consulta original versus a consulta proposta nos casos em que um produto foi vendido várias vezes na mesma data (por exemplo, 319/ 2007-09-05 00:00:00.000)

insira a descrição da imagem aqui


CLR - uma varredura, conjunto de resultados desagrupado completo

O corpo principal da função

Não há muito para ver aqui; o corpo principal da função declara as entradas (que devem corresponder à função SQL correspondente), configura uma conexão SQL e abre o 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;
    }
}

A lógica central

Separei a lógica principal para facilitar o foco:

// 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;
}

Ajudantes

A lógica a seguir pode ser escrita em linha, mas é um pouco mais fácil de ler quando eles são divididos em seus próprios métodos.

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;
}

Associando tudo no SQL

Até o momento, tudo estava em C #, então vamos ver o SQL real envolvido. (Como alternativa, você pode usar esse script de implantação para criar o assembly diretamente dos bits do meu assembly, em vez de se compilar.)

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

Ressalvas

A abordagem CLR oferece muito mais flexibilidade para otimizar o algoritmo e provavelmente poderia ser ajustada ainda mais por um especialista em C #. No entanto, também existem desvantagens na estratégia de CLR. Algumas coisas a ter em mente:

  • Essa abordagem CLR mantém uma cópia do conjunto de dados na memória. É possível usar uma abordagem de streaming, mas eu encontrei dificuldades iniciais e descobri que há um problema pendente do Connect reclamando que as alterações no SQL 2008+ tornam mais difícil o uso desse tipo de abordagem. Ainda é possível (como Paul demonstra), mas requer um nível mais alto de permissões, definindo o banco de dados como TRUSTWORTHYe concedendo EXTERNAL_ACCESSao assembly CLR. Portanto, existem algumas implicações de segurança e possíveis aborrecimentos, mas o retorno é uma abordagem de streaming que pode ser melhor dimensionada para conjuntos de dados muito maiores do que os do AdventureWorks.
  • O CLR pode ser menos acessível para alguns DBAs, tornando essa função mais uma caixa preta que não é tão transparente, nem tão facilmente modificada, nem tão facilmente implantada e talvez não tão facilmente depurada. Essa é uma grande desvantagem quando comparada a uma abordagem T-SQL.


Bônus: T-SQL # 2 - a abordagem prática que eu realmente usaria

Depois de tentar pensar criativamente sobre o problema por um tempo, pensei em publicar também a maneira prática bastante simples e que provavelmente escolheria resolver esse problema se ele aparecesse no meu trabalho diário. Ele usa a funcionalidade de janela do SQL 2012+, mas não do tipo inovador que a pergunta esperava:

-- 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

Na verdade, isso gera um plano de consulta geral bastante simples, mesmo quando analisamos os dois planos de consulta relevantes juntos:

insira a descrição da imagem aqui insira a descrição da imagem aqui

Algumas razões pelas quais eu gosto dessa abordagem:

  • Ele produz o conjunto completo de resultados solicitado na declaração do problema (em oposição à maioria das outras soluções T-SQL, que retornam uma versão agrupada dos resultados).
  • É fácil explicar, entender e depurar; Não voltarei um ano depois e me pergunto como diabos eu posso fazer uma pequena alteração sem arruinar a correção ou o desempenho
  • Ele roda aproximadamente 900msno conjunto de dados fornecido, e não no 2700msda busca de loop original
  • Se os dados forem muito mais densos (mais transações por dia), a complexidade computacional não aumenta quadraticamente com o número de transações na janela deslizante (como ocorre na consulta original); Eu acho que isso aborda parte da preocupação de Paul em querer evitar várias digitalizações
  • Isso resulta basicamente em nenhuma E / S tempdb nas atualizações recentes do SQL 2012+ devido à nova funcionalidade de gravação lenta tempdb
  • Para conjuntos de dados muito grandes, é trivial dividir o trabalho em lotes separados para cada produto se a pressão da memória se tornar uma preocupação

Algumas advertências em potencial:

  • Embora tecnicamente varra o Production.TransactionHistory apenas uma vez, não é realmente uma abordagem de "uma varredura", porque a tabela #temp de tamanho semelhante e também precisará executar logicion I / O adicional nessa tabela. No entanto, não vejo isso muito diferente de uma tabela de trabalho sobre a qual temos mais controle manual, pois definimos sua estrutura precisa
  • Dependendo do seu ambiente, o uso do tempdb pode ser visto como positivo (por exemplo, em um conjunto separado de unidades SSD) ou negativo (alta simultaneidade no servidor, já existe muita contenção de tempdb)
Geoff Patterson
fonte
25

Como é uma resposta longa, decidi adicionar um resumo aqui.

  • A princípio, apresento uma solução que produz exatamente o mesmo resultado na mesma ordem que na pergunta. Ele varre a tabela principal três vezes: para obter uma lista ProductIDscom o intervalo de datas de cada Produto, resumir os custos de cada dia (porque existem várias transações com as mesmas datas), para associar o resultado às linhas originais.
  • Em seguida, comparo duas abordagens que simplificam a tarefa e evitam uma última varredura da tabela principal. O resultado é um resumo diário, ou seja, se várias transações em um Produto tiverem a mesma data, elas serão roladas em uma única linha. Minha abordagem da etapa anterior varre a tabela duas vezes. A abordagem de Geoff Patterson examina a tabela uma vez, porque ele usa conhecimento externo sobre o intervalo de datas e a lista de produtos.
  • Por fim, apresento uma solução de passe único que retorna novamente um resumo diário, mas não requer conhecimento externo sobre o intervalo de datas ou a lista de ProductIDs.

Vou usar o banco de dados AdventureWorks2014 e o SQL Server Express 2014.

Alterações no banco de dados original:

  • Tipo alterado de [Production].[TransactionHistory].[TransactionDate]de datetimepara date. O componente de tempo era zero de qualquer maneira.
  • Tabela de calendário adicionada [dbo].[Calendar]
  • Índice adicionado 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);

O artigo da MSDN sobre OVERcláusula tem um link para uma excelente postagem no blog sobre as funções da janela por Itzik Ben-Gan. Nesse cargo ele explica como OVERfunciona, a diferença entre ROWSe RANGEopções e menciona este mesmo problema de cálculo de uma soma rolando sobre um intervalo de datas. Ele menciona que a versão atual do SQL Server não implementa RANGEna íntegra e não implementa tipos de dados de intervalo temporal. Sua explicação da diferença entre ROWSe RANGEme deu uma ideia.

Datas sem intervalos e duplicatas

Se a TransactionHistorytabela contiver datas sem intervalos e sem duplicatas, a consulta a seguir produzirá resultados corretos:

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;

De fato, uma janela de 45 linhas cobriria exatamente 45 dias.

Datas com intervalos sem duplicatas

Infelizmente, nossos dados têm intervalos de datas. Para resolver esse problema, podemos usar uma Calendartabela para gerar um conjunto de datas sem intervalos, depois LEFT JOINdados originais para esse conjunto e usar a mesma consulta ROWS BETWEEN 45 PRECEDING AND CURRENT ROW. Isso produziria resultados corretos apenas se as datas não se repetissem (dentro da mesma ProductID).

Datas com intervalos com duplicatas

Infelizmente, nossos dados têm lacunas nas datas e as datas podem se repetir na mesma ProductID. Para resolver esse problema, podemos obter GROUPdados originais ProductID, TransactionDatepara gerar um conjunto de datas sem duplicatas. Em seguida, use a Calendartabela para gerar um conjunto de datas sem intervalos. Em seguida, podemos usar a consulta com ROWS BETWEEN 45 PRECEDING AND CURRENT ROWpara calcular o rolamento SUM. Isso produziria resultados corretos. Veja os comentários na consulta abaixo.

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
;

Confirmei que esta consulta produz os mesmos resultados que a abordagem da pergunta que usa subconsulta.

Planos de execução

Estatísticas

Primeira consulta usa subconsulta, segundo - esta abordagem. Você pode ver que a duração e o número de leituras são muito menos nessa abordagem. A maioria do custo estimado nessa abordagem é a final ORDER BY, veja abaixo.

subconsulta

A abordagem de subconsulta possui um plano simples com loops e O(n*n)complexidade aninhados .

sobre

O plano para essa abordagem varre TransactionHistoryvárias vezes, mas não há loops. Como você pode ver, mais de 70% do custo estimado é o Sortda final ORDER BY.

io

Resultado superior - subqueryinferior - OVER.


Evitando verificações extras

A última verificação de índice, mesclar junção e classificação no plano acima é causada pela final INNER JOINcom a tabela original para tornar o resultado final exatamente o mesmo que uma abordagem lenta com subconsulta. O número de linhas retornadas é o mesmo da TransactionHistorytabela. Existem linhas em TransactionHistoryque várias transações ocorreram no mesmo dia para o mesmo produto. Se não houver problema em mostrar apenas o resumo diário no resultado, essa final JOINpoderá ser removida e a consulta se tornará um pouco mais simples e um pouco mais rápida. A última Verificação de índice, Mesclar associação e Classificação do plano anterior são substituídas por Filtro, que remove as linhas adicionadas por 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
;

digitalização dupla

Ainda assim, TransactionHistoryé digitalizado duas vezes. É necessária uma varredura extra para obter o intervalo de datas para cada produto. Eu estava interessado em ver como ele se compara a outra abordagem, na qual usamos conhecimento externo sobre o intervalo global de datas TransactionHistory, além de uma tabela extra Productque tem tudo ProductIDspara evitar essa verificação extra. Eu removi o cálculo do número de transações por dia desta consulta para validar a comparação. Ele pode ser adicionado nas duas consultas, mas eu gostaria de simplificar a comparação. Eu também tive que usar outras datas, porque eu uso a versão 2014 do banco de dados.

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);

uma varredura

Ambas as consultas retornam o mesmo resultado na mesma ordem.

Comparação

Aqui estão as estatísticas de tempo e IO.

stats2

io2

A variante de duas varreduras é um pouco mais rápida e tem menos leituras, porque a variante de uma varredura precisa usar muito o Worktable. Além disso, a variante de uma varredura gera mais linhas do que o necessário, como você pode ver nos planos. Ele gera datas para cada um ProductIDque está na Producttabela, mesmo se um ProductIDnão tiver nenhuma transação. Existem 504 linhas na Producttabela, mas apenas 441 produtos possuem transações TransactionHistory. Além disso, gera o mesmo intervalo de datas para cada produto, mais do que o necessário. Se TransactionHistorytivesse um histórico geral mais longo, com cada produto individual tendo um histórico relativamente curto, o número de linhas desnecessárias extras seria ainda maior.

Por outro lado, é possível otimizar um pouco mais a variante de duas varreduras criando outro índice mais estreito apenas (ProductID, TransactionDate). Esse índice seria usado para calcular as datas de início / término de cada produto ( CTE_Products) e teria menos páginas do que o índice de cobertura e, como resultado, causaria menos leituras.

Assim, podemos escolher, ter uma verificação simples explícita extra ou ter uma tabela de trabalho implícita.

BTW, se não há problema em obter resultados apenas com resumos diários, é melhor criar um índice que não inclua ReferenceOrderID. Usaria menos páginas => menos IO.

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

Solução de passagem única usando o CROSS APPLY

Torna-se uma resposta realmente longa, mas aqui está mais uma variante que retorna apenas resumo diário novamente, mas faz apenas uma varredura dos dados e não requer conhecimento externo sobre o intervalo de datas ou a lista de IDs do produto. Também não faz classificações intermediárias. O desempenho geral é semelhante às variantes anteriores, embora pareça um pouco pior.

A idéia principal é usar uma tabela de números para gerar linhas que preencham as lacunas nas datas. Para cada data existente, use LEADpara calcular o tamanho do intervalo em dias e, em seguida, use CROSS APPLYpara adicionar o número necessário de linhas no conjunto de resultados. No começo, tentei com uma tabela permanente de números. O plano mostrava um grande número de leituras nesta tabela, embora a duração real fosse praticamente a mesma, como quando eu gerava números em tempo real 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
;

Este plano é "mais longo", porque a consulta usa duas funções de janela ( LEADe SUM).

aplicação cruzada

estatísticas ca

ca io

Vladimir Baranov
fonte
23

Uma solução SQLCLR alternativa que é executada mais rapidamente e requer menos memória:

Script de implantação

Isso requer o EXTERNAL_ACCESSconjunto de permissões porque ele usa uma conexão de loopback com o servidor de destino e o banco de dados em vez da conexão de contexto (lenta). É assim que se chama a função:

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;

Produz exatamente os mesmos resultados, na mesma ordem, que a pergunta.

Plano de execução:

Plano de execução do SQLCLR TVF

Plano de execução da consulta de origem SQLCLR

Planejar estatísticas de desempenho do Explorer

Leituras lógicas do criador de perfil: 481

A principal vantagem dessa implementação é que ela é mais rápida do que a conexão de contexto e utiliza menos memória. Ele mantém apenas duas coisas na memória por vez:

  1. Quaisquer linhas duplicadas (mesmo produto e data da transação). Isso é necessário porque, até que o produto ou a data sejam alterados, não sabemos qual será a soma final da execução. Nos dados de amostra, há uma combinação de produto e data que possui 64 linhas.
  2. Um intervalo deslizante de 45 dias apenas de datas de custo e transação, para o produto atual. Isso é necessário para ajustar a soma de execução simples para as linhas que saem da janela deslizante de 45 dias.

Esse cache mínimo deve garantir que esse método seja dimensionado bem; certamente melhor do que tentar manter toda a entrada definida na memória CLR.

Código fonte

Paul White
fonte
17

Se você estiver na edição Enterprise, Developer ou Evaluation de 64 bits do SQL Server 2014, poderá usar o OLTP na memória . A solução não será uma única varredura e quase não utilizará nenhuma função da janela, mas poderá agregar algum valor a essa pergunta e o algoritmo usado poderá ser usado como inspiração para outras soluções.

Primeiro, você precisa habilitar o OLTP na memória no banco de dados 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;

O parâmetro para o procedimento é uma variável da tabela In-Memory e deve ser definida como um 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);

O ID não é exclusivo nesta tabela, é único para cada combinação de ProductIDe TransactionDate.

Existem alguns comentários no procedimento que informam o que ele faz, mas no geral ele calcula o total de execução em um loop e, para cada iteração, faz uma pesquisa no total de execução como era há 45 dias (ou mais).

O total atual atual menos o total atual há 45 dias é a soma contínua de 45 dias que estamos procurando.

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;

Invoque o procedimento como este.

-- 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;

Testar isso no meu computador As Estatísticas do Cliente reportam um Tempo total de execução de cerca de 750 milissegundos. Para comparações, a versão da subconsulta leva 3,5 segundos.

Divagações extras:

Esse algoritmo também pode ser usado pelo T-SQL regular. Calcule o total em execução, rangesem linhas, e armazene o resultado em uma tabela temporária. Em seguida, você pode consultar essa tabela com uma associação automática ao total em execução, como era há 45 dias atrás, e calcular a soma rolante. No entanto, a implementação de rangecompare to rowsé bastante lenta devido ao fato de que é necessário tratar duplicatas da ordem por cláusula de maneira diferente, para que eu não tenha todo esse bom desempenho com essa abordagem. Uma solução alternativa para isso poderia ser usar outra função da janela, como last_value()sobre um total calculado em execução, usando rowspara simular um rangetotal em execução. Outra maneira é usar max() over(). Ambos tiveram alguns problemas. Localizando o índice apropriado a ser usado para evitar classificações e evitar spools com omax() over()versão. Eu desisti de otimizar essas coisas, mas se você estiver interessado no código que eu tenho até agora, por favor me avise.

Mikael Eriksson
fonte
13

Bem, isso foi divertido :) Minha solução é um pouco mais lenta que a do @ GeoffPatterson, mas parte disso é o fato de estar voltando à tabela original para eliminar uma das suposições de Geoff (ou seja, uma linha por par de produto / data) . Fui assumindo que essa era uma versão simplificada de uma consulta final e pode exigir informações adicionais da tabela original.

Nota: Estou emprestando a tabela de calendário de Geoff e, de fato, acabei com uma solução muito semelhante:

-- 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

Aqui está a própria consulta:

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;

Basicamente, decidi que a maneira mais fácil de lidar com isso era usar o opção para a cláusula ROWS. Mas isso exigia que eu tivesse apenas uma linha por ProductID, TransactionDatecombinação e não apenas isso, mas tinha que ter uma linha por ProductIDe possible date. Fiz isso combinando as tabelas Product, calendar e TransactionHistory em um CTE. Então tive que criar outro CTE para gerar as informações contínuas. Eu tive que fazer isso porque se eu a juntasse diretamente à tabela original, obteria uma eliminação de linha que desperdiçaria meus resultados. Depois disso, foi uma simples questão de juntar meu segundo CTE de volta à tabela original. Eu adicionei a TBEcoluna (a ser eliminada) para se livrar das linhas em branco criadas nos CTEs. Também usei um CROSS APPLYno CTE inicial para gerar limites para minha tabela de calendários.

Adicionei o índice recomendado:

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

E obteve o plano final de execução:

insira a descrição da imagem aqui insira a descrição da imagem aqui insira a descrição da imagem aqui

EDIT: No final, adicionei um índice na tabela do calendário que acelerava o desempenho por uma margem razoável.

CREATE INDEX ix_calendar ON calendar(d)
Kenneth Fisher
fonte
2
A RunningTotal.TBE IS NOT NULLcondição (e, consequentemente, a TBEcoluna) é desnecessária. Você não receberá linhas redundantes se a soltar, porque sua condição de junção interna inclui a coluna de data - portanto, o conjunto de resultados não pode ter datas que não estavam originalmente na origem.
187 Andriy M
2
Sim. Eu concordo completamente. E ainda assim me fez ganhar cerca de 0,2 segundos. Eu acho que isso permite que o otimizador conheça algumas informações adicionais.
9139 Kenneth Fisher
4

Eu tenho algumas soluções alternativas que não usam índices ou tabelas de referência. Talvez eles possam ser úteis em situações nas quais você não tem acesso a nenhuma tabela adicional e não pode criar índices. Parece ser possível obter resultados corretos ao agrupar TransactionDatecom apenas uma única passagem dos dados e apenas uma função de janela. No entanto, não consegui descobrir uma maneira de fazer isso com apenas uma função da janela quando você não pode agrupar por TransactionDate.

Para fornecer um quadro de referência, na minha máquina, a solução original postada na pergunta tem um tempo de CPU de 2808 ms sem o índice de cobertura e 1950 ms com o índice de cobertura. Estou testando com o banco de dados AdventureWorks2014 e o SQL Server Express 2014.

Vamos começar com uma solução para quando podemos agrupar TransactionDate. Uma soma contínua nos últimos X dias também pode ser expressa da seguinte maneira:

Soma em execução para uma linha = soma em execução de todas as linhas anteriores - soma em execução de todas as linhas anteriores para as quais a data está fora da janela de data.

No SQL, uma maneira de expressar isso é fazendo duas cópias dos seus dados e para a segunda cópia, multiplicando o custo por -1 e adicionando X + 1 dias à coluna da data. A computação de uma soma contínua em todos os dados implementará a fórmula acima. Vou mostrar isso para alguns dados de exemplo. Abaixo está uma data de amostra para um single ProductID. Eu represento datas como números para facilitar os cálculos. Dados iniciais:

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

Adicione uma segunda cópia dos dados. A segunda cópia tem 46 dias adicionados à data e o custo multiplicado por -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 
╚══════╩══════╩═══════════╝

Tome a soma corrente ordenada por Dateascendente e CopiedRowdescendente:

╔══════╦══════╦═══════════╦════════════╗
 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 
╚══════╩══════╩═══════════╩════════════╝

Filtre as linhas copiadas para obter o resultado desejado:

╔══════╦══════╦═══════════╦════════════╗
 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 
╚══════╩══════╩═══════════╩════════════╝

O SQL a seguir é uma maneira de implementar o algoritmo acima:

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);

Na minha máquina, isso levou 702 ms de tempo de CPU com o índice de cobertura e 734 ms de tempo de CPU sem o índice. O plano de consulta pode ser encontrado aqui: https://www.brentozar.com/pastetheplan/?id=SJdCsGVSl

Uma desvantagem desta solução é que parece haver uma classificação inevitável ao fazer o pedido pela nova TransactionDatecoluna. Não acho que esse tipo possa ser resolvido adicionando índices, pois precisamos combinar duas cópias dos dados antes de fazer o pedido. Consegui me livrar de uma classificação no final da consulta adicionando uma coluna diferente ao ORDER BY. Se eu pedisse, FilterFlagdescobri que o SQL Server otimizaria essa coluna da classificação e executaria uma classificação explícita.

As soluções para quando precisamos retornar um conjunto de resultados com TransactionDatevalores duplicados para o mesmo ProductIderam muito mais complicadas. Eu resumiria o problema como simultaneamente necessário particionar e ordenar pela mesma coluna. A sintaxe fornecida por Paul resolve esse problema, não surpreende que seja tão difícil expressar com as funções atuais da janela disponíveis no SQL Server (se não fosse difícil expressar, não haveria necessidade de expandir a sintaxe).

Se eu usar a consulta acima sem agrupar, obtenho valores diferentes para a soma rolante quando houver várias linhas com o mesmo ProductIde TransactionDate. Uma maneira de resolver isso é fazer o mesmo cálculo da soma de execução acima, mas também sinalizar a última linha da partição. Isso pode ser feito com LEAD(supondo que ProductIDnunca seja NULL) sem uma classificação adicional. Para o valor final da soma em execução, eu uso MAXcomo função de janela para aplicar o valor na última linha da partição a todas as linhas na partição.

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);  

Na minha máquina, isso levou 2464ms de tempo de CPU sem o índice de cobertura. Como antes, parece haver um tipo inevitável. O plano de consulta pode ser encontrado aqui: https://www.brentozar.com/pastetheplan/?id=HyWxhGVBl

Eu acho que há espaço para melhorias na consulta acima. Certamente, existem outras maneiras de usar as funções do Windows para obter o resultado desejado.

Joe Obbish
fonte