web-dev-qa-db-ja.com

ウィンドウ関数を使用した日付範囲ローリング合計

日付範囲のローリング合計を計算する必要があります。説明のために、 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);

実行計画は次のとおりです。

Execution plan

恐ろしく非効率的ではありませんが、SQL Server 2012、2014、または2016(これまでのところ)でサポートされているウィンドウ集計関数と分析関数のみを使用してこのクエリを表現できるように思われます。

わかりやすくするために、データに対して1回のパスを実行するソリューションを探しています。

T-SQLでは、これはおそらく OVER が機能し、実行プランがウィンドウスプールとウィンドウアグリゲートを特徴とすることを意味します。 OVER句を使用するすべての言語要素は公平なゲームです。 SQLCLRソリューションは、正しい結果を生成することが保証されている場合に受け入れられます。

T-SQLソリューションの場合、実行プラン内のハッシュ、ソート、およびウィンドウスプール/集計が少ないほど優れています。自由にインデックスを追加してください。ただし、個別の構造は許可されていません(そのため、たとえば事前計算されたテーブルはトリガーと同期されていません)。参照テーブルを使用できます(数値、日付などのテーブル)

理想的には、ソリューションは上記のサブクエリバージョンと同じ順序でまったく同じ結果を生成しますが、間違いなく正しいものも受け入れられます。パフォーマンスは常に考慮事項であるため、ソリューションは少なくとも合理的に効率的である必要があります。

専用チャットルーム:この質問とその回答に関連するディスカッション用のパブリックチャットルームを作成しました。 少なくとも20のレピュテーションポイント のユーザーは直接参加できます。担当者が20名未満で参加を希望される場合は、下のコメントで私にpingしてください。

58
Paul White 9

ポール、すばらしい質問です。 T-SQLとCLRの2つの異なるアプローチを使用しました。

T-SQLクイックサマリー

T-SQLのアプローチは、次の手順に要約できます。

  • 製品/日付のクロス積をとります
  • 観測された販売データをマージする
  • そのデータを製品/日付レベルに集約します
  • この集計データに基づいて、過去45日間のローリング合計を計算します(これには、「欠落」した日が含まれます)。
  • これらの結果をフィルターして、1つ以上の売上があった製品/日付のペアのみに絞り込みます。

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の要約は、次の手順で要約できます。

  • 製品と日付順に並べられたデータをメモリに読み込む
  • 各トランザクションの処理中に、現在の合計コストに追加します。トランザクションが前のトランザクションと異なる製品である場合は常に、積算合計を0にリセットします。
  • 現在のトランザクションと同じ(製品、日付)を持つ最初のトランザクションへのポインターを保持します。その(製品、日付)の最後のトランザクションが検出されるたびに、そのトランザクションのローリング合計を計算し、それを同じ(製品、日付)のすべてのトランザクションに適用します。
  • すべての結果をユーザーに返します!

