Suma variable de rango de fechas utilizando funciones de ventana

57

Necesito calcular una suma continua en un rango de fechas. Para ilustrar, utilizando la base de datos de ejemplo AdventureWorks , la siguiente sintaxis hipotética haría exactamente lo que necesito:

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;

Lamentablemente, la RANGEextensión del marco de la ventana no permite actualmente un intervalo en SQL Server.

Sé que puedo escribir una solución usando una subconsulta y un agregado regular (sin ventana):

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 el siguiente índice:

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

El plan de ejecución es:

Plan de ejecución

Si bien no es terriblemente ineficiente, parece que debería ser posible expresar esta consulta utilizando solo funciones de análisis y agregado de ventana compatibles con SQL Server 2012, 2014 o 2016 (hasta ahora).

Para mayor claridad, estoy buscando una solución que realice un solo paso sobre los datos.

En T-SQL es probable que esto signifique que la OVERcláusula hará el trabajo, y el plan de ejecución contará con spools y agregados de ventanas. Todos los elementos del lenguaje que usan la OVERcláusula son juegos justos. Una solución SQLCLR es aceptable, siempre que esté garantizada. que produzca resultados correctos.

Para las soluciones T-SQL, cuantos menos hashes, clasificaciones y spools / agregados de ventana en el plan de ejecución, mejor. Siéntase libre de agregar índices, pero no se permiten estructuras separadas (por lo que no hay tablas calculadas previamente sincronizadas con los desencadenantes, por ejemplo). Se permiten tablas de referencia (tablas de números, fechas, etc.)

Idealmente, las soluciones producirán exactamente los mismos resultados en el mismo orden que la versión de subconsulta anterior, pero cualquier cosa posiblemente correcta también es aceptable. El rendimiento siempre es una consideración, por lo que las soluciones deben ser al menos razonablemente eficientes.

Sala de chat dedicada: he creado una sala de chat pública para debates relacionados con esta pregunta y sus respuestas. Cualquier usuario con al menos 20 puntos de reputación puede participar directamente. Envíenme un comentario a continuación si tiene menos de 20 repeticiones y desea participar.

Paul White
fuente

Respuestas:

42

Gran pregunta, Paul! Usé un par de enfoques diferentes, uno en T-SQL y otro en CLR.

Resumen rápido de T-SQL

El enfoque T-SQL se puede resumir en los siguientes pasos:

  • Tomar el producto cruzado de productos / fechas
  • Fusionar en los datos de ventas observados
  • Agregar esos datos al nivel de producto / fecha
  • Calcule sumas continuas en los últimos 45 días en función de estos datos agregados (que contienen los días "faltantes" rellenados)
  • Filtre esos resultados solo para los emparejamientos de producto / fecha que tuvieron una o más ventas

Usando SET STATISTICS IO ON, este enfoque informa Table 'TransactionHistory'. Scan count 1, logical reads 484, lo que confirma el "pase único" sobre la mesa. Como referencia, la consulta original de búsqueda de bucle informa Table 'TransactionHistory'. Scan count 113444, logical reads 438366.

Según lo informado por SET STATISTICS TIME ON, el tiempo de CPU es 514ms. Esto se compara favorablemente con 2231msla consulta original.

Resumen rápido de CLR

El resumen de CLR se puede resumir en los siguientes pasos:

  • Lea los datos en la memoria, ordenados por producto y fecha.
  • Mientras procesa cada transacción, agregue un total acumulado de los costos. Siempre que una transacción sea un producto diferente a la transacción anterior, restablezca el total acumulado a 0.
  • Mantenga un puntero a la primera transacción que tenga el mismo (producto, fecha) que la transacción actual. Siempre que se encuentre la última transacción con ese (producto, fecha), calcule la suma acumulada para esa transacción y aplíquela a todas las transacciones con el mismo (producto, fecha)
  • ¡Devuelva todos los resultados al usuario!

