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;
遗憾的是,
RANGE
窗框范围当前不允许间隔在SQL Server中。我知道我可以使用子查询和常规(非窗口)聚合来编写解决方案:
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;
给出以下索引:
CREATE UNIQUE INDEX i
ON Production.TransactionHistory
(ProductID, TransactionDate, ReferenceOrderID)
INCLUDE
(ActualCost);
执行计划是:
好像应该可以仅使用SQL Server 2012、2014或2016(到目前为止)支持的窗口聚合和分析功能来表达此查询。为了清楚起见,我正在寻找一种解决方案,该解决方案应执行在T-SQL中一次传递数据。
这可能意味着
OVER
子句将完成工作,并且执行计划将具有Window Spools和Window Aggregates。所有使用OVER
子句的语言元素都是公平的游戏。 SQLCLR解决方案是可以接受的,只要可以保证产生正确的结果即可。对于T-SQL解决方案,执行计划中的哈希,排序和窗口假脱机/聚合越少越好。可以随意添加索引,但是不允许使用单独的结构(例如,没有预先计算的表与触发器保持同步)。允许使用引用表(数字,日期等表)。理想情况下,解决方案将以与上述子查询版本相同的顺序产生完全相同的结果,但是任何可以说正确的方法也是可以接受的。性能始终是一个考虑因素,因此解决方案至少应该合理有效。
专用聊天室:我创建了一个公共聊天室,用于与该问题及其答案相关的讨论。具有至少20个信誉点的任何用户都可以直接参与。如果您的代表少于20位并且想参加,请在下面的评论中对我进行ping操作。
讨论“使用窗口函数的日期范围滚动总和”
#1 楼
很好的问题,保罗!我使用了两种不同的方法,一种在T-SQL中使用,一种在CLR中。T-SQL快速摘要
T-SQL方法可以概括为以下步骤:
采用产品/日期的跨产品
合并观察到的销售数据
将该数据汇总到产品/日期级别
根据此汇总数据计算过去45天的滚动总和(其中包含任何“缺失”天)
将这些结果过滤为仅具有一个或多个销售额的产品/日期配对
使用
SET STATISTICS IO ON
,此方法报告Table 'TransactionHistory'. Scan count 1, logical reads 484
,从而确认了在桌子上“单次通过”。作为参考,原始的循环搜索查询报告为Table 'TransactionHistory'. Scan count 113444, logical reads 438366
。如
SET STATISTICS TIME ON
所报告的,CPU时间为514ms
。与原始查询的2231ms
相比,它是有利的。CLR快速摘要
CLR摘要可以概括为以下步骤:
将数据读取到内存中,按产品和date
在处理每笔交易时,会累加总成本。只要某笔交易是与前一笔交易不同的产品,就将运行总计重置为0。
维护一个指向与当前交易相同(产品,日期)的第一笔交易的指针。每当遇到与该(产品,日期)有关的最后一笔交易时,请计算该交易的滚动总和,并将其应用于具有相同(产品,日期)相同的所有交易
将所有结果返回给用户!
/>
使用
SET STATISTICS IO ON
,此方法报告没有发生逻辑I / O!哇,完美的解决方案! (实际上,似乎SET STATISTICS IO
不会报告CLR中发生的I / O。但是从代码中,很容易看到对表进行了一次扫描,并按Paul建议的索引顺序检索了数据。 />如SET STATISTICS TIME ON
所报告,CPU时间现在为187ms
。因此,与T-SQL方法相比,这是一个很大的改进。不幸的是,两种方法的总耗时非常相似,每次大约半秒钟。但是,基于CLR的方法的确必须向控制台输出113K的行(相比之下,按产品/日期分组的T-SQL方法只有52K的行),因此这就是我专注于CPU时间的原因。这种方法的另一个主要优点是,它产生的结果与原始的循环/查找方法完全相同,即使在同一天多次售出产品的情况下,每次交易都包含一行。 (在AdventureWorks上,我专门比较了逐行结果,并确认它们与Paul的原始查询是一致的。)
这种方法的缺点(至少以当前的形式)是它读取内存中的所有数据。但是,已设计的算法仅在任何给定时间严格需要内存中的当前窗口帧,并且可以对其进行更新以处理超出内存的数据集。 Paul通过生成此算法的实现(仅将滑动窗口存储在内存中)来说明这一点。这是以给CLR程序集更高的权限为代价的,但是在将此解决方案扩展到任意大的数据集时,绝对值得。
T-SQL-一种扫描,按日期分组
初始设置
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
查询
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)
执行计划
从执行计划中,我们看到Paul提出的原始索引足以让我们执行一个有序命令扫描
Production.TransactionHistory
,使用合并联接将交易历史记录与每种可能的产品/日期组合结合起来。假设
这种方法有一些重要的假设。我想应该由Paul决定是否可以接受它们:)
我正在使用
Production.Product
表。该表可在AdventureWorks2012
上免费使用,并且该关系是通过Production.TransactionHistory
上的外键强制执行的,因此我将其解释为公平的游戏。这种方法依赖于以下事实:交易在
AdventureWorks2012
上没有时间成分;如果他们这样做了,那么在没有先跳过交易历史记录的情况下,将不可能再生成完整的产品/日期组合。我正在生成一个行集,每个产品/日期对仅包含一行。我认为这“可以说是正确的”,并且在许多情况下可以返回更理想的结果。对于每种产品/日期,我添加了一个
NumOrders
列以指示发生了多少销售。请参阅以下屏幕快照,以比较在同一日期多次售出产品(例如,319
/ 2007-09-05 00:00:00.000
)的情况下原始查询与建议查询的比较结果。CLR-一次扫描,完整的未分组结果集
主要功能主体
这里看不到很多东西;函数的主体声明输入(必须与相应的SQL函数匹配),建立SQL连接,然后打开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;
}
}
核心逻辑
我已经分离出主要逻辑,因此更易于关注:
// Given a SqlReader with transaction history data, computes / returns the rolling sums
private static List<TrxnRollingSum> ComputeRollingSums(SqlDataReader reader,
int rollingPeriodDays) {
var startIndexOfRollingPeriod = 0;
var rollingSumIndex = 0;
var trxns = new List<TrxnRollingSum>();
// Prior to the loop, initialize "next" to be the first transaction
var nextTrxn = GetNextTrxn(reader, null);
while (nextTrxn != null)
{
var currTrxn = nextTrxn;
nextTrxn = GetNextTrxn(reader, currTrxn);
trxns.Add(currTrxn);
// If the next transaction is not the same product/date as the current
// transaction, we can finalize the rolling sum for the current transaction
// and all previous transactions for the same product/date
var finalizeRollingSum = nextTrxn == null || (nextTrxn != null &&
(currTrxn.ProductId != nextTrxn.ProductId ||
currTrxn.TransactionDate != nextTrxn.TransactionDate));
if (finalizeRollingSum)
{
// Advance the pointer to the first transaction (for the same product)
// that occurs within the rolling period
while (startIndexOfRollingPeriod < trxns.Count
&& trxns[startIndexOfRollingPeriod].TransactionDate <
currTrxn.TransactionDate.AddDays(-1 * rollingPeriodDays))
{
startIndexOfRollingPeriod++;
}
// Compute the rolling sum as the cumulative sum (for this product),
// minus the cumulative sum for prior to the beginning of the rolling window
var sumPriorToWindow = trxns[startIndexOfRollingPeriod].PrevSum;
var rollingSum = currTrxn.ActualCost + currTrxn.PrevSum - sumPriorToWindow;
// Fill in the rolling sum for all transactions sharing this product/date
while (rollingSumIndex < trxns.Count)
{
trxns[rollingSumIndex++].RollingSum = rollingSum;
}
}
// If this is the last transaction for this product, reset the rolling period
if (nextTrxn != null && currTrxn.ProductId != nextTrxn.ProductId)
{
startIndexOfRollingPeriod = trxns.Count;
}
}
return trxns;
}
Helpers
以下逻辑可以内联编写,但是将它们拆分成自己的方法时,它的读取要容易一些。
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;
}
在SQL中将它们结合在一起
到目前为止,所有内容都在C#中,所以让我们看一下所涉及的实际SQL。 (或者,您可以使用此部署脚本直接从我的程序集的各个部分创建程序集,而不是自己编译。)
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
注意事项
CLR方法为优化算法提供了更大的灵活性,它可能由C#专家进一步调整。但是,CLR策略也有缺点。请记住以下几点:
这种CLR方法会将数据集的副本保存在内存中。可以使用流方法,但是我遇到了最初的困难,发现存在一个突出的Connect问题,抱怨SQL 2008+中的更改使使用这种方法更加困难。仍然可能(如Paul所示),但是需要更高的权限,方法是将数据库设置为
TRUSTWORTHY
并将EXTERNAL_ACCESS
授予CLR程序集。因此,存在一些麻烦和潜在的安全隐患,但是这种收益是一种流方法,比AdventureWorks上的方法可以更好地扩展到更大的数据集。某些DBA可能无法访问CLR,因此该功能更多黑盒,它不那么透明,不那么容易修改,不那么容易部署,甚至不那么容易调试。与T-SQL方法相比,这是一个很大的缺点。
奖金:T-SQL#2-在尝试思考问题后我将实际使用的实用方法
有创意的一段时间,我想我还会发布一种相当简单,实用的方法,如果我的日常工作中遇到这个问题,我可能会选择解决这个问题。它确实利用了SQL 2012+窗口功能,但没有以问题所希望的突破性方式使用:
-- 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
这实际上产生了一个相当简单的总体查询计划,即使同时考虑了两者以下是两个相关查询计划中的一个:
我喜欢这种方法的一些原因:
它产生问题语句中要求的完整结果集(与大多数其他T-SQL解决方案相反,后者返回结果的分组版本。
很容易解释,理解和调试。我一年后不会再来了,不知道如何在不破坏正确性或性能的情况下做出一点小改动
它在提供的数据集上运行在
900ms
左右,而不是原始循环的2700ms
- seek 如果数据密集得多(每天有更多事务),则计算复杂度不会随着滑动窗口中事务的数量平方增加(就像原始查询那样);我认为这解决了Paul担心要避免多次扫描的部分问题
由于新的tempdb惰性写入功能,在SQL 2012+的最新更新中基本上没有tempdb I / O导致
非常大的数据集,如果要考虑存储压力,则将每个产品的工作分为几批是很简单的
以下几个潜在的警告:
确实只扫描Production.TransactionHistory一次,但这并不是真正的“一次扫描”方法,因为#temp表的大小相似,并且还需要对该表执行附加的逻辑I / O。但是,我认为这与工作表没有太大的区别,因为我们已经定义了它的精确结构,因此可以对其进行更多的手动控制
根据您的环境,可以将tempdb的使用视为积极的(例如,它在一组单独的SSD驱动器上)或为负(服务器上的高并发性,已经有很多tempdb争用)
#2 楼
这是一个很长的答案,所以我决定在此处添加一个摘要。首先,我提出一种解决方案,该解决方案以与问题中相同的顺序产生完全相同的结果。它扫描主表3次:获取带有每个产品日期范围的ProductIDs
列表,总结每天的成本(因为有多个具有相同日期的交易),将结果与原始行合并。 br />接下来,我比较了两种简化任务并避免最后扫描主表的方法。他们的结果是每日摘要,即,如果某产品的多个交易具有相同的日期,则将它们汇总为一行。我上一步中的方法对表进行了两次扫描。 Geoff Patterson的方法对表进行了一次扫描,因为他使用了有关日期范围和产品列表的外部知识。最后,我提出了单遍解决方案,该解决方案再次返回每日摘要,但不需要外部有关日期范围或
ProductIDs
列表的知识。我将使用AdventureWorks2014数据库和SQL Server Express2014。对原始数据库的更改:
将
[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);
关于
OVER
子句的MSDN文章链接到Itzik Ben-Gan关于窗口函数的出色博客文章。他在那篇文章中解释了OVER
的工作原理,ROWS
和RANGE
选项之间的区别,并提到了在日期范围内计算滚动总和的问题。他提到当前版本的SQL Server不能完全实现RANGE
,也不能实现时间间隔数据类型。他对ROWS
和RANGE
之间差异的解释给了我一个主意。没有空隙和重复的日期
如果
TransactionHistory
表包含没有空格且没有重复的日期,则以下查询将产生正确的结果:实际上,一个45行的窗口将恰好覆盖45天。 有空白且没有重复的日期
很遗憾,我们的数据中有空白。为了解决这个问题,我们可以使用
Calendar
表来生成一组没有间隔的日期,然后将LEFT JOIN
的原始数据添加到该数据集中,并使用与ROWS BETWEEN 45 PRECEDING AND CURRENT ROW
相同的查询。仅当日期不重复时(在同一ProductID
内),这才会产生正确的结果。不幸的是,我们的数据在日期和日期上都有间隙可以在同一ProductID
中重复。为了解决这个问题,我们可以用原始数据GROUP
生成一组没有重复的日期。然后,使用ProductID, TransactionDate
表生成一组没有间隔的日期。然后,我们可以使用带有Calendar
的查询来计算滚动ROWS BETWEEN 45 PRECEDING AND CURRENT ROW
。这将产生正确的结果。请参阅下面查询中的注释。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;
我确认此查询产生的结果与使用子查询的问题的处理方法相同。
执行计划
第一个查询使用子查询,第二个查询使用这种方法。您可以看到这种方法的持续时间和读取次数要少得多。这种方法中估计成本的大部分是最终的
SUM
,请参见下文。子查询方法具有嵌套循环和
ORDER BY
复杂度的简单计划。 br /> 这种方法的计划扫描
O(n*n)
几次,但是没有循环。如您所见,估计成本的70%以上是最终TransactionHistory
的Sort
。顶部结果-
ORDER BY
,底部-subquery
。避免额外扫描
上面计划中的最后一个索引扫描,合并联接和排序是由带有原始表的最终
OVER
引起的,以使最终结果与使用子查询的慢速方法完全相同。返回的行数与INNER JOIN
表中的行数相同。当同一产品在同一天发生数笔交易时,在TransactionHistory
中存在行。如果可以只在结果中显示每日摘要,则可以删除最后的TransactionHistory
,查询变得更简单,更快。上一个计划中的最后一个索引扫描,合并联接和排序已替换为过滤器,该过滤器删除了JOIN
添加的行。 >仍然,对Calendar
进行两次扫描。需要进行一次额外的扫描才能获取每种产品的日期范围。我很想知道它与另一种方法的比较,在另一种方法中,我们使用有关TransactionHistory
中全球日期范围的外部知识,外加具有所有TransactionHistory
的额外表Product
来避免该额外扫描。我从此查询中删除了每天交易数的计算,以使比较有效。可以在两个查询中都添加它,但为了简化比较,我想使其保持简单。我还必须使用其他日期,因为我使用的是2014年版本的数据库。 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
;
两个查询均以相同的顺序返回相同的结果。
比较
这里是时间和IO统计信息。
两次扫描变体要快一些,并且读取次数更少,因为一次扫描变体必须大量使用Worktable。此外,单扫描变体生成的行超出了计划中所需要的数量。即使
ProductIDs
没有任何交易,它也会为ProductID
表中的每个Product
生成日期。 ProductID
表中有504行,但是Product
中只有441个产品有交易。而且,它会为每种产品生成相同的日期范围,这超出了所需范围。如果TransactionHistory
的总体历史较长,而每个产品的历史较短,则多余的行数甚至会更高。另一方面,可以优化两次扫描通过仅在
TransactionHistory
上创建另一个更窄的索引来进一步扩展。该索引将用于计算每种产品的开始/结束日期(
(ProductID, TransactionDate)
),并且页面的数量少于覆盖索引的页面,因此导致读取次数减少。因此,我们可以选择,要么进行额外的显式简单扫描,要么具有隐式工作表。
BTW,如果可以只包含每日摘要的结果是可以的,那么最好创建一个不包含以下内容的索引: t包括
CTE_Products
。它将使用更少的页面=>更少的IO。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
;
使用CROSS APPLY的单遍解决方案
答案,但这是又一个变体,它仅再次返回每日摘要,但是它仅扫描一次数据,并且不需要外部日期范围或ProductID列表的知识。它也不做中间排序。总体性能与以前的变体相似,但似乎有些差。
主要思想是使用数字表来生成可填补日期空白的行。对于每个现有日期,请使用
ReferenceOrderID
以天为单位计算间隔的大小,然后使用LEAD
将所需的行数添加到结果集中。刚开始,我用一个永久的数字表尝试过。该计划在此表中显示了大量读取,尽管实际持续时间几乎与我使用CROSS APPLY
快速生成数字时相同。 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);
该计划是“较长的”,因为查询使用两个窗口函数(
CTE
和LEAD
)。 br /> #3 楼
一种替代的SQLCLR解决方案,执行速度更快,所需的内存更少:部署脚本
这需要设置
EXTERNAL_ACCESS
权限,因为它使用到目标服务器和数据库的环回连接,而不是(慢速)上下文连接。这是如何调用该函数的方法: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;
以与问题相同的顺序产生完全相同的结果。
执行计划:
分析器逻辑读取:481
此实现的主要优点是它比使用上下文连接更快,并且使用的内存更少。它一次只能将两件事保存在内存中:
任何重复的行(相同的产品和交易日期)。这是必需的,因为在产品或日期更改之前,我们不知道最终的总金额是多少。在样本数据中,产品和日期的组合包含64行。
当前产品的成本和交易日期的滑动范围仅为45天。调整离开45天滑动窗口的行的简单运行总和是必要的。
这种最少的缓存应确保此方法可扩展。肯定比尝试将整个输入集保留在CLR内存中要好。
源代码
#4 楼
如果您使用的是SQL Server 2014 64位企业版,开发人员版或评估版,则可以使用内存中OLTP。该解决方案将不会是一次扫描,并且几乎不会使用任何窗口函数,但是它可能会增加此问题的价值,并且所使用的算法可能会被其他解决方案用作启发。首先您需要在AdventureWorks数据库上启用内存中OLTP。
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;
该过程的参数是内存中表变量,必须将其定义为类型。
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在此表中不是唯一的,它对于
ProductID
和TransactionDate
的每个组合都是唯一的。该过程告诉您它做了什么,但总的来说,它是循环计算运行总计,对于每次迭代,它都会查找运行总计(如45天或更早)。
当前的总运行量减去45天前的总运行量就是我们正在寻找的45天的总和。
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;
调用这样的过程。
-- 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;
在我的计算机上进行测试,客户端统计报告的总执行时间约为750毫秒。为了进行比较,子查询版本需要3.5秒。
额外的麻烦:
常规T-SQL也可以使用此算法。使用
range
(而非行)计算运行总计,并将结果存储在临时表中。然后,您可以查询该表,并将其与45天前的运行总计进行自我连接,然后计算滚动总和。但是,与range
相比,rows
的实现速度相当慢,原因是需要以不同的方式对待order by子句的重复项,因此我无法通过这种方法获得所有的良好性能。解决此问题的方法可能是在使用last_value()
来模拟rows
运行总计的计算的运行总计上使用另一个窗口函数,例如range
。另一种方法是使用max() over()
。两者都有一些问题。使用max() over()
版本找到用于避免排序和避免线轴的适当索引。我放弃了优化这些事情,但是如果您对到目前为止的代码感兴趣,请告诉我。#5 楼
很好玩:)我的解决方案比@GeoffPatterson的解决方案要慢一些,但是部分原因是我要回溯到原始表以消除Geoff的一个假设(即每个产品/日期对一行) 。我假设这是最终查询的简化版本,并且可能需要原始表之外的其他信息。注意:我借用了Geoff的日历表,实际上最后得到了一个非常好的结果。类似的解决方案:
-- 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
这里是查询本身:
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;
基本上,我决定了最简单的方法要解决这个问题,是使用ROWS子句的选项。但这要求我每个
ProductID
,TransactionDate
组合只能有一行,而不仅仅是那个,而且我必须每个ProductID
和possible date
只能有一行。我这样做是在CTE中结合了Product,calendar和TransactionHistory表。然后,我必须创建另一个CTE来生成滚动信息。我之所以必须这样做是因为,如果我将它直接加入到原始表中,则会得到行消除,从而导致结果失败。之后,只需将我的第二个CTE重新加入原始表即可。我确实添加了TBE
列(将被消除)以摆脱在CTE中创建的空白行。另外,我在初始CTE中使用了CROSS APPLY
来为我的日历表生成边界。然后我添加了推荐的索引:
CREATE NONCLUSTERED INDEX [TransactionHistory_IX1]
ON [Production].[TransactionHistory] ([TransactionDate])
INCLUDE ([ProductID],[ReferenceOrderID],[ActualCost])
得到了最终的执行计划:
编辑:最后,我在日历表上添加了一个索引以加快性能以合理的幅度。
CREATE INDEX ix_calendar ON calendar(d)
#6 楼
我有一些不使用索引或引用表的替代解决方案。在您无权访问任何其他表且无法创建索引的情况下,它们可能很有用。当按TransactionDate
进行分组时,仅通过一次数据传递和一个窗口函数,似乎确实可以获得正确的结果。但是,当您无法按TransactionDate
进行分组时,我无法找出仅使用一个窗口函数的方法。不带覆盖索引的CPU时间为2808 ms,带覆盖索引的CPU时间为1950 ms。我正在使用AdventureWorks2014数据库和SQL Server Express 2014进行测试。让我们从何时可以按TransactionDate
分组的解决方案开始。最近X天的运行总和也可以用以下方式表示:行的运行总和=所有先前行的运行总和-先前所有行的运行总和日期在日期窗口之外。
在SQL中,表达这种情况的一种方法是制作数据的两个副本,第二个副本将成本乘以-1并相加X + 1天到日期列。计算所有数据的总和将实现上述公式。我将为一些示例数据显示此内容。以下是单个
ProductID
的一些示例日期。我将日期表示为数字,以便于计算。起始数据:╔══════╦══════╗
║ Date ║ Cost ║
╠══════╬══════╣
║ 1 ║ 3 ║
║ 2 ║ 6 ║
║ 20 ║ 1 ║
║ 45 ║ -4 ║
║ 47 ║ 2 ║
║ 64 ║ 2 ║
╚══════╩══════╝
添加第二个数据副本。第二份副本的日期增加了46天,成本乘以-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 ║
╚══════╩══════╩═══════════╝
筛选出复制的行以获得所需的结果:
╔══════╦══════╦═══════════╦════════════╗
║ 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 ║
╚══════╩══════╩═══════════╩════════════╝
以下SQL是一种实现方式上面的算法:
╔══════╦══════╦═══════════╦════════════╗
║ 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 ║
╚══════╩══════╩═══════════╩════════════╝
在我的机器上,使用覆盖索引需要702毫秒的CPU时间,而没有索引需要734毫秒的CPU时间。查询计划可以在以下位置找到:https://www.brentozar.com/pastetheplan/?id=SJdCsGVSl
该解决方案的一个缺点是,在按以下方式订购时似乎存在不可避免的排序方式新的
Date
列。我不认为可以通过添加索引来解决这种问题,因为我们需要在执行排序之前合并两个数据副本。通过在ORDER BY中添加另一列,我可以消除查询末尾的排序。如果我按CopiedRow
排序,我发现SQL Server将从排序中优化该列并执行显式排序。 TransactionDate
要复杂得多。我将问题概括为同时需要按同一列进行划分和排序。 Paul提供的语法解决了该问题,因此使用SQL Server中可用的当前窗口函数来表达它是如此困难(如果不难表达就无需扩展该语法)也就不足为奇了。 > 如果我使用上面的查询而不进行分组,那么当有多个行具有相同的
FilterFlag
和TransactionDate
时,我得到的滚动总和的值将不同。解决此问题的一种方法是执行与上述相同的运行总和计算,同时标记分区中的最后一行。可以使用ProductId
(假设ProductId
永远不会为NULL)完成此操作,而无需进行其他排序。对于最终的运行总和值,我使用TransactionDate
作为窗口函数,将分区最后一行中的值应用于分区中的所有行。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);
我的机器在没有覆盖索引的情况下花了2464ms的CPU时间。和以前一样,这似乎是不可避免的。查询计划可以在这里找到:https://www.brentozar.com/pastetheplan/?id=HyWxhGVBl
我认为上述查询还有改进的余地。当然,还有其他使用Windows函数获得所需结果的方法。
评论
不需要RunningTotal.TBE IS NOT NULL条件(因此是TBE列)。如果将其删除,您将不会得到多余的行,因为内部联接条件包括date列-因此结果集不能具有原始不在源中的日期。
– Andriy M
2015年9月11日在8:42
是的我完全同意。但这仍然使我获得了约0.2秒的收益。我认为它使优化器知道一些其他信息。
–肯尼斯·费舍尔
2015年9月11日在12:09