このアプローチは、SET STATISTICS IO ONを使用して、論理I/Oが発生していないことを報告します!うわー、完璧なソリューションです! (実際、SET STATISTICS IOはCLR内で発生したI/Oを報告していないようです。しかし、コードから見ると、テーブルの正確に1回のスキャンが行われ、インデックスPaulによってデータを順番に取得していることが簡単にわかります提案した。

SET STATISTICS TIME ONによって報告されたように、CPU時間は187msになりました。したがって、これはT-SQLアプローチよりもかなり改善されています。残念ながら、両方のアプローチの全体的な経過時間は、それぞれ約0.5秒で非常によく似ています。ただし、CLRベースのアプローチでは、113K行をコンソールに出力する必要があります(製品/日付でグループ化するT-SQLアプローチでは52Kに対して)ので、代わりにCPU時間に焦点を合わせました。

このアプローチのもう1つの大きな利点は、元のループ/シークアプローチとまったく同じ結果が得られることです。同じ日に製品が複数回販売される場合でも、すべてのトランザクションの行が含まれます。 (AdventureWorksでは、行ごとの結果を具体的に比較し、Paulの元のクエリと結びついていることを確認しました。)

このアプローチの欠点は、少なくとも現在の形式では、メモリ内のすべてのデータを読み取ることです。ただし、設計されたアルゴリズムは、常にメモリ内の現在のウィンドウフレームを厳密に必要とするだけであり、メモリを超えるデータセットに対して機能するように更新できます。 Paulは、スライディングウィンドウのみをメモリに格納するこのアルゴリズムの実装を作成することにより、彼の回答でこの点を説明しました。これは、CLRアセンブリにより高いアクセス許可を与えることを犠牲にして行われますが、このソリューションを任意の大規模なデータセットにスケールアップすることには間違いなく価値があります。


T-SQL-日付でグループ化された1つのスキャン

初期設定

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の単一順序スキャンを実行するのに十分であることがわかります。マージ結合を使用して、トランザクション履歴と可能な各製品/日付の組み合わせを組み合わせます。

enter image description here

前提条件

このアプローチには、いくつかの重要な前提があります。それらが受け入れられるかどうかを決めるのはポール次第だと思います:)

  • Production.Productテーブルを使用しています。このテーブルはAdventureWorks2012で無料で利用でき、関係はProduction.TransactionHistoryの外部キーによって強制されるため、これを公平なゲームと解釈しました。
  • このアプローチは、トランザクションにAdventureWorks2012の時間コンポーネントがないという事実に依存しています。そうした場合、製品/日付の組み合わせの完全なセットを生成することは、最初にトランザクション履歴を通過しないと不可能になります。
  • 製品/日付のペアごとに1行のみを含む行セットを作成しています。これは「間違いなく正しい」と思い、多くの場合、より望ましい結果を返します。製品/日付ごとに、NumOrders列を追加して、発生した販売数を示しています。製品が同じ日に複数回販売された場合の元のクエリと提案されたクエリの結果の比較については、次のスクリーンショットを参照してください(例:319/2007-09-05 00:00:00.000

enter image description here


CLR-1つのスキャン、完全なグループ化されていない結果セット

メイン関数本体

ここに表示するものはありません。関数の本体は、入力(対応する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アプローチは、データセットのコピーをメモリに保持します。ストリーミングアプローチを使用することは可能ですが、最初の問題が発生し、SQL 2008+での変更によりこのタイプを使用することがより困難になるという 未解決の接続の問題 があることがわかりましたアプローチの。 (Paulが示すように)それは可能ですが、データベースをTRUSTWORTHYとして設定し、CLRアセンブリにEXTERNAL_ACCESSを付与することにより、より高いレベルの権限が必要です。したがって、面倒で潜在的なセキュリティ上の影響がありますが、見返りは、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

これにより、2つの関連するクエリプランの両方を一緒に見た場合でも、実際にはかなり単純なクエリプランが生成されます。

enter image description hereenter image description here

このアプローチが好きないくつかの理由:

  • 問題のステートメントで要求された完全な結果セットが生成されます(結果のグループ化されたバージョンを返す他のほとんどのT-SQLソリューションとは対照的です)。
  • 説明、理解、デバッグは簡単です。私は1年後に戻ってくることはなく、正しさやパフォーマンスを損なうことなく、小さな変更をどのように行うことができるのか疑問に思います
  • 元のループシークの900msではなく、提供されたデータセットで約2700msで実行されます
  • データの密度がはるかに高い(1日あたりのトランザクション数が多い)場合、計算の複雑さは、スライディングウィンドウ内のトランザクション数とともに2次的に増大しません(元のクエリの場合のように)。これは、複数のスキャンを避けたいというポールの懸念の一部に対応していると思います
  • 新しいtempdb遅延書き込み機能 が原因で、SQL 2012+の最近の更新でtempdb I/Oが本質的に発生しない
  • 非常に大きなデータセットの場合、メモリのプレッシャーが問題になる場合は、製品ごとに作業を個別のバッチに分割するのは簡単です。

いくつかの潜在的な警告:

  • 技術的にはProduction.TransactionHistoryを1回だけスキャンしますが、同様のサイズの#tempテーブルがあり、そのテーブルに対して追加のロジックI/Oを実行する必要があるため、これは本当に「1回のスキャン」アプローチではありません。ただし、正確な構造を定義しているため、これを手動で制御できる作業テーブルとあまり違いがあるとは思いません。
  • 環境に応じて、tempdbの使用は、肯定的なもの(たとえば、SSDドライブの別のセットにある)または否定的なもの(サーバーでの同時実行性が高く、tempdbの競合がすでに多い)と見なされる場合があります。
43
Geoff Patterson

これは長い答えなので、ここに要約を追加することにしました。

  • 最初に、質問と同じ順序でまったく同じ結果を生成するソリューションを提示します。メインテーブルを3回スキャンします。各製品の日付範囲を含むProductIDsのリストを取得し、各日のコストを合計します(同じ日付のトランザクションがいくつかあるため)。結果を元の行と結合します。
  • 次に、タスクを簡略化し、メインテーブルの最後のスキャンを1つ回避する2つのアプローチを比較します。結果は日次の要約です。つまり、製品の複数のトランザクションが同じ日付である場合、それらは単一の行にまとめられます。前のステップからの私のアプローチは、テーブルを2回スキャンします。 Geoff Pattersonによるアプローチは、日付の範囲と製品のリストに関する外部の知識を使用するため、テーブルを1回スキャンします。
  • ついに、私は再び日次要約を返すシングルパスソリューションを提示しますが、日付の範囲や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);
_

OVER 句に関するMSDNの記事には、Itzik Ben-Ganによる ウィンドウ関数に関する優れたブログ投稿 へのリンクがあります。その投稿では、OVERの仕組み、ROWSオプションとRANGEオプションの違いについて説明し、日付範囲のローリング合計を計算するこの非常に問題について言及しています。 SQL Serverの現在のバージョンはRANGEを完全に実装しておらず、時間間隔データ型を実装していないと彼は述べています。 ROWSRANGEの違いについての彼の説明は私にアイデアを与えてくれました。

ギャップや重複のない日付

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内で繰り返すことができます。この問題を解決するために、_ProductID, TransactionDate_によるGROUPの元のデータを使用して、重複のない日付のセットを生成できます。次に、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
;
_

このクエリは、サブクエリを使用する質問からのアプローチと同じ結果を生成することを確認しました。

実行計画

stats

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

subquery

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

over

このアプローチを計画してTransactionHistoryを数回スキャンしますが、ループはありません。ご覧のとおり、推定コストの70%以上は、最終的な_ORDER BY_のSortです。

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

two-scan

それでも、TransactionHistoryは2回スキャンされます。各製品の日付の範囲を取得するには、1回の追加スキャンが必要です。 TransactionHistoryの日付のグローバル範囲に関する外部の知識に加えて、すべてのProductを含む余分なテーブルProductIDsを使用して、その余分なスキャンを回避する別のアプローチと比較する方法に興味がありました。比較を有効にするために、このクエリから1日あたりのトランザクション数の計算を削除しました。両方のクエリで追加できますが、比較のために単純にしておく必要があります。 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);
_