Usando SET STATISTICS IO ON, este enfoque informa que no ha ocurrido E / S lógica. ¡Guau, una solución perfecta! (En realidad, parece que SET STATISTICS IOno informa las E / S incurridas dentro de CLR. Pero a partir del código, es fácil ver que se realiza exactamente un escaneo de la tabla y recupera los datos en orden según el índice que sugirió Paul.

Según lo informado por SET STATISTICS TIME ON, el tiempo de CPU es ahora187ms . Entonces, esta es una mejora considerable sobre el enfoque T-SQL. Desafortunadamente, el tiempo total transcurrido de ambos enfoques es muy similar a aproximadamente medio segundo cada uno. Sin embargo, el enfoque basado en CLR tiene que generar 113K filas en la consola (frente a solo 52K para el enfoque T-SQL que agrupa por producto / fecha), por eso me he centrado en el tiempo de CPU.

Otra gran ventaja de este enfoque es que produce exactamente los mismos resultados que el enfoque original de bucle / búsqueda, incluida una fila para cada transacción, incluso en los casos en que un producto se vende varias veces el mismo día. (En AdventureWorks, comparé específicamente los resultados fila por fila y confirmó que se relacionan con la consulta original de Paul).

Una desventaja de este enfoque, al menos en su forma actual, es que lee todos los datos en la memoria. Sin embargo, el algoritmo que se ha diseñado solo necesita estrictamente el marco de la ventana actual en la memoria en cualquier momento dado y podría actualizarse para que funcione para conjuntos de datos que exceden la memoria. Paul ha ilustrado este punto en su respuesta al producir una implementación de este algoritmo que almacena solo la ventana deslizante en la memoria. Esto se produce a expensas de otorgar permisos más altos al ensamblaje CLR, pero definitivamente valdría la pena escalar esta solución a conjuntos de datos arbitrariamente grandes.


T-SQL: una exploración, agrupada por fecha

Configuración 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

La consulta

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)

El plan de ejecucion

A partir del plan de ejecución, vemos que el índice original propuesto por Paul es suficiente para permitirnos realizar una exploración ordenada única de Production.TransactionHistory, utilizando una combinación de combinación para combinar el historial de transacciones con cada combinación posible de producto / fecha.

ingrese la descripción de la imagen aquí

Supuestos

Hay algunas suposiciones importantes integradas en este enfoque. Supongo que dependerá de Paul decidir si son aceptables :)

  • Estoy usando la Production.Productmesa. Esta tabla está disponible gratuitamente AdventureWorks2012y la relación se aplica mediante una clave externa de Production.TransactionHistory, por lo que interpreté esto como un juego justo.
  • Este enfoque se basa en el hecho de que las transacciones no tienen un componente de tiempo AdventureWorks2012; si lo hicieran, generar el conjunto completo de combinaciones de producto / fecha ya no sería posible sin primero pasar por el historial de transacciones.
  • Estoy produciendo un conjunto de filas que contiene solo una fila por par de producto / fecha. Creo que esto es "posiblemente correcto" y en muchos casos un resultado más deseable para volver. Para cada producto / fecha, he agregado una NumOrderscolumna para indicar cuántas ventas ocurrieron. Consulte la siguiente captura de pantalla para ver una comparación de los resultados de la consulta original en comparación con la consulta propuesta en los casos en que un producto se vendió varias veces en la misma fecha (por ejemplo, 319/ 2007-09-05 00:00:00.000)

ingrese la descripción de la imagen aquí


CLR: un escaneo, conjunto de resultados desagrupado completo

El cuerpo de la función principal

No hay mucho que ver aquí; El cuerpo principal de la función declara las entradas (que deben coincidir con la función SQL correspondiente), establece una conexión SQL y abre el 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 lógica central

He separado la lógica principal para que sea más fácil enfocarse en:

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

Ayudantes

La siguiente lógica podría escribirse en línea, pero es un poco más fácil de leer cuando se dividen en sus propios 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;
}

Vinculando todo en SQL

Todo hasta este punto ha estado en C #, así que veamos el SQL real involucrado. (Alternativamente, puede usar este script de implementación para crear el ensamblaje directamente desde los bits de mi ensamblaje en lugar de compilarse usted mismo).

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

Advertencias

