ウィンドウ関数を使用した日付範囲のローリングサム


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(これまで)でサポートされているウィンドウ集計および分析関数のみを使用してこのクエリを表現できるように思われます。

明確にするために、データを1回パスするソリューションを探しています。

T-SQLには、これは、ということを意味する可能性がある句は仕事、およびウィンドウスプールとウィンドウ集計を特色にする実行計画を行います。この句を使用するすべての言語要素は、公正なゲームです。SQLCLRソリューションは、正しい結果が得られることが保証されていれば許容れます。OVEROVER

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

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

専用チャットルーム:この質問とその回答に関連するディスカッション用の公開チャットルームを作成しました。20以上の評価ポイントを持つユーザーは直接参加できます。20人未満の担当者が参加したい場合は、下のコメント欄で私にpingを送ってください。

回答:


42

素晴らしい質問です、ポール!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 IOCLR内で発生したI / Oを報告しないようです。しかし、コードからは、テーブルのスキャンが1回だけ行われ、Paulが示唆したインデックスで順番にデータを取得することがわかります。

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

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

ここに画像の説明を入力してください

仮定

このアプローチには、いくつかの重要な仮定が組み込まれています。許容できるかどうかを決めるのはポール次第だと思います:)

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

ここに画像の説明を入力してください


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+の変更によりこのタイプのアプローチを使用するのがより困難になると訴える未解決のConnectの問題あることがわかりました。(Paulが示すように)まだ可能ですが、データベースをCLRアセンブリとして設定しTRUSTWORTHY、付与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つの関連するクエリプランの両方を一緒に見ても、実際にはかなり単純なクエリプラン全体が得られます。

ここに画像の説明を入力してください ここに画像の説明を入力してください

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

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

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

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

25

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

  • 最初に、質問と同じ順序でまったく同じ結果を生成するソリューションを紹介します。メインテーブルを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、彼はどのように機能するかROWSRANGEオプションとオプションの違いを説明し、日付範囲でローリングサムを計算するこのまさに問題に言及しています。彼は、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。この問題を解決するために、重複することなく日付のセットを生成すること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

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
;

2スキャン

それでも、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の統計は次のとおりです。

stats2

io2

1スキャンバリアントはWorktableを多く使用する必要があるため、2スキャンバリアントは少し高速で読み取りが少なくなります。また、1スキャンバリアントは、計画で確認できるように、必要以上の行を生成します。aにトランザクションがない場合でもProductIDProductテーブルにあるそれぞれの日付を生成しますProductIDProductテーブルには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)を使用するため、このプランは「長く」なります。

クロスアプライ

caの統計

ca io


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ソースクエリ実行計画

Explorerのパフォーマンス統計を計画する

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

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

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

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

ソースコード


17

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日前の現在の合計に自己結合してそのテーブルを照会し、ローリングサムを計算できます。ただし、order by句の重複を異なる方法で処理する必要があるため、range比較対象の実装rowsは非常に遅いため、このアプローチではそれほど良いパフォーマンスが得られませんでした。これを回避するにはlast_value()、計算された積算合計を超えるような別のウィンドウ関数を使用して、積算合計をrowsシミュレートしrangeます。別の方法はを使用することmax() over()です。両方ともいくつかの問題がありました。ソートを避け、スプールを避けるために使用する適切なインデックスを見つけるmax() over()版。私はそれらの最適化をあきらめましたが、これまでに持っているコードに興味があれば教えてください。


13

まあそれは楽しかったです:)私の解決策は@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句のオプション。しかし、それは私だけごとに1つの行を持っていることを要求さProductIDTransactionDate組み合わせだけではなく、それが、私はごとに1つの行を持っていたProductIDpossible date。Product、calendar、TransactionHistoryの各テーブルをCTEに組み合わせてそれを行いました。次に、ローリング情報を生成するために別のCTEを作成する必要がありました。私はこれをしなければなりませんでした。元のテーブルに直接結合すると、行の削除が発生して結果が失われたからです。その後、2番目のCTEを元のテーブルに戻すという簡単なことでした。CTEで作成された空白TBEを取り除くために、列(削除する)を追加しました。また、最初のCTEでを使用して、カレンダーテーブルの境界を生成しました。CROSS APPLY

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

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列)は不要です。内部結合条件には日付列が含まれているため、削除しても冗長な行は得られません。したがって、結果セットには元のソースにない日付を含めることはできません。
アンドリーM

2
うん。私は完全に同意します。それでも、それでも約.2秒の差が生じました。オプティマイザーにいくつかの追加情報を知らせると思います。
ケネスフィッシャー

4

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

参照フレームを提供するために、私のマシンでは、質問に投稿された元のソリューションの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で使用可能な現在のウィンドウ関数で表現するのがそれほど難しくないことは驚くことではありません(表現することが難しくなければ、構文を拡張する必要はありません)。

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

上記のクエリには改善の余地があると思います。もちろん、ウィンドウ関数を使用して目的の結果を得る他の方法もあります。

弊社のサイトを使用することにより、あなたは弊社のクッキーポリシーおよびプライバシーポリシーを読み、理解したものとみなされます。
Licensed under cc by-sa 3.0 with attribution required.