Đây là một câu trả lời dài, vì vậy tôi quyết định thêm một bản tóm tắt ở đây.
- Đầu tiên tôi trình bày một giải pháp tạo ra kết quả chính xác theo cùng một thứ tự như trong câu hỏi. Nó quét bảng chính 3 lần: để có danh sách
ProductIDs
với phạm vi ngày cho mỗi Sản phẩm, để tổng hợp chi phí cho mỗi ngày (vì có một số giao dịch có cùng ngày), để kết quả với các hàng ban đầu.
- Tiếp theo tôi so sánh hai cách tiếp cận đơn giản hóa nhiệm vụ và tránh một lần quét cuối cùng của bảng chính. Kết quả của họ là một bản tóm tắt hàng ngày, tức là nếu một số giao dịch trên Sản phẩm có cùng ngày họ được cuộn thành một hàng. Cách tiếp cận của tôi từ bước trước quét bảng hai lần. Cách tiếp cận của Geoff Patterson quét bảng một lần, bởi vì anh ta sử dụng kiến thức bên ngoài về phạm vi ngày và danh sách Sản phẩm.
- Cuối cùng tôi trình bày một giải pháp vượt qua duy nhất một lần nữa trả về một bản tóm tắt hàng ngày, nhưng nó không đòi hỏi kiến thức bên ngoài về phạm vi ngày hoặc danh sách
ProductIDs
.
Tôi sẽ sử dụng cơ sở dữ liệu AdventureWorks2014 và SQL Server Express 2014.
Thay đổi cơ sở dữ liệu gốc:
- Thay đổi loại
[Production].[TransactionHistory].[TransactionDate]
từ datetime
đến date
. Thành phần thời gian bằng không.
- Đã thêm bảng lịch
[dbo].[Calendar]
- Đã thêm chỉ mục vào
[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);
Bài viết về OVER
mệnh đề MSDN có một liên kết đến một bài đăng blog tuyệt vời về các chức năng cửa sổ của Itzik Ben-Gan. Trong bài đăng đó, ông giải thích cách thức OVER
hoạt động, sự khác biệt giữa ROWS
và RANGE
các tùy chọn và đề cập đến chính vấn đề này trong việc tính toán tổng số trong một phạm vi ngày. Ông đề cập rằng phiên bản hiện tại của SQL Server không triển khai RANGE
đầy đủ và không triển khai các kiểu dữ liệu khoảng thời gian. Giải thích của anh ấy về sự khác biệt giữa ROWS
và RANGE
cho tôi một ý tưởng.
Ngày không có khoảng cách và trùng lặp
Nếu TransactionHistory
bảng chứa ngày không có khoảng trống và không trùng lặp, thì truy vấn sau đây sẽ cho kết quả chính xác:
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;
Thật vậy, một cửa sổ 45 hàng sẽ bao gồm chính xác 45 ngày.
Ngày có khoảng trống không trùng lặp
Thật không may, dữ liệu của chúng tôi có khoảng cách về ngày. Để giải quyết vấn đề này, chúng ta có thể sử dụng một Calendar
bảng để tạo ra một tập hợp các ngày không có khoảng trống, sau đó LEFT JOIN
dữ liệu gốc cho tập hợp này và sử dụng cùng một truy vấn với ROWS BETWEEN 45 PRECEDING AND CURRENT ROW
. Điều này sẽ tạo ra kết quả chính xác chỉ khi ngày không lặp lại (trong cùng một ProductID
).
Ngày có khoảng trống với trùng lặp
Thật không may, dữ liệu của chúng tôi có cả khoảng cách về ngày và ngày có thể lặp lại trong cùng một ProductID
. Để giải quyết vấn đề này, chúng ta có thể tạo GROUP
dữ liệu gốc bằng cách ProductID, TransactionDate
tạo một tập hợp ngày mà không trùng lặp. Sau đó sử dụng Calendar
bảng để tạo một tập hợp các ngày không có khoảng trống. Sau đó chúng ta có thể sử dụng truy vấn với ROWS BETWEEN 45 PRECEDING AND CURRENT ROW
để tính toán cán SUM
. Điều này sẽ tạo ra kết quả chính xác. Xem bình luận trong truy vấn dưới đây.
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
;
Tôi xác nhận rằng truy vấn này tạo ra kết quả tương tự như cách tiếp cận từ câu hỏi sử dụng truy vấn con.
Kế hoạch thực hiện
Truy vấn đầu tiên sử dụng truy vấn con, thứ hai - phương pháp này. Bạn có thể thấy rằng thời lượng và số lần đọc ít hơn nhiều trong phương pháp này. Phần lớn chi phí ước tính trong phương pháp này là cuối cùng ORDER BY
, xem bên dưới.
Phương pháp truy vấn con có một kế hoạch đơn giản với các vòng lặp lồng nhau và O(n*n)
độ phức tạp.
Kế hoạch cho phương pháp này quét TransactionHistory
nhiều lần, nhưng không có vòng lặp. Như bạn có thể thấy hơn 70% chi phí ước tính là Sort
cuối cùng ORDER BY
.
Kết quả hàng đầu - subquery
, dưới cùng - OVER
.
Tránh quét thêm
Quét chỉ mục cuối cùng, Hợp nhất Tham gia và Sắp xếp trong kế hoạch ở trên là do trận chung kết INNER JOIN
với bảng ban đầu để tạo ra kết quả cuối cùng giống hệt như cách tiếp cận chậm với truy vấn phụ. Số lượng hàng trả về giống như trong TransactionHistory
bảng. Có hàng trong TransactionHistory
khi một số giao dịch xảy ra trong cùng một ngày cho cùng một sản phẩm. Nếu kết quả chỉ hiển thị tóm tắt hàng ngày trong kết quả, thì kết quả cuối cùng này JOIN
có thể được loại bỏ và truy vấn trở nên đơn giản hơn và nhanh hơn một chút. Quét chỉ mục cuối cùng, Hợp nhất tham gia và sắp xếp từ gói trước được thay thế bằng Bộ lọc, loại bỏ các hàng được thêm bởi 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
;
Tuy nhiên, TransactionHistory
được quét hai lần. Cần thêm một lần quét để có được phạm vi ngày cho mỗi sản phẩm. Tôi đã quan tâm để xem làm thế nào nó so sánh với một cách tiếp cận khác, nơi chúng tôi sử dụng kiến thức bên ngoài về phạm vi ngày toàn cầu TransactionHistory
, cộng với bảng bổ sung Product
có tất cả ProductIDs
để tránh việc quét thêm đó. Tôi đã loại bỏ tính toán số lượng giao dịch mỗi ngày khỏi truy vấn này để so sánh hợp lệ. Nó có thể được thêm vào trong cả hai truy vấn, nhưng tôi muốn giữ nó đơn giản để so sánh. Tôi cũng phải sử dụng các ngày khác, vì tôi sử dụng phiên bản 2014 của cơ sở dữ liệu.
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);
Cả hai truy vấn trả về cùng một kết quả theo cùng một thứ tự.
So sánh
Dưới đây là số liệu thống kê thời gian và IO.
Biến thể hai lần quét nhanh hơn một chút và có ít lần đọc hơn, vì biến thể một lần quét phải sử dụng Worktable rất nhiều. Ngoài ra, biến thể quét một lần tạo ra nhiều hàng hơn mức cần thiết như bạn có thể thấy trong các kế hoạch. Nó tạo ngày cho mỗi ProductID
cái trong Product
bảng, ngay cả khi ProductID
không có bất kỳ giao dịch nào. Có 504 hàng trong Product
bảng, nhưng chỉ có 441 sản phẩm có giao dịch TransactionHistory
. Ngoài ra, nó tạo ra cùng một phạm vi ngày cho mỗi sản phẩm, nhiều hơn mức cần thiết. Nếu TransactionHistory
có lịch sử tổng thể dài hơn, với mỗi sản phẩm riêng lẻ có lịch sử tương đối ngắn, số lượng hàng không cần thiết thêm sẽ còn cao hơn.
Mặt khác, có thể tối ưu hóa biến thể hai lần quét hơn một chút bằng cách tạo một chỉ mục khác, hẹp hơn trên chỉ (ProductID, TransactionDate)
. Chỉ mục này sẽ được sử dụng để tính ngày bắt đầu / ngày kết thúc cho mỗi sản phẩm ( CTE_Products
) và nó sẽ có ít trang hơn so với chỉ số che phủ và kết quả là gây ra ít đọc hơn.
Vì vậy, chúng ta có thể chọn, hoặc có thêm một lần quét đơn giản rõ ràng hoặc có một Worktable ẩn.
BTW, nếu có kết quả chỉ với các bản tóm tắt hàng ngày thì tốt hơn là tạo một chỉ mục không bao gồm ReferenceOrderID
. Nó sẽ sử dụng ít trang hơn => ít IO hơn.
CREATE NONCLUSTERED INDEX [i2] ON [Production].[TransactionHistory]
(
[ProductID] ASC,
[TransactionDate] ASC
)
INCLUDE ([ActualCost])
Giải pháp vượt qua đơn sử dụng CROSS ỨNG DỤNG
Nó trở thành một câu trả lời thực sự dài, nhưng đây là một biến thể nữa chỉ trả về tóm tắt hàng ngày một lần nữa, nhưng nó chỉ quét một lần dữ liệu và nó không yêu cầu kiến thức bên ngoài về phạm vi ngày hoặc danh sách ProductID. Nó cũng không thực hiện Sắp xếp trung gian. Hiệu suất tổng thể tương tự như các biến thể trước đó, mặc dù có vẻ tệ hơn một chút.
Ý tưởng chính là sử dụng một bảng số để tạo các hàng sẽ lấp đầy các khoảng trống theo ngày. Đối với mỗi ngày hiện tại, hãy sử dụng LEAD
để tính kích thước của khoảng cách tính theo ngày và sau đó sử dụng CROSS APPLY
để thêm số lượng hàng cần thiết vào tập kết quả. Lúc đầu, tôi đã thử nó với một bảng số vĩnh viễn. Kế hoạch cho thấy số lượng đọc lớn trong bảng này, mặc dù thời lượng thực tế khá giống nhau, như khi tôi tạo số khi đang sử dụng 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
;
Gói này "dài hơn", vì truy vấn sử dụng hai hàm cửa sổ ( LEAD
và SUM
).
RunningTotal.TBE IS NOT NULL
kiện (và, do đó,TBE
cột) là không cần thiết. Bạn sẽ không nhận được các hàng thừa nếu bạn bỏ nó, bởi vì điều kiện nối bên trong của bạn bao gồm cột ngày - do đó, tập kết quả không thể có ngày ban đầu không có trong nguồn.