El enfoque CLR proporciona mucha más flexibilidad para optimizar el algoritmo, y probablemente un experto en C # podría ajustarlo aún más. Sin embargo, también hay desventajas en la estrategia CLR. Un par de cosas a tener en cuenta:

  • Este enfoque CLR mantiene una copia del conjunto de datos en la memoria. Es posible usar un enfoque de transmisión, pero encontré dificultades iniciales y descubrí que hay un problema pendiente de Connect quejándose de que los cambios en SQL 2008+ hacen que sea más difícil usar este tipo de enfoque. Todavía es posible (como lo demuestra Paul), pero requiere un mayor nivel de permisos configurando la base de datos como TRUSTWORTHYy otorgandoEXTERNAL_ACCESS al ensamblado CLR. Por lo tanto, hay algunas complicaciones y posibles implicaciones de seguridad, pero la recompensa es un enfoque de transmisión que puede escalar mejor a conjuntos de datos mucho más grandes que los de AdventureWorks.
  • CLR puede ser menos accesible para algunos DBA, lo que hace que esta función sea más un recuadro negro que no es tan transparente, no se modifica tan fácilmente, no se despliega tan fácilmente y quizás no se depura tan fácilmente. Esta es una desventaja bastante grande en comparación con un enfoque T-SQL.


Bonificación: T-SQL # 2: el enfoque práctico que realmente usaría

Después de tratar de pensar en el problema de manera creativa por un tiempo, pensé que también publicaría la forma bastante simple y práctica en la que probablemente elegiría abordar este problema si surgiera en mi trabajo diario. Hace uso de la funcionalidad de la ventana SQL 2012+, pero no en el tipo de manera innovadora que la pregunta esperaba:

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

En realidad, esto produce un plan de consulta general bastante simple, incluso cuando se miran los dos planes de consulta relevantes juntos:

ingrese la descripción de la imagen aquí ingrese la descripción de la imagen aquí

Algunas razones por las que me gusta este enfoque:

  • Produce el conjunto de resultados completo solicitado en la declaración del problema (a diferencia de la mayoría de las otras soluciones T-SQL, que devuelven una versión agrupada de los resultados).
  • Es fácil de explicar, comprender y depurar; No volveré un año después y me pregunto cómo diablos puedo hacer un pequeño cambio sin arruinar la corrección o el rendimiento.
  • Se ejecuta aproximadamente 900msen el conjunto de datos proporcionado, en lugar de en la 2700msbúsqueda de bucle original
  • Si los datos fueran mucho más densos (más transacciones por día), la complejidad computacional no crece cuadráticamente con el número de transacciones en la ventana deslizante (como lo hace para la consulta original); Creo que esto resuelve parte de la preocupación de Paul acerca de querer evitar múltiples escaneos
  • Resulta esencialmente sin E / S tempdb en las actualizaciones recientes de SQL 2012+ debido a la nueva funcionalidad de escritura diferida de tempdb
  • Para conjuntos de datos muy grandes, es trivial dividir el trabajo en lotes separados para cada producto si la presión de la memoria fuera una preocupación.

Un par de advertencias potenciales:

  • Si bien técnicamente escanea Production.TransactionHistory solo una vez, no es realmente un enfoque de "escaneo único" porque la tabla #temp de tamaño similar y necesitará realizar E / S de registro adicionales en esa tabla también. Sin embargo, no veo esto muy diferente de una mesa de trabajo sobre la que tenemos más control manual ya que hemos definido su estructura precisa
  • Dependiendo de su entorno, el uso de tempdb podría verse como positivo (por ejemplo, en un conjunto separado de unidades SSD) o negativo (alta concurrencia en el servidor, mucha contención de tempdb ya)
Geoff Patterson
fuente
25

Esta es una respuesta larga, así que decidí agregar un resumen aquí.

  • Al principio presento una solución que produce exactamente el mismo resultado en el mismo orden que en la pregunta. Escanea la tabla principal 3 veces: para obtener una lista deProductIDs con el rango de fechas para cada Producto, para resumir los costos de cada día (porque hay varias transacciones con las mismas fechas), para unir el resultado con las filas originales.
  • A continuación, comparo dos enfoques que simplifican la tarea y evitan una última exploración de la tabla principal. Su resultado es un resumen diario, es decir, si varias transacciones en un Producto tienen la misma fecha, se agrupan en una sola fila. Mi enfoque del paso anterior escanea la mesa dos veces. El enfoque de Geoff Patterson escanea la tabla una vez, porque usa conocimiento externo sobre el rango de fechas y la lista de Productos.
  • Finalmente, presento una solución de un solo paso que nuevamente devuelve un resumen diario, pero no requiere conocimiento externo sobre el rango de fechas o la lista de ProductIDs.

