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 de
ProductIDs
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 datetime
a 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 OVER
clá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 OVER
funciona, la diferencia entre las opciones ROWS
y 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 RANGE
por completo y no implementa los tipos de datos de intervalo temporal. Su explicación de la diferencia entre ROWS
y RANGE
me dio una idea.
Fechas sin espacios y duplicados
Si la TransactionHistory
tabla 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 Calendar
tabla para generar un conjunto de fechas sin espacios, luego los LEFT JOIN
datos 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 GROUP
datos originales ProductID, TransactionDate
generando un conjunto de fechas sin duplicados. Luego use la Calendar
tabla para generar un conjunto de fechas sin espacios. Entonces podemos usar la consulta con ROWS BETWEEN 45 PRECEDING AND CURRENT ROW
para 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
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.
El enfoque de subconsulta tiene un plan simple con bucles anidados y O(n*n)
complejidad.
Planifique los escaneos de este enfoque TransactionHistory
varias veces, pero no hay bucles. Como puede ver, más del 70% del costo estimado es Sort
el final ORDER BY
.
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 JOIN
con 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 TransactionHistory
tabla. Hay filas en TransactionHistory
cuando 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 JOIN
puede 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
;
Aún así, TransactionHistory
se 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 Product
que tiene todo ProductIDs
para 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);
Ambas consultas devuelven el mismo resultado en el mismo orden.
Comparación
Aquí están las estadísticas de tiempo e IO.
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 ProductID
que está en la Product
tabla, incluso si a ProductID
no tiene ninguna transacción. Hay 504 filas en la Product
tabla, 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 TransactionHistory
tuviera 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 LEAD
para calcular el tamaño de la brecha en días y luego use CROSS APPLY
para 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 ( LEAD
y SUM
).
RunningTotal.TBE IS NOT NULL
condición (y, en consecuencia, laTBE
columna) 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.