使用窗口功能的日期范围滚动总和


56

我需要计算日期范围内的滚动总和。为了说明这一点,使用AdventureWorks示例数据库,以下假设语法将完全满足我的需要:

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操作。

Answers:


42

很好的问题,保罗!我使用了两种不同的方法,一种在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时间为514ms2231ms与原始查询相比,这是有利的。

CLR快速摘要

CLR摘要可以概括为以下步骤:

  • 将数据读入内存,按产品和日期排序
  • 在处理每笔交易时,会增加总的成本。只要某笔交易是与上一笔交易不同的产品,就将运行总计重置为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方法而言,则只有52,000行),因此这就是为什么我专注于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,使用合并联接将交易历史记录与每种可能的产品/日期组合进行组合。

在此处输入图片说明

假设条件

这种方法有一些重要的假设。我想应该由保罗来决定它们是否可以接受:)

  • 我正在用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;
}

帮手

可以内联编写以下逻辑,但是将它们拆分为自己的方法时,它的读取要容易一些。

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_ACCESSCLR程序集来获得更高级别的权限。因此,存在一些麻烦和潜在的安全隐患,但回报是一种流方法,比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原始循环搜索的数据集
  • 如果数据密集得多(每天有更多交易),则计算复杂度不会随滑动窗口中的交易数量呈二次增长(就像原始查询一样);我认为这解决了保罗担心要避免多次扫描的部分问题
  • 由于新的tempdb惰性写入功能,因此在SQL 2012+的最新更新中基本上没有tempdb I / O。
  • 对于非常大的数据集,如果要考虑存储压力,则将每个产品的工作分为几个批次是很简单的

一些潜在的警告:

  • 尽管从技术上讲它只扫描Production.TransactionHistory一次,但这并不是真正的“一次扫描”方法,因为#temp表的大小相似,并且还需要对该表执行附加的逻辑I / O。但是,我认为这与工作表没有太大的区别,因为我们已经定义了它的精确结构,因此可以对其进行更多的手动控制
  • 根据您的环境,可以将tempdb的使用情况看成是肯定的(例如,在单独的一组SSD驱动器上)或否定的(服务器上的高并发性,已经有很多tempdb争用)

25

这是一个很长的答案,所以我决定在此处添加摘要。

  • 首先,我提出一个解决方案,该解决方案以与问题中相同的顺序产生完全相同的结果。它扫描主表3次:获取ProductIDs每个产品的日期范围列表,汇总每天的费用(因为有多个具有相同日期的交易),将结果与原始行合并。
  • 接下来,我将比较两种简化任务并避免最后扫描主表的方法。其结果是每日摘要,即,如果某个产品上的多个交易具有相同的日期,则将它们汇总为一行。我上一步中的方法对表进行了两次扫描。Geoff Patterson的方法对表格进行了一次扫描,因为他使用有关日期范围和产品列表的外部知识。
  • 最后,我提出了一种单次通过解决方案,该解决方案再次返回每日摘要,但是它不需要有关日期范围或的列表的外部知识ProductIDs

我将使用AdventureWorks2014数据库和SQL Server Express 2014。