Voy a utilizar AdventureWorks2014 base de datos y SQL Server Express 2014.

Cambios en la base de datos original:

  • Se cambió el tipo de [Production].[TransactionHistory].[TransactionDate]de datetimea date. El componente de tiempo era cero de todos modos.
  • Tabla de calendario agregada [dbo].[Calendar]
  • Índice agregado 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);

El artículo sobre la OVERcláusula de MSDN tiene un enlace a una excelente publicación de blog sobre las funciones de ventana de Itzik Ben-Gan. En esa publicación, explica cómo OVERfunciona, la diferencia entre las opciones ROWSy RANGE, y menciona este mismo problema de calcular una suma continua en un rango de fechas. Menciona que la versión actual de SQL Server no se implementa RANGEpor completo y no implementa los tipos de datos de intervalo temporal. Su explicación de la diferencia entre ROWSy RANGEme dio una idea.

Fechas sin espacios y duplicados

Si la TransactionHistorytabla contenía fechas sin espacios y sin duplicados, la siguiente consulta produciría resultados correctos:

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 hecho, una ventana de 45 filas cubriría exactamente 45 días.

Fechas con espacios sin duplicados

Desafortunadamente, nuestros datos tienen lagunas en las fechas. Para resolver este problema, podemos usar una Calendartabla para generar un conjunto de fechas sin espacios, luego los LEFT JOINdatos originales para este conjunto y usar la misma consulta con ROWS BETWEEN 45 PRECEDING AND CURRENT ROW. Esto produciría resultados correctos solo si las fechas no se repiten (dentro del mismo ProductID).

Fechas con huecos con duplicados

Desafortunadamente, nuestros datos tienen dos brechas en las fechas y las fechas pueden repetirse dentro de la misma ProductID. Para resolver este problema, podemos generar GROUPdatos originales ProductID, TransactionDategenerando un conjunto de fechas sin duplicados. Luego use la Calendartabla para generar un conjunto de fechas sin espacios. Entonces podemos usar la consulta con ROWS BETWEEN 45 PRECEDING AND CURRENT ROWpara calcular el balanceo SUM. Esto produciría resultados correctos. Ver comentarios en la consulta a continuación.

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
;

Confirmé que esta consulta produce los mismos resultados que el enfoque de la pregunta que usa la subconsulta.

Planes de ejecucion

estadísticas

La primera consulta usa subconsulta, la segunda, este enfoque. Puede ver que la duración y el número de lecturas es mucho menor en este enfoque. La mayoría del costo estimado en este enfoque es el final ORDER BY, ver más abajo.

subconsulta

El enfoque de subconsulta tiene un plan simple con bucles anidados y O(n*n)complejidad.

terminado

Planifique los escaneos de este enfoque TransactionHistoryvarias veces, pero no hay bucles. Como puede ver, más del 70% del costo estimado es Sortel final ORDER BY.

io

Resultado superior - subquery, inferior - OVER.


Evitar escaneos adicionales

La última exploración de índice, combinación de unión y clasificación en el plan anterior se debe a la final INNER JOINcon la tabla original para hacer que el resultado final sea exactamente el mismo que un enfoque lento con subconsulta. El número de filas devueltas es el mismo que en la TransactionHistorytabla. Hay filas en TransactionHistorycuando ocurrieron varias transacciones en el mismo día para el mismo producto. Si está bien mostrar solo un resumen diario en el resultado, entonces este final JOINpuede eliminarse y la consulta se vuelve un poco más simple y más rápida. El último Escaneo de índice, Combinar unión y Ordenar del plan anterior se reemplazan con Filtro, que elimina las filas agregadas 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
;

dos escaneos

Aún así, TransactionHistoryse escanea dos veces. Se necesita un escaneo adicional para obtener el rango de fechas para cada producto. Me interesó ver cómo se compara con otro enfoque, donde usamos conocimiento externo sobre el rango global de fechas TransactionHistory, además de una tabla adicional Productque tiene todo ProductIDspara evitar ese escaneo adicional. Eliminé el cálculo del número de transacciones por día de esta consulta para que la comparación sea válida. Se puede agregar en ambas consultas, pero me gustaría mantenerlo simple para comparar. También tuve que usar otras fechas, porque uso la versión 2014 de la base de datos.

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

