これは長い答えなので、ここに要約を追加することにしました。
- 最初に、質問と同じ順序でまったく同じ結果を生成するソリューションを紹介します。メインテーブルを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
;
それでも、TransactionHistory
2回スキャンされます。各製品の日付範囲を取得するには、追加のスキャンが1回必要です。のグローバルな日付範囲に関する外部の知識と、その余分なスキャンを避けるために必要なすべてのTransactionHistory
追加のテーブルを使用する別のアプローチと比較する方法に興味がありました。比較を有効にするために、このクエリから1日あたりのトランザクション数の計算を削除しました。両方のクエリに追加できますが、比較のためにシンプルに保ちたいと思います。また、2014バージョンのデータベースを使用しているため、他の日付も使用する必要がありました。Product
ProductIDs
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
列)は不要です。内部結合条件には日付列が含まれているため、削除しても冗長な行は得られません。したがって、結果セットには元のソースにない日付を含めることはできません。