これは長い答えなので、ここに要約を追加することにしました。
- 最初に、質問と同じ順序でまったく同じ結果を生成するソリューションを紹介します。メインテーブルを3回スキャンします。ProductIDs各製品の日付の範囲のリストを取得し、(同じ日付のトランザクションが複数あるため)各日のコストを合計し、結果を元の行と結合します。
- 次に、タスクを簡素化し、メインテーブルの最後のスキャンを回避する2つのアプローチを比較します。結果は毎日の要約です。つまり、製品の複数のトランザクションの日付が同じ場合、それらは単一の行にロールされます。前のステップからの私のアプローチでは、テーブルを2回スキャンします。Geoff Pattersonによるアプローチでは、日付の範囲と製品リストに関する外部の知識を使用しているため、テーブルを1回スキャンします。
- 最後に、再び毎日の要約を返すシングルパスソリューションを紹介しますが、日付の範囲やのリストに関する外部の知識は必要ありませんProductIDs。
私が使用するAdventureWorks2014のデータベースとSQL Server Expressの2014。
元のデータベースの変更:
- [Production].[TransactionHistory].[TransactionDate]fromのタイプを変更し- 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テーブルにギャップや重複のない日付が含まれている場合、次のクエリは正しい結果を生成します。
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テーブルを使用して、ギャップのない一連の日付を生成します。その後、クエリを使用して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
;
このクエリは、サブクエリを使用する質問のアプローチと同じ結果を生成することを確認しました。
実行計画

最初のクエリはサブクエリを使用し、2番目はこのアプローチです。このアプローチでは、読み取りの期間と回数がはるかに少ないことがわかります。このアプローチの推定コストの大半は最終的なものORDER BYです。以下を参照してください。

サブクエリアプローチには、ネストされたループとO(n*n)複雑な単純な計画があります。

このアプローチスキャンをTransactionHistory数回計画しますが、ループはありません。ご覧のように、推定コストの70%以上がSort最終コストです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
;

それでも、TransactionHistory2回スキャンされます。各製品の日付範囲を取得するには、追加のスキャンが1回必要です。のグローバルな日付範囲に関する外部の知識と、その余分なスキャンを避けるために必要なすべてのTransactionHistory追加のテーブルを使用する別のアプローチと比較する方法に興味がありました。比較を有効にするために、このクエリから1日あたりのトランザクション数の計算を削除しました。両方のクエリに追加できますが、比較のためにシンプルに保ちたいと思います。また、2014バージョンのデータベースを使用しているため、他の日付も使用する必要がありました。ProductProductIDs
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の統計は次のとおりです。


1スキャンバリアントはWorktableを多く使用する必要があるため、2スキャンバリアントは少し高速で読み取りが少なくなります。また、1スキャンバリアントは、計画で確認できるように、必要以上の行を生成します。aにトランザクションがない場合でもProductID、Productテーブルにあるそれぞれの日付を生成しますProductID。Productテーブルには504行ありますが、には441の製品のみがトランザクションを持っていTransactionHistoryます。また、各製品に対して同じ範囲の日付を生成しますが、これは必要以上です。場合はTransactionHistory、個々の製品が比較的短い歴史を持つ長い全体的な歴史を持っていた、余分な不要な行の数はさらに高くなるであろう。
一方、justに別のより狭いインデックスを作成することで、2スキャンバリアントをさらに最適化することができ(ProductID, TransactionDate)ます。このインデックスは、各製品の開始/終了日を計算するために使用され(CTE_Products)、インデックスをカバーするよりもページが少なくなり、結果として読み取りが少なくなります。
そのため、追加の明示的なシンプルスキャンを使用するか、暗黙のワークテーブルを使用するかを選択できます。
ちなみに、日次の要約だけで結果が得られる場合は、を含まないインデックスを作成することをお勧めしますReferenceOrderID。より少ないページ=>より少ないIOを使用します。
CREATE NONCLUSTERED INDEX [i2] ON [Production].[TransactionHistory]
(
    [ProductID] ASC,
    [TransactionDate] ASC
)
INCLUDE ([ActualCost])
CROSS APPLYを使用したシングルパスソリューション
これは非常に長い答えになりますが、ここでは日ごとの要約のみを再び返すもう1つのバリアントがありますが、データのスキャンは1回だけであり、日付の範囲や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
;
クエリは2つのウィンドウ関数(LEADおよびSUM)を使用するため、このプランは「長く」なります。



               
              
RunningTotal.TBE IS NOT NULL条件(及び、従って、TBE列)は不要です。内部結合条件には日付列が含まれているため、削除しても冗長な行は得られません。したがって、結果セットには元のソースにない日付を含めることはできません。