one-scan

どちらのクエリも同じ結果を同じ順序で返します。

比較

時間とIO stats。

stats2

io2

1スキャンバリアントはWorktableを頻繁に使用する必要があるため、2スキャンバリアントは少し高速で読み取りが少なくなります。さらに、1スキャンバリアントは、計画でわかるように、必要以上の行を生成します。 ProductIDにトランザクションがない場合でも、Productテーブルにある各ProductIDの日付を生成します。 Productテーブルには504行ありますが、TransactionHistoryにトランザクションがあるのは441製品のみです。また、製品ごとに同じ範囲の日付が生成されるため、必要以上の日付になります。 TransactionHistoryの全体的な履歴が長く、個々の製品の履歴が比較的短い場合、余分な不要な行の数はさらに多くなります。

一方、_(ProductID, TransactionDate)_だけに別のより狭いインデックスを作成することにより、2スキャンバリアントをさらに最適化することができます。このインデックスは、各製品(_CTE_Products_)の開始/終了日を計算するために使用され、カバーするインデックスよりもページ数が少なくなるため、結果として読み取りが少なくなります。

したがって、追加の明示的な単純スキャンを使用するか、暗黙のWorktableを使用するかを選択できます。

ちなみに、日次の要約だけで結果が得られる場合は、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)を使用するため、このプランは「より長く」なります。

cross apply

ca stats

ca io

25

実行速度が速く、必要なメモリが少ない代替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 execution plan

SQLCLR Source Query execution plan

Plan Explorer performance statistics

プロファイラーの論理読み取り:481

この実装の主な利点は、コンテキスト接続を使用するよりも高速で、使用するメモリが少ないことです。一度に2つのものだけをメモリに保持します。

  1. 重複する行(同じ製品と取引日)。これは、製品または日付のいずれかが変更されるまで、最終的な実行合計がどうなるかわからないためです。サンプルデータには、64行の製品と日付の組み合わせが1つあります。
  2. 現在の製品について、45日間の変動するコストと取引日のみ。これは、45日間のスライディングウィンドウを離れる行の単純な実行合計を調整するために必要です。

この最小限のキャッシングにより、このメソッドが適切にスケーリングされるようになります。入力セット全体をCLRメモリに保持するよりも確かに優れています。

ソースコード

24
Paul White 9

SQL Server 2014の64ビットのEnterprise、Developer、またはEvaluationエディションを使用している場合は、 In-Memory 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日前の現在の合計に自己結合してそのテーブルをクエリし、ローリング合計を計算できます。ただし、rangeと比較してrowsの実装は、order by句の重複を異なる方法で処理する必要があるため、このアプローチではそれほど優れたパフォーマンスを得られなかったため、かなり遅い。これに対する回避策は、rowsを使用して計算された積算合計に対してlast_value()などの別のウィンドウ関数を使用し、range積算合計をシミュレートすることです。もう1つの方法は、max() over()を使用することです。どちらにもいくつかの問題がありました。 max() over()バージョンを使用して、ソートを回避し、スプールを回避するために使用する適切なインデックスを見つけます。これらの最適化はあきらめましたが、これまでにコードに興味がある場合はお知らせください。