un escaneo

Ambas consultas devuelven el mismo resultado en el mismo orden.

Comparación

Aquí están las estadísticas de tiempo e IO.

stats2

io2

La variante de dos escaneos es un poco más rápida y tiene menos lecturas, porque la variante de un escaneo tiene que usar mucho Worktable. Además, la variante de un escaneo genera más filas de las necesarias, como puede ver en los planos. Genera fechas para cada uno ProductIDque está en la Producttabla, incluso si a ProductIDno tiene ninguna transacción. Hay 504 filas en la Producttabla, pero solo 441 productos tienen transacciones TransactionHistory. Además, genera el mismo rango de fechas para cada producto, que es más de lo necesario. Si TransactionHistorytuviera un historial general más largo, con cada producto individual teniendo un historial relativamente corto, el número de filas adicionales innecesarias sería aún mayor.

Por otro lado, es posible optimizar un poco más la variante de dos escaneos creando otro índice más estrecho solo (ProductID, TransactionDate). Este índice se usaría para calcular las fechas de inicio / finalización de cada producto ( CTE_Products) y tendría menos páginas que el índice de cobertura y, como resultado, provocaría menos lecturas.

Por lo tanto, podemos elegir, ya sea tener un escaneo simple explícito adicional o tener una mesa de trabajo implícita.

Por cierto, si está bien tener un resultado con solo resúmenes diarios, entonces es mejor crear un índice que no incluya ReferenceOrderID. Usaría menos páginas => menos IO.

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

Solución de un solo paso utilizando CROSS APPLY

Se convierte en una respuesta realmente larga, pero aquí hay una variante más que devuelve solo un resumen diario nuevamente, pero solo realiza un escaneo de los datos y no requiere conocimiento externo sobre el rango de fechas o la lista de ProductID. No hace clasificaciones intermedias también. El rendimiento general es similar a las variantes anteriores, aunque parece ser un poco peor.

La idea principal es usar una tabla de números para generar filas que llenen los espacios en las fechas. Para cada fecha existente, use LEADpara calcular el tamaño de la brecha en días y luego use CROSS APPLYpara agregar el número requerido de filas en el conjunto de resultados. Al principio lo probé con una tabla permanente de números. El plan mostró un gran número de lecturas en esta tabla, aunque la duración real fue más o menos la misma que cuando generaba números sobre la marcha 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 plan es "más largo", porque la consulta usa dos funciones de ventana ( LEADy SUM).

aplicación cruzada

estadísticas de ca

ca io

Vladimir Baranov
fuente
23

Una solución alternativa SQLCLR que se ejecuta más rápido y requiere menos memoria:

Script de implementación

Eso requiere el EXTERNAL_ACCESSconjunto de permisos porque usa una conexión de bucle invertido con el servidor de destino y la base de datos en lugar de la conexión de contexto (lenta). Así es como llamar a la función:

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 exactamente los mismos resultados, en el mismo orden, que la pregunta.

Plan de ejecución:

Plan de ejecución de SQLCLR TVF

Plan de ejecución de consultas de origen SQLCLR

Estadísticas de rendimiento de Plan Explorer

Lecturas lógicas del generador de perfiles: 481

La principal ventaja de esta implementación es que es más rápido que usar la conexión de contexto y usa menos memoria. Solo guarda dos cosas en la memoria a la vez:

  1. Cualquier fila duplicada (mismo producto y fecha de transacción). Esto es necesario porque hasta que el producto o la fecha cambien, no sabemos cuál será la suma final. En los datos de muestra, hay una combinación de producto y fecha que tiene 64 filas.
  2. Un rango variable de 45 días de costos y fechas de transacción solamente, para el producto actual. Esto es necesario para ajustar la suma de ejecución simple para las filas que salen de la ventana deslizante de 45 días.

Este almacenamiento en caché mínimo debería garantizar que este método escala bien; ciertamente mejor que tratar de mantener todo el conjunto de entrada en la memoria CLR.

Código fuente

Paul White
fuente
17