对原始数据库的更改:

  • 的类型[Production].[TransactionHistory].[TransactionDate]从更改datetimedate。无论如何,时间分量为零。
  • 添加了日历表 [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工作原理,ROWSRANGE选项之间的区别,并提到了在日期范围内计算滚动总和的问题。他提到当前版本的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

io

最佳结果- 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统计信息。

统计2

io2

两次扫描变体要快一些,并且读取次数更少,因为一次扫描变体必须大量使用Worktable。此外,单扫描变体生成的行超出了计划中所需要的数量。即使表a 没有任何交易,它也会ProductIDProduct表中的每个表生成日期ProductIDProduct表中有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
;

该计划是“更长的”计划,因为查询使用两个窗口函数(LEADSUM)。

交叉申请

统计

卡奥


23

一种替代的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;

与问题产生的顺序相同,结果完全相同。

执行计划:

SQLCLR TVF执行计划

SQLCLR源查询执行计划

计划资源管理器性能统计

探查器逻辑读取:481

此实现的主要优点是它比使用上下文连接更快,并且使用的内存更少。一次只能将两件事保存在内存中:

  1. 任何重复的行(相同的产品和交易日期)。这是必需的,因为在产品或日期更改之前,我们不知道最终的总金额是多少。在样本数据中,产品和日期的组合包含64行。
  2. 仅适用于当前产品的45天的成本和交易日期滑动范围。调整离开45天滑动窗口的行的简单运行总和是必要的。

这种最少的缓存应确保此方法可扩展。肯定比尝试将整个输入集保留在CLR内存中更好。

源代码


17

如果您使用的是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不在该表中唯一的,它是用于的每个组合独特ProductIDTransactionDate

该过程中有一些注释可以告诉您它的功能,但总体而言,它是在循环中计算运行总计,对于每次迭代,它都会查找运行总计(如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天前的运行总计进行自我连接,然后计算滚动总和。但是,由于需要以不同的方式对待order by子句的重复,因此执行rangecompare to rows的速度相当慢,因此使用这种方法并不能获得所有良好的性能。解决该问题的方法可能是使用另一个窗口函数,例如last_value()使用rows模拟range运行总计的计算运行总计。另一种方法是使用max() over()。两者都有一些问题。找到合适的索引来避免排序,并避免使用max() over()版。我放弃了优化这些事情,但是如果您对到目前为止的代码感兴趣,请告诉我。


13

很好玩:)我的解决方案比@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子句的选项。但这要求每个组合只包含一行ProductIDTransactionDate而不仅限于此,而且每个ProductID和也必须包含一行possible date。我这样做是在CTE中结合了Product,Calendar和TransactionHistory表。然后,我必须创建另一个CTE来生成滚动信息。我之所以必须这样做是因为,如果我直接将其加入到原始表中,则会导致行消除,从而降低了结果。之后,只需将我的第二个CTE重新加入原始表即可。我确实添加了TBE要删除的列以摆脱在CTE中创建的空白行。另外,我CROSS APPLY在初始CTE中使用来为日历表生成边界。

然后,我添加了推荐的索引:

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

并得到了最终的执行计划:

在此处输入图片说明 在此处输入图片说明 在此处输入图片说明

编辑:最后,我在日历表上添加了一个索引,以合理的幅度加快了性能。

CREATE INDEX ix_calendar ON calendar(d)

2
RunningTotal.TBE IS NOT NULL条件(以及因此的TBE列)是不必要的。如果将其删除,则不会获得多余的行,因为内部联接条件包括date列-因此结果集不能具有源中最初不存在的日期。
Andriy M 2015年

2
是的 我完全同意。但这仍然使我获得了约0.2秒的收益。我认为它使优化器知道一些其他信息。
肯尼斯·费舍尔

4

我有一些不使用索引或引用表的替代解决方案。在您无权访问任何其他表且无法创建索引的情况下,它们可能很有用。TransactionDate仅通过一次数据传递和一个窗口函数进行分组时,似乎确实可以获得正确的结果。但是,当您无法按分组时,我无法解决仅使用一个窗口函数的方法TransactionDate

为了提供参考框架,在我的机器上,问题中发布的原始解决方案的CPU时间为2808 ms(不包括覆盖率索引)和1950 ms(带有覆盖率索引)。我正在使用AdventureWorks2014数据库和SQL Server Express 2014进行测试。

让我们从何时可以分组的解决方案开始TransactionDate。最近X天的运行总和也可以用以下方式表示:

每行的运行总和=所有先前行的运行总和-日期在日期窗口之外的所有先前行的运行总和。

在SQL中,表达这种情况的一种方法是制作数据的两个副本,第二个副本,将成本乘以-1,然后在date列中添加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升序和CopiedRow降序排序的运行总和:

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

筛选出复制的行以获得所需的结果:

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

以下SQL是实现上述算法的一种方法:

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

在我的机器上,使用覆盖索引需要702毫秒的CPU时间,而没有索引需要734毫秒的CPU时间。查询计划可以在这里找到:https : //www.brentozar.com/pastetheplan/?id=SJdCsGVSl

该解决方案的一个缺点是,在按新TransactionDate列进行排序时,似乎存在不可避免的排序。我不认为可以通过添加索引来解决这种排序问题,因为在进行排序之前,我们需要合并两个数据副本。通过在ORDER BY中添加不同的列,我能够消除查询末尾的排序。如果我下达命令,FilterFlag我发现SQL Server将优化排序中的该列并执行显式排序。

当我们需要返回具有相同重复TransactionDate值的结果集时,解决方案要ProductId复杂得多。我将问题总结为同时需要按同一列进行划分和排序。Paul提供的语法可以解决该问题,因此使用SQL Server中可用的当前窗口函数来表达它是如此困难(如果不难表达,就无需扩展该语法)也就不足为奇了。

如果我使用上面的查询而不进行分组,那么当有多个行与相同时ProductId,我会得到不同的滚动总和值TransactionDate。解决此问题的一种方法是执行与上述相同的运行总和计算,但还要标记分区中的最后一行。可以使用LEAD(假设ProductID永远不会为NULL)完成此操作,而无需进行其他排序。对于最终的运行总和值,我MAX用作窗口函数,将分区最后一行中的值应用于分区中的所有行。

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

在我的机器上,这花费了2464ms的CPU时间,没有覆盖索引。和以前一样,这似乎是不可避免的。查询计划可以在这里找到:https : //www.brentozar.com/pastetheplan/?id=HyWxhGVBl

我认为上述查询还有改进的余地。当然,还有其他使用Windows函数获得所需结果的方法。

By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.