이것은 긴 답변이므로 여기에 요약을 추가하기로 결정했습니다.
- 처음에는 질문에서와 동일한 순서로 정확히 동일한 결과를 생성하는 솔루션을 제시합니다. 기본 테이블을 3 번 스캔합니다.
ProductIDs
각 제품의 날짜 범위 목록을 가져 오고, 매일 같은 비용을 가진 여러 트랜잭션이 있기 때문에 매일 비용을 요약하고, 원래 행과 결과를 결합합니다.
- 다음으로 작업을 단순화하고 메인 테이블의 마지막 스캔을 피하는 두 가지 접근 방식을 비교합니다. 결과는 일일 요약입니다. 즉, 제품의 여러 트랜잭션이 동일한 날짜를 가진 경우 단일 행으로 롤업됩니다. 이전 단계의 접근 방식으로 테이블을 두 번 스캔합니다. Geoff Patterson의 접근법은 날짜 범위 및 제품 목록에 대한 외부 지식을 사용하므로 테이블을 한 번 스캔합니다.
- 마지막으로 일일 요약을 다시 반환하는 단일 패스 솔루션을 제시하지만 날짜 범위 또는에 대한 목록에 대한 외부 지식이 필요하지 않습니다
ProductIDs
.
AdventureWorks2014 데이터베이스와 SQL Server Express 2014를 사용하겠습니다 .
원본 데이터베이스의 변경 사항 :
- 유형을
[Production].[TransactionHistory].[TransactionDate]
에서 (으) datetime
로 변경 했습니다 date
. 어쨌든 시간 구성 요소는 0이었습니다.
- 캘린더 테이블 추가
[dbo].[Calendar]
- 에 색인 추가
[Production].[TransactionHistory]
.
CREATE TABLE [dbo].[Calendar]
(
[dt] [date] NOT NULL,
CONSTRAINT [PK_Calendar] PRIMARY KEY CLUSTERED
(
[dt] ASC
))
CREATE UNIQUE NONCLUSTERED INDEX [i] ON [Production].[TransactionHistory]
(
[ProductID] ASC,
[TransactionDate] ASC,
[ReferenceOrderID] ASC
)
INCLUDE ([ActualCost])
-- Init calendar table
INSERT INTO dbo.Calendar (dt)
SELECT TOP (50000)
DATEADD(day, ROW_NUMBER() OVER (ORDER BY s1.[object_id])-1, '2000-01-01') AS dt
FROM sys.all_objects AS s1 CROSS JOIN sys.all_objects AS s2
OPTION (MAXDOP 1);
MSDN 기사 OVER
조항 에 대해서는 Itzik Ben-Gan의 창 기능 에 대한 훌륭한 블로그 게시물 링크가 있습니다. 해당 게시물에서 그는 방법을 설명합니다 OVER
, 차이 작동 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
있습니다.
중복되는 공백이있는 날짜
안타깝게도 Google 데이터에는 날짜에 차이가 있으며 동일한 날짜 내에서 날짜가 반복 될 수 있습니다 ProductID
. 이 문제를 해결하기 위해 중복없이 날짜 집합을 생성하여 GROUP
원본 데이터 ProductID, TransactionDate
를 만들 수 있습니다 . 그런 다음 Calendar
표를 사용하여 공백없이 날짜 집합을 생성하십시오. 그런 다음 with ROWS BETWEEN 45 PRECEDING AND CURRENT ROW
를 사용하여 rolling을 계산할 수 있습니다 SUM
. 올바른 결과를 얻을 수 있습니다. 아래 쿼리에서 주석을 참조하십시오.
WITH
-- calculate Start/End dates for each product
CTE_Products
AS
(
SELECT TH.ProductID
,MIN(TH.TransactionDate) AS MinDate
,MAX(TH.TransactionDate) AS MaxDate
FROM [Production].[TransactionHistory] AS TH
GROUP BY TH.ProductID
)
-- generate set of dates without gaps for each product
,CTE_ProductsWithDates
AS
(
SELECT CTE_Products.ProductID, C.dt
FROM
CTE_Products
INNER JOIN dbo.Calendar AS C ON
C.dt >= CTE_Products.MinDate AND
C.dt <= CTE_Products.MaxDate
)
-- generate set of dates without duplicates for each product
-- calculate daily cost as well
,CTE_DailyCosts
AS
(
SELECT TH.ProductID, TH.TransactionDate, SUM(ActualCost) AS DailyActualCost
FROM [Production].[TransactionHistory] AS TH
GROUP BY TH.ProductID, TH.TransactionDate
)
-- calculate rolling sum over 45 days
,CTE_Sum
AS
(
SELECT
CTE_ProductsWithDates.ProductID
,CTE_ProductsWithDates.dt
,CTE_DailyCosts.DailyActualCost
,SUM(CTE_DailyCosts.DailyActualCost) OVER (
PARTITION BY CTE_ProductsWithDates.ProductID
ORDER BY CTE_ProductsWithDates.dt
ROWS BETWEEN 45 PRECEDING AND CURRENT ROW) AS RollingSum45
FROM
CTE_ProductsWithDates
LEFT JOIN CTE_DailyCosts ON
CTE_DailyCosts.ProductID = CTE_ProductsWithDates.ProductID AND
CTE_DailyCosts.TransactionDate = CTE_ProductsWithDates.dt
)
-- remove rows that were added by Calendar, which fill the gaps in dates
-- add back duplicate dates that were removed by GROUP BY
SELECT
TH.ProductID
,TH.TransactionDate
,TH.ActualCost
,CTE_Sum.RollingSum45
FROM
[Production].[TransactionHistory] AS TH
INNER JOIN CTE_Sum ON
CTE_Sum.ProductID = TH.ProductID AND
CTE_Sum.dt = TH.TransactionDate
ORDER BY
TH.ProductID
,TH.TransactionDate
,TH.ReferenceOrderID
;
이 쿼리는 하위 쿼리를 사용하는 질문의 접근 방식과 동일한 결과를 생성 함을 확인했습니다.
실행 계획
첫 번째 쿼리는 하위 쿼리를 사용하고 두 번째는이 방법을 사용합니다. 이 방법에서는 지속 시간과 읽기 수가 훨씬 적습니다. 이 접근법에서 예상 비용의 대부분은 최종적입니다 ( ORDER BY
아래 참조).
하위 쿼리 접근 방식에는 중첩 루프와 O(n*n)
복잡성 이 포함 된 간단한 계획이 있습니다.
이 접근 방식에 대한 계획은 TransactionHistory
여러 번 스캔 하지만 루프는 없습니다. 보시다시피 예상 비용의 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
두 번 스캔됩니다. 각 제품의 날짜 범위를 얻으려면 한 번의 추가 스캔이 필요합니다. 나는 그것이 다른 날짜와 비교하는 방법에 관심이있었습니다. 우리는의 날짜 범위에 대한 외부 지식 TransactionHistory
과 추가 스캔을 피할 수있는 추가 테이블을 사용 Product
했습니다 ProductIDs
. 비교를 유효하게하기 위해이 쿼리에서 매일 트랜잭션 수 계산을 제거했습니다. 두 쿼리 모두에 추가 할 수 있지만 비교를 위해 간단하게 유지하고 싶습니다. 2014 버전의 데이터베이스를 사용하기 때문에 다른 날짜도 사용해야했습니다.
DECLARE @minAnalysisDate DATE = '2013-07-31',
-- Customizable start date depending on business needs
@maxAnalysisDate DATE = '2014-08-03'
-- Customizable end date depending on business needs
SELECT
-- one scan
ProductID, TransactionDate, ActualCost, RollingSum45
--, NumOrders
FROM (
SELECT ProductID, TransactionDate,
--NumOrders,
ActualCost,
SUM(ActualCost) OVER (
PARTITION BY ProductId ORDER BY TransactionDate
ROWS BETWEEN 45 PRECEDING AND CURRENT ROW
) AS RollingSum45
FROM (
-- The full cross-product of products and dates,
-- combined with actual cost information for that product/date
SELECT p.ProductID, c.dt AS TransactionDate,
--COUNT(TH.ProductId) AS NumOrders,
SUM(TH.ActualCost) AS ActualCost
FROM Production.Product p
JOIN dbo.calendar c
ON c.dt BETWEEN @minAnalysisDate AND @maxAnalysisDate
LEFT OUTER JOIN Production.TransactionHistory TH
ON TH.ProductId = p.productId
AND TH.TransactionDate = c.dt
GROUP BY P.ProductID, c.dt
) aggsByDay
) rollingSums
--WHERE NumOrders > 0
WHERE ActualCost IS NOT NULL
ORDER BY ProductID, TransactionDate
-- MAXDOP 1 to avoid parallel scan inflating the scan count
OPTION (MAXDOP 1);
두 쿼리 모두 동일한 결과를 동일한 순서로 반환합니다.
비교
시간 및 IO 통계는 다음과 같습니다.
한 스캔 변종은 작업 테이블을 많이 사용해야하므로 2- 스캔 변형은 조금 더 빠르고 읽기 수가 적습니다. 또한 단일 스캔 변형은 계획에서 볼 수 있듯이 필요한 것보다 많은 행을 생성합니다. 거래가없는 경우에도 테이블 ProductID
에있는 각각의 날짜를 생성 합니다. 테이블 에는 504 개의 행이 있지만에 441 개의 제품 만 트랜잭션을 가지고 있습니다 . 또한 각 제품에 대해 동일한 날짜 범위를 생성하는데, 이는 필요 이상입니다. 경우 각 개별 제품이 상대적으로 짧은 역사를 가지고있는 이상 전체 역사를 가지고, 여분의 불필요한 행의 수는 더 높은 것입니다.Product
ProductID
Product
TransactionHistory
TransactionHistory
반면에,보다 좁은 다른 인덱스를 생성하여 2 스캔 변형을 조금 더 최적화 할 수 있습니다 (ProductID, TransactionDate)
. 이 색인은 각 제품의 시작 / 종료 날짜를 계산하는 데 사용되며 ( CTE_Products
) 색인을 포함하는 것보다 적은 페이지를 가지며 결과적으로 읽기가 줄어 듭니다.
따라서 명시 적 단순 스캔을 추가하거나 암시 적 작업 테이블을 선택할 수 있습니다.
BTW, 일일 요약만으로 결과를 얻는 것이 좋다면을 포함하지 않는 인덱스를 만드는 것이 좋습니다 ReferenceOrderID
. 적은 페이지 => 적은 IO를 사용합니다.
CREATE NONCLUSTERED INDEX [i2] ON [Production].[TransactionHistory]
(
[ProductID] ASC,
[TransactionDate] ASC
)
INCLUDE ([ActualCost])
CROSS APPLY를 사용한 단일 패스 솔루션
정말 긴 대답이되지만 여기에는 일일 요약 만 다시 반환하는 또 하나의 변형이 있지만 데이터를 한 번만 스캔하므로 날짜 범위 또는 ProductID 목록에 대한 외부 지식이 필요하지 않습니다. 중간 정렬도 수행하지 않습니다. 전반적인 성능은 이전 변형과 비슷하지만 약간 나빠 보입니다.
주요 아이디어는 숫자 테이블을 사용하여 날짜의 간격을 채울 행을 생성하는 것입니다. 기존의 각 날짜에 대해 LEAD
일 단위 간격의 크기를 계산 한 다음 CROSS APPLY
결과 집합에 필요한 수의 행을 추가하는 데 사용 합니다. 처음에는 영구적 인 숫자 테이블로 시도했습니다. 계획은이 표에서 많은 수의 읽기를 보여 주었지만 실제 지속 시간은을 사용하여 즉시 숫자를 생성했을 때와 거의 동일 CTE
합니다.
WITH
e1(n) AS
(
SELECT 1 UNION ALL SELECT 1 UNION ALL SELECT 1 UNION ALL
SELECT 1 UNION ALL SELECT 1 UNION ALL SELECT 1 UNION ALL
SELECT 1 UNION ALL SELECT 1 UNION ALL SELECT 1 UNION ALL SELECT 1
) -- 10
,e2(n) AS (SELECT 1 FROM e1 CROSS JOIN e1 AS b) -- 10*10
,e3(n) AS (SELECT 1 FROM e1 CROSS JOIN e2) -- 10*100
,CTE_Numbers
AS
(
SELECT ROW_NUMBER() OVER (ORDER BY n) AS Number
FROM e3
)
,CTE_DailyCosts
AS
(
SELECT
TH.ProductID
,TH.TransactionDate
,SUM(ActualCost) AS DailyActualCost
,ISNULL(DATEDIFF(day,
TH.TransactionDate,
LEAD(TH.TransactionDate)
OVER(PARTITION BY TH.ProductID ORDER BY TH.TransactionDate)), 1) AS DiffDays
FROM [Production].[TransactionHistory] AS TH
GROUP BY TH.ProductID, TH.TransactionDate
)
,CTE_NoGaps
AS
(
SELECT
CTE_DailyCosts.ProductID
,CTE_DailyCosts.TransactionDate
,CASE WHEN CA.Number = 1
THEN CTE_DailyCosts.DailyActualCost
ELSE NULL END AS DailyCost
FROM
CTE_DailyCosts
CROSS APPLY
(
SELECT TOP(CTE_DailyCosts.DiffDays) CTE_Numbers.Number
FROM CTE_Numbers
ORDER BY CTE_Numbers.Number
) AS CA
)
,CTE_Sum
AS
(
SELECT
ProductID
,TransactionDate
,DailyCost
,SUM(DailyCost) OVER (
PARTITION BY ProductID
ORDER BY TransactionDate
ROWS BETWEEN 45 PRECEDING AND CURRENT ROW) AS RollingSum45
FROM CTE_NoGaps
)
SELECT
ProductID
,TransactionDate
,DailyCost
,RollingSum45
FROM CTE_Sum
WHERE DailyCost IS NOT NULL
ORDER BY
ProductID
,TransactionDate
;
쿼리는 두 개의 창 함수 ( LEAD
및 SUM
)를 사용하기 때문에이 계획은 더 길다 .
RunningTotal.TBE IS NOT NULL
조건 (그리고, 결과적으로,TBE
열)는 불필요하다. 내부 조인 조건에 날짜 열이 포함되어 있으므로 중복 행을 삭제하면 중복 행이 표시되지 않으므로 결과 집합에 원래 소스에 없었던 날짜가있을 수 없습니다.