Si está en la edición Enterprise, Developer o Evaluation de 64 bits de SQL Server 2014, puede usar OLTP en memoria . La solución no será un solo escaneo y casi no usará ninguna función de ventana, pero podría agregar algo de valor a esta pregunta y el algoritmo utilizado podría usarse como inspiración para otras soluciones.

Primero debe habilitar OLTP en memoria en la base de datos 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;

El parámetro para el procedimiento es una variable de tabla en memoria y debe definirse como 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 no es único en esta tabla, es único para cada combinación de ProductIDy TransactionDate.

Hay algunos comentarios en el procedimiento que le dicen lo que hace, pero en general está calculando el total acumulado en un bucle y para cada iteración realiza una búsqueda del total acumulado como era hace 45 días (o más).

El total acumulado actual menos el total acumulado como era hace 45 días es la suma acumulada de 45 días que estamos buscando.

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 el procedimiento de esta manera.

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

Probar esto en mi computadora Client Statistics informa un tiempo de ejecución total de aproximadamente 750 milisegundos. Para las comparaciones, la versión de la subconsulta tarda 3,5 segundos.

Divagaciones adicionales:

Este algoritmo también podría ser utilizado por T-SQL normal. Calcule el total acumulado, utilizando rangeno filas, y almacene el resultado en una tabla temporal. Luego puede consultar esa tabla con una unión automática al total acumulado como era hace 45 días y calcular la suma acumulada. Sin embargo, la implementación de rangecomparado con rowses bastante lenta debido al hecho de que es necesario tratar los duplicados del orden por cláusula de manera diferente, por lo que no obtuve todo ese buen rendimiento con este enfoque. Una solución alternativa a esto podría ser usar otra función de ventana como last_value()sobre un total acumulado calculado utilizando rowspara simular un rangetotal acumulado. Otra forma es usar la versión. Dejé de optimizar esas cosas, pero si estás interesado en el código que tengo hasta ahora, házmelo saber.max() over() . Ambos tuvieron algunos problemas. Encontrar el índice apropiado para usar para evitar géneros y evitar carretes con elmax() over()

Mikael Eriksson
fuente
13

Bueno, eso fue divertido :) Mi solución es un poco más lenta que @ GeoffPatterson, pero parte de eso es el hecho de que estoy volviendo a la tabla original para eliminar una de las suposiciones de Geoff (es decir, una fila por par de producto / fecha) . Asumí que se trataba de una versión simplificada de una consulta final y que podría requerir información adicional de la tabla original.

Nota: estoy tomando prestada la tabla de calendario de Geoff y, de hecho, terminé con una solución muy similar:

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

Aquí está la consulta en sí:

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;

Básicamente decidí que la forma más fácil de lidiar con eso era usar el opción para la cláusula ROWS. Pero eso requiere que sólo tengo una fila por ProductID, TransactionDatecombinación y no sólo eso, sino que tenía que tener una fila por ProductIDy possible date. Lo hice combinando las tablas Producto, calendario y TransactionHistory en un CTE. Luego tuve que crear otro CTE para generar la información continua. Tenía que hacer esto porque si volvía a unirme directamente a la tabla original, obtenía la eliminación de filas que arrojaba mis resultados. Después de eso, fue una simple cuestión de unir mi segundo CTE a la mesa original. Agregué la TBEcolumna (para eliminar) para eliminar las filas en blanco creadas en los CTE. También utilicé un CROSS APPLYen el CTE inicial para generar límites para mi tabla de calendario.

Luego agregué el índice recomendado:

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

Y obtuve el plan de ejecución final:

ingrese la descripción de la imagen aquí ingrese la descripción de la imagen aquí ingrese la descripción de la imagen aquí

EDITAR: Al final agregué un índice en la tabla del calendario que aceleró el rendimiento por un margen razonable.

CREATE INDEX ix_calendar ON calendar(d)
Kenneth Fisher
fuente
2
La RunningTotal.TBE IS NOT NULLcondición (y, en consecuencia, la TBEcolumna) es innecesaria. No lo va a obtener filas redundantes si lo suelta, porque su condición de unión interna incluye la columna de fecha, por lo tanto, el conjunto de resultados no puede tener fechas que no estaban originalmente en la fuente.
Andriy M
2
Sí. Concuerdo completamente. Y, sin embargo, todavía me hizo ganar unos 0,2 segundos. Creo que le permitió al optimizador conocer información adicional.
Kenneth Fisher
4

