这是一个很长的答案,所以我决定在此处添加摘要。
- 首先,我提出一个解决方案,该解决方案以与问题中相同的顺序产生完全相同的结果。它扫描主表3次:获取
ProductIDs
每个产品的日期范围列表,汇总每天的费用(因为有多个具有相同日期的交易),将结果与原始行合并。
- 接下来,我将比较两种简化任务并避免最后扫描主表的方法。其结果是每日摘要,即,如果某个产品上的多个交易具有相同的日期,则将它们汇总为一行。我上一步中的方法对表进行了两次扫描。Geoff Patterson的方法对表格进行了一次扫描,因为他使用有关日期范围和产品列表的外部知识。
- 最后,我提出了一种单次通过解决方案,该解决方案再次返回每日摘要,但是它不需要有关日期范围或的列表的外部知识
ProductIDs
。
我将使用AdventureWorks2014数据库和SQL Server Express 2014。
对原始数据库的更改:
- 的类型
[Production].[TransactionHistory].[TransactionDate]
从更改datetime
为date
。无论如何,时间分量为零。
- 添加了日历表
[dbo].[Calendar]
- 新增索引至
[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);
MSDN上有关OVER
条款的文章具有指向Itzik Ben-Gan的有关窗口函数的优秀博客文章的链接。在那篇文章中,他解释了OVER
工作原理,ROWS
和RANGE
选项之间的区别,并提到了在日期范围内计算滚动总和的问题。他提到当前版本的SQL Server不能完全实现RANGE
,也不能实现时间间隔数据类型。他的区别的解释ROWS
,并RANGE
给了我一个想法。
没有空白和重复的日期
如果TransactionHistory
表中包含没有间隔且没有重复的日期,则以下查询将产生正确的结果:
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;
确实,一个45行的窗口将覆盖45天。
有间隔且无重复的日期
不幸的是,我们的数据存在日期差异。为了解决这个问题,我们可以使用Calendar
表格来生成一组没有间隔的日期,然后将LEFT JOIN
原始数据添加到该组,并使用与相同的查询ROWS BETWEEN 45 PRECEDING AND CURRENT ROW
。仅当日期不重复时(在内ProductID
),这才会产生正确的结果。
有重复的空白的日期
不幸的是,我们的数据在日期上有两个缺口,并且日期可以在同一时间内重复ProductID
。为了解决这个问题,我们可以通过GROUP
原始数据ProductID, TransactionDate
生成一组没有重复的日期。然后使用Calendar
表格生成一组没有间隔的日期。然后,我们可以使用查询with ROWS BETWEEN 45 PRECEDING AND CURRENT ROW
来计算滚动SUM
。这将产生正确的结果。请参阅下面查询中的注释。
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
;
我确认此查询所产生的结果与使用子查询的方法所产生的结果相同。
执行计划
第一个查询使用子查询,第二个使用这种方法。您可以看到这种方法的持续时间和读取次数要少得多。最终ORDER BY
,这种方法的估计成本是最高的,请参见下文。
子查询方法具有嵌套循环和O(n*n)
复杂性的简单计划。
为此方法计划扫描TransactionHistory
几次,但是没有循环。如您所见Sort
,最终费用为估算费用的70%以上ORDER BY
。
最佳结果- subquery
底部- OVER
。
避免额外的扫描
上面计划中的最后一个索引扫描,合并联接和排序是由INNER JOIN
原始表的最终结果引起的,以使最终结果与使用子查询的慢速方法完全相同。返回的行数与TransactionHistory
表中的相同。TransactionHistory
同一产品的同一天发生多个交易时,存在行。如果可以只在结果中显示每日摘要,则JOIN
可以删除该最终结果,查询变得更简单,更快。上一个计划中的最后一个“索引扫描”,“合并联接”和“排序”被“过滤器”替换,该过滤器删除了由添加的行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
;
仍然TransactionHistory
被扫描两次。需要进行一次额外的扫描才能获取每种产品的日期范围。我很想知道它与另一种方法的比较,在另一种方法中,我们使用关于中的全球日期范围的外部知识TransactionHistory
,以及Product
具有ProductIDs
避免这种额外扫描的所有附加表。我从此查询中删除了每天交易数的计算,以使比较有效。可以在两个查询中都添加它,但为了简化比较,我想使其保持简单。我还必须使用其他日期,因为我使用的是2014年版本的数据库。
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);
这两个查询以相同的顺序返回相同的结果。
比较方式
这是时间和IO统计信息。
两次扫描变体要快一些,并且读取次数更少,因为一次扫描变体必须大量使用Worktable。此外,单扫描变体生成的行超出了计划中所需要的数量。即使表a 没有任何交易,它也会ProductID
为Product
表中的每个表生成日期ProductID
。Product
表中有504行,但只有441个产品的交易记录TransactionHistory
。而且,它为每种产品生成相同的日期范围,这超出了所需范围。如果TransactionHistory
总体历史较长,而每个产品的历史较短,那么多余的行数就会更高。
另一方面,可以通过在just上创建另一个更窄的索引来进一步优化两次扫描变体(ProductID, TransactionDate)
。该索引将用于计算每个产品(CTE_Products
)的开始/结束日期,并且其页面数少于覆盖索引的页面数,因此导致读取次数减少。
因此,我们可以选择具有额外的显式简单扫描或具有隐式的工作表。
顺便说一句,如果只包含每日摘要的结果是可以的,那么最好创建一个不包含的索引ReferenceOrderID
。它将使用更少的页面=>更少的IO。
CREATE NONCLUSTERED INDEX [i2] ON [Production].[TransactionHistory]
(
[ProductID] ASC,
[TransactionDate] ASC
)
INCLUDE ([ActualCost])
使用CROSS APPLY的单程解决方案
这真是一个很长的答案,但这是又一个变种,它仅再次返回每日摘要,但只扫描一次数据,不需要外部了解日期范围或ProductID列表。它也不做中间排序。总体性能与以前的变体相似,尽管看起来有些差。
主要思想是使用数字表来生成可填补日期空白的行。对于每个现有日期,使用LEAD
来计算以天为单位的间隔的大小,然后使用CROSS APPLY
来将所需的行数添加到结果集中。最初,我用一个永久的数字表进行了尝试。该计划在此表中显示了大量读取,尽管实际持续时间与我使用即时生成数字时几乎相同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
;
该计划是“更长的”计划,因为查询使用两个窗口函数(LEAD
和SUM
)。
RunningTotal.TBE IS NOT NULL
条件(以及因此的TBE
列)是不必要的。如果将其删除,则不会获得多余的行,因为内部联接条件包括date列-因此结果集不能具有源中最初不存在的日期。