17
Mikael Eriksson

それは面白かった:)私の解決策は@GeoffPattersonのそれより少し遅いですが、その一部は、Geoffの仮定の1つ(つまり、製品/日付のペアごとに1行)を排除するために元のテーブルに縛り付けているという事実です。私はこれが最終クエリの簡略化されたバージョンであり、元のテーブルからの追加情報を必要とする可能性があると仮定して行きました。

注:私は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の組み合わせごとに1つの行だけが必要でしたが、ProductIDpossible dateごとに1つの行が必要でした。 CTEでProduct、calendar、およびTransactionHistoryテーブルを組み合わせてそれを行いました。次に、ローリング情報を生成するために別のCTEを作成する必要がありました。元のテーブルに直接結合すると、行の削除が行われて結果が失われるため、これを行う必要がありました。その後は、2番目のCTEを元のテーブルに追加するだけで済みました。 CTEで作成されたblank行を削除するためにTBE列(削除される)を追加しました。また、最初のCTEでCROSS APPLYを使用して、カレンダーテーブルの境界を生成しました。

次に、推奨インデックスを追加しました。

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

そして最終的な実行計画を得ました:

enter image description hereenter image description hereenter image description here

EDIT:結局、カレンダーテーブルにインデックスを追加して、妥当なマージンでパフォーマンスを向上させました。

CREATE INDEX ix_calendar ON calendar(d)
13
Kenneth Fisher

インデックスや参照テーブルを使用しない代替ソリューションがいくつかあります。おそらく、追加のテーブルにアクセスできず、インデックスを作成できない場合に役立つでしょう。データの単一のパスと単一のウィンドウ関数だけでTransactionDateでグループ化すると、正しい結果が得られるようです。しかし、TransactionDateでグループ化できない場合、1つのウィンドウ関数だけでそれを行う方法を理解できませんでした。

参照フレームを提供するために、私のマシンで質問に投稿された元のソリューションのCPU時間は、カバリングインデックスなしで2808ミリ秒、カバリングインデックスありで1950ミリ秒です。私は、AdventureWorks2014データベースとSQL Server Express 2014でテストしています。

TransactionDateでグループ化できる場合の解決策から始めましょう。過去X日間の合計は、次のように表すこともできます。

行の現在の合計=すべての以前の行の現在の合計-日付が日付ウィンドウの範囲外であるすべての以前の行の現在の合計。

SQLでは、これを表現する1つの方法は、データの2つのコピーを作成し、2番目のコピーを作成して、コストに-1を乗算し、日付列にX + 1日を追加することです。すべてのデータの実行合計を計算すると、上記の式が実装されます。これをいくつかのサンプルデータで示します。以下は、単一のProductIDのサンプル日付です。計算を簡単にするために、日付を数値として表します。開始データ:

╔══════╦══════╗
║ Date ║ Cost ║
╠══════╬══════╣
║    1 ║    3 ║
║    2 ║    6 ║
║   20 ║    1 ║
║   45 ║   -4 ║
║   47 ║    2 ║
║   64 ║    2 ║
╚══════╩══════╝

データの2番目のコピーを追加します。 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は、上記のアルゴリズムを実装する1つの方法です。

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

このソリューションの欠点の1つは、新しいTransactionDate列で並べ替えると、避けられないソートになるように見えることです。順序付けを行う前にデータの2つのコピーを組み合わせる必要があるため、インデックスを追加することでこの種類を解決できるとは思いません。 ORDER BYに別の列を追加することで、クエリの最後にソートを取り除くことができました。 FilterFlagで注文した場合、SQL Serverがその列をソートから最適化し、明示的なソートを実行することがわかりました。

同じTransactionDateに対して重複するProductId値を含む結果セットを返す必要がある場合の解決策は、はるかに複雑でした。私は問題を、同じ列で分割と順序付けを同時に行う必要があると要約します。 Paulが提供した構文はその問題を解決するので、SQL Serverで使用可能な現在のウィンドウ関数で表現することはそれほど難しくありません(表現するのが難しくなかった場合は、構文を拡張する必要はありません)。

グループ化せずに上記のクエリを使用すると、同じProductIdTransactionDateの行が複数ある場合、ローリング合計の値が異なります。これを解決する1つの方法は、上記と同じ実行合計の計算を行うだけでなく、パーティションの最後の行にフラグを立てることです。これは、追加のソートなしでLEADProductIDが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

上記のクエリには改善の余地があると思います。希望する結果を得るためにウィンドウ関数を使用する方法は確かに他にもあります。

4
Joe Obbish