Tengo algunas soluciones alternativas que no usan índices o tablas de referencia. Quizás podrían ser útiles en situaciones en las que no tiene acceso a ninguna tabla adicional y no puede crear índices. Parece posible obtener resultados correctos cuando se agrupa TransactionDatecon un solo paso de los datos y una sola función de ventana. Sin embargo, no pude encontrar una manera de hacerlo con una sola función de ventana cuando no se puede agrupar TransactionDate.

Para proporcionar un marco de referencia, en mi máquina, la solución original publicada en la pregunta tiene un tiempo de CPU de 2808 ms sin el índice de cobertura y 1950 ms con el índice de cobertura. Estoy probando con la base de datos AdventureWorks2014 y SQL Server Express 2014.

Comencemos con una solución para cuándo podemos agrupar TransactionDate. Una suma acumulada en los últimos X días también se puede expresar de la siguiente manera:

Suma de ejecución para una fila = suma de ejecución de todas las filas anteriores: suma de ejecución de todas las filas anteriores para las que la fecha está fuera de la ventana de fecha.

En SQL, una forma de expresar esto es haciendo dos copias de sus datos y para la segunda copia, multiplicando el costo por -1 y agregando X + 1 días a la columna de fecha. Calcular una suma acumulada sobre todos los datos implementará la fórmula anterior. Mostraré esto para algunos datos de ejemplo. A continuación se muestra una fecha de muestra para un single ProductID. Represento las fechas como números para facilitar los cálculos. Datos de inicio:

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

Agregue una segunda copia de los datos. La segunda copia tiene 46 días agregados a la fecha y el costo 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 la suma acumulada ordenada por Dateascendente y 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 las filas copiadas para obtener el resultado deseado:

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

El siguiente SQL es una forma de implementar el algoritmo anterior:

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

En mi máquina, esto tomó 702 ms de tiempo de CPU con el índice de cobertura y 734 ms de tiempo de CPU sin el índice. El plan de consulta se puede encontrar aquí: https://www.brentozar.com/pastetheplan/?id=SJdCsGVSl

Una desventaja de esta solución es que parece haber un tipo inevitable al ordenar por la nueva TransactionDatecolumna. No creo que este tipo pueda resolverse agregando índices porque necesitamos combinar dos copias de los datos antes de hacer el pedido. Pude deshacerme de una especie al final de la consulta agregando una columna diferente a ORDER BY. Si ordenara por FilterFlag, descubrí que SQL Server optimizaría esa columna de la clasificación y realizaría una clasificación explícita.

Las soluciones para cuando necesitamos devolver un conjunto de resultados con TransactionDatevalores duplicados para el mismo ProductIdfueron mucho más complicadas. Resumiría el problema como la necesidad simultánea de particionar y ordenar por la misma columna. La sintaxis que Paul proporcionó resuelve ese problema, por lo que no es sorprendente que sea tan difícil de expresar con las funciones de ventana actuales disponibles en SQL Server (si no fuera difícil de expresar, no habría necesidad de expandir la sintaxis).

Si uso la consulta anterior sin agrupar, obtengo diferentes valores para la suma acumulada cuando hay varias filas con el mismo ProductIdy TransactionDate. Una forma de resolver esto es hacer el mismo cálculo de suma de ejecución que antes pero también marcar la última fila de la partición. Esto se puede hacer con LEAD(suponiendo ProductIDque nunca es NULL) sin una ordenación adicional. Para el valor de suma final en ejecución, lo uso MAXcomo una función de ventana para aplicar el valor en la última fila de la partición a todas las filas de la partición.

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

En mi máquina, esto tomó 2464 ms de tiempo de CPU sin el índice de cobertura. Como antes, parece haber un tipo inevitable. El plan de consulta se puede encontrar aquí: https://www.brentozar.com/pastetheplan/?id=HyWxhGVBl

Creo que hay margen de mejora en la consulta anterior. Ciertamente, hay otras formas de usar las funciones de Windows para obtener el resultado deseado.

Joe Obbish
fuente