Bu uzun bir cevap, bu yüzden buraya bir özet eklemeye karar verdim.
- İlk başta, tam olarak aynı sonucu, söz konusu sırayla aynı sırada üreten bir çözüm sunuyorum. Ana tabloyu 3 kez tarar:
ProductIDs
her bir Ürünün tarih aralığını içeren bir liste almak , her bir gün için maliyetleri toplar (çünkü aynı tarihte birkaç işlem olduğundan), orijinal satırlarla sonuca katılır.
- Daha sonra, görevi basitleştiren ve ana tablonun son bir taramasını engelleyen iki yaklaşımı karşılaştırdım. Elde ettiği sonuç günlük bir özettir, yani bir Üründeki birçok işlem aynı tarihe sahipse, tek sıra halinde yuvarlanır. Önceki adımdaki yaklaşımım masayı iki kez tarar. Geoff Patterson'ın yaklaşımı masayı bir kez tarar, çünkü tarih aralığı ve Ürün listesi hakkında dış bilgi kullanır.
- Sonunda yine günlük bir özeti döndüren tek bir geçiş çözümü sunuyorum, ancak tarih aralığı veya listesi hakkında dış bilgi gerektirmiyor
ProductIDs
.
AdventureWorks2014 veritabanı ve SQL Server Express 2014 kullanacağım .
Orijinal veritabanında yapılan değişiklikler:
- Değiştirilen tip
[Production].[TransactionHistory].[TransactionDate]
gelen datetime
etmek date
. Zaman bileşeni zaten sıfırdı.
- Takvim tablosu eklendi
[dbo].[Calendar]
- Dizin eklendi
[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);
Fıkra hakkında MSDN makalesinde Itzik Ben-Gan'ın pencere işlevleri hakkında mükemmel bir blog yazısıOVER
bağlantısı var . Bu yazıda nasıl çalıştığını, seçenekleri ve seçenekleri arasındaki farkı açıklar ve bir tarih aralığında bir yuvarlama toplamı hesaplamakta bu çok sorundan bahseder. Geçerli SQL Server sürümünün tam olarak uygulanmadığını ve geçici aralık veri türlerini uygulamadığını söyler . Aradaki farkı açıkladı ve bana bir fikir verdi.OVER
ROWS
RANGE
RANGE
ROWS
RANGE
Boşluklar ve kopyalar olmadan tarihler
TransactionHistory
Tabloda boşluklar olmadan ve kopyalar olmadan tarihler varsa , aşağıdaki sorgu doğru sonuçlar verir:
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;
Aslında, 45 satırlık bir pencere tam olarak 45 günü kapsayacaktır.
Kopyaları olmayan boşluklu tarihler
Ne yazık ki, verilerimizin tarihler arasında boşluklar var. Bu sorunu çözmek için Calendar
, boşluksuz bir tarihler kümesi oluşturmak için bir tablo kullanabiliriz , daha sonra LEFT JOIN
bu set için orijinal verileri kullanabilir ve aynı sorguyu kullanabiliriz ROWS BETWEEN 45 PRECEDING AND CURRENT ROW
. Bu, yalnızca tarihler tekrarlanmazsa (aynı içinde ProductID
) doğru sonuçlar verir .
Çiftleri olan boşluklu tarihler
Ne yazık ki, verilerimiz tarihler arasında hem boşluklar vardır hem de tarihler aynı içinde tekrarlanabilir ProductID
. Bu sorunu çözmek için, kopyaları olmayan bir tarihler kümesi oluşturarak GROUP
orijinal veriler yapabiliriz ProductID, TransactionDate
. Ardından Calendar
boşluksuz bir tarihler kümesi oluşturmak için tabloyu kullanın. Daha sonra ROWS BETWEEN 45 PRECEDING AND CURRENT ROW
yuvarlamayı hesaplamak için sorguyu kullanabiliriz SUM
. Bu doğru sonuçlar üretecektir. Aşağıdaki sorguda yorumları görün.
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
;
Bu sorgunun, alt sorgu kullanan soruya yaklaşımıyla aynı sonuçları verdiğini onayladım.
İcra planları
İlk sorgu alt sorgu kullanır, ikincisi - bu yaklaşım. Bu yaklaşımda süre ve okuma sayısının çok daha az olduğunu görebilirsiniz. Bu yaklaşımda tahmini maliyetin çoğunluğu nihaidir ORDER BY
, aşağıya bakınız.
Alt sorgu yaklaşımı iç içe döngüler ve O(n*n)
karmaşıklıkla basit bir plana sahiptir .
Bu yaklaşım için plan TransactionHistory
birkaç kez tarar , ancak hiçbir döngü yoktur. Gördüğünüz gibi tahmini maliyetin% 70'inden fazlası Sort
final içindir ORDER BY
.
En iyi sonuç - subquery
, alt - OVER
.
Ekstra taramalardan kaçınılması
Yukarıdaki plandaki son Dizin Taraması, Birleştir ve Sırala INNER JOIN
seçeneği, nihai sonucu alt sorgudaki yavaş bir yaklaşımla tamamen aynı yapmak için orijinal tablodaki finalden kaynaklanır. Dönen satırların sayısı TransactionHistory
tablodakilerle aynıdır . TransactionHistory
Aynı ürün için aynı gün birkaç işlem gerçekleştiğinde satırlar var . Sonuçta yalnızca günlük özeti göstermek uygunsa, bu son JOIN
kaldırılabilir ve sorgu biraz daha basit ve biraz daha hızlı hale gelir. Son Dizin Tara, Birleştir ve Birleştir önceki plandaki Sıralama, tarafından eklenen satırları kaldıran Filtre ile değiştirilir 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
;
Yine de, TransactionHistory
iki kez taranır. Her ürünün tarih aralığını almak için bir ekstra tarama daha gereklidir. Küresel tarih aralıkları hakkında dış bilgileri kullandığımız başka bir yaklaşımla karşılaştırmayı ve bu fazladan taramayı önlemek için hepsine sahip olan TransactionHistory
ekstra bir tablo ile nasıl karşılaştığını görmek ilgimi çekti . Karşılaştırmayı geçerli kılmak için bu sorgudan günlük işlem sayısı hesaplamasını kaldırdım. Her iki sorguya da eklenebilir, ancak karşılaştırma için basit olmasını isterim. Başka tarihler de kullanmak zorunda kaldım, çünkü veritabanının 2014 sürümünü kullandım.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);
Her iki sorgu da aynı sonucu aynı sırayla döndürür.
karşılaştırma
İşte zaman ve IO istatistikleri.
İki tarama değişkeni biraz daha hızlıdır ve daha az okumaya sahiptir, çünkü bir tarama değişkeninin Worktable'ı çok kullanması gerekir. Ayrıca, tek tarama varyantı planlarda görebileceğiniz gibi gerekenden daha fazla satır oluşturur. Herhangi bir işlem yapmamış olsa bile ProductID
, Product
tablodaki her biri için tarih oluşturur ProductID
. Product
Tabloda 504 satır var , ancak sadece 441 üründe işlem gerçekleştiriliyor TransactionHistory
. Ayrıca, her ürün için gerekenden daha fazla olan aynı tarih aralığını oluşturur. Daha TransactionHistory
uzun bir genel geçmişe sahip olsaydı, her bir ürün nispeten kısa bir geçmişe sahipse , fazladan gereksiz sıra sayısı daha da artardı.
Öte yandan, sadece biraz daha dar bir endeks oluşturarak iki taramalı varyantı biraz daha optimize etmek mümkündür (ProductID, TransactionDate)
. Bu indeks, her bir ürün için Başlangıç / Bitiş tarihlerini hesaplamak için kullanılacaktır ( CTE_Products
) ve endeksini kapsayandan daha az sayfa içerecek ve sonuç olarak daha az okumaya sebep olacaktır.
Öyleyse, ekstra basit bir tarama yapmayı ya da üstü açık bir Worktable'ı seçebiliriz.
BTW, yalnızca günlük özetlerle sonuç almak uygunsa, içermeyen bir dizin oluşturmak daha iyidir ReferenceOrderID
. Daha az sayfa kullanırdı => daha az G / Ç.
CREATE NONCLUSTERED INDEX [i2] ON [Production].[TransactionHistory]
(
[ProductID] ASC,
[TransactionDate] ASC
)
INCLUDE ([ActualCost])
CROSS APPLY kullanarak tek geçişli çözüm
Çok uzun bir cevap haline geldi, ancak işte yine sadece günlük özeti döndüren bir başka değişken daha var, ancak verilerin yalnızca bir taramasını yapıyor ve tarih aralığı veya ProductID'lerin listesi hakkında dış bilgi gerektirmiyor. Ara sıra yapmaz. Genel performans, önceki değişkenlere benzer, ancak biraz daha kötü gibi görünüyor.
Ana fikir, tarihlerdeki boşlukları dolduracak satırlar üretmek için bir sayılar tablosu kullanmaktır. Mevcut her tarih LEAD
için boşluğun boyutunu gün cinsinden hesaplamak için kullanın ve ardından CROSS APPLY
sonuç kümesine gereken sayıda satır eklemek için kullanın . İlk başta kalıcı bir sayılar tablosu ile denedim. Plan, bu tabloda çok sayıda okuma gösterdi, ancak gerçek süre, anında kullanarak sayılar oluşturduğumda olduğu gibi hemen hemen aynıydı 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
;
Bu plan "daha uzun" çünkü sorgu iki pencere işlevi kullanıyor ( LEAD
ve SUM
).
RunningTotal.TBE IS NOT NULL
Durum (ve dolayısıyla,TBE
sütun) gereksizdir. Düşürürseniz gereksiz satırlar almayacaksınız, çünkü iç birleşim koşulu tarih sütununu içerir - bu nedenle sonuç kümesinin başlangıçta kaynakta bulunmayan tarihleri olamaz.