यह एक लंबा जवाब है, इसलिए मैंने यहां एक सारांश जोड़ने का फैसला किया।
- सबसे पहले मैं एक समाधान प्रस्तुत करता हूं जो प्रश्न में उसी क्रम में समान परिणाम उत्पन्न करता है। यह मुख्य तालिका को 3 बार स्कैन करता है:
ProductIDs
मूल पंक्तियों के साथ परिणाम में शामिल होने के लिए, प्रत्येक उत्पाद के लिए तारीखों की सीमा के साथ एक सूची प्राप्त करने के लिए, प्रत्येक दिन (क्योंकि समान तिथियों के साथ कई लेनदेन होते हैं)।
- आगे मैं दो दृष्टिकोणों की तुलना करता हूं जो कार्य को सरल बनाते हैं और मुख्य तालिका के एक अंतिम स्कैन से बचते हैं। उनका परिणाम एक दैनिक सारांश है, अर्थात यदि किसी उत्पाद पर कई लेन-देन एक ही तिथि में हैं तो उन्हें एकल पंक्ति में रोल किया गया है। पिछले चरण से मेरा दृष्टिकोण दो बार तालिका को स्कैन करता है। ज्योफ पैटरसन द्वारा दृष्टिकोण एक बार तालिका को स्कैन करता है, क्योंकि वह उत्पादों की तारीखों और सूची की सीमा के बारे में बाहरी ज्ञान का उपयोग करता है।
- अंत में मैं एक एकल पास समाधान प्रस्तुत करता हूं जो फिर से एक दैनिक सारांश देता है, लेकिन इसके लिए तारीखों की सूची या सूची के बारे में बाहरी ज्ञान की आवश्यकता नहीं होती है
ProductIDs
।
मैं AdventureWorks2014 डेटाबेस और SQL सर्वर एक्सप्रेस 2014 का उपयोग करूंगा ।
मूल डेटाबेस में परिवर्तन:
- की बदली गई प्रकार
[Production].[TransactionHistory].[TransactionDate]
से 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 लेख में इटज़िक बेन-गण द्वारा विंडो कार्यों के बारे में एक उत्कृष्ट ब्लॉग पोस्ट का लिंक है । उस पोस्ट में वह बताता है कि कैसे OVER
काम करता है, बीच का अंतर ROWS
और RANGE
विकल्प और एक तिथि सीमा से अधिक रोलिंग राशि की गणना की इस बहुत समस्या का उल्लेख करता है। उन्होंने उल्लेख किया है कि SQL सर्वर का वर्तमान संस्करण 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
;
मैंने पुष्टि की कि यह प्रश्न उप-प्रश्न का उपयोग करने वाले प्रश्न के दृष्टिकोण के समान परिणाम उत्पन्न करता है।
निष्पादन की योजना
पहली क्वेरी सबक्विरी का उपयोग करती है, दूसरी - यह दृष्टिकोण। आप देख सकते हैं कि इस दृष्टिकोण में अवधि और पढ़ने की संख्या बहुत कम है। इस दृष्टिकोण में अनुमानित लागत का अधिकांश अंतिम है 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 आँकड़े हैं।
टू-स्कैन वेरिएंट थोड़ा तेज है और इसमें बहुत कम रीड हैं, क्योंकि वन-स्कैन वेरिएंट में वर्कटेब का काफी उपयोग करना है। इसके अलावा, एक-स्कैन वैरिएंट जरूरत से ज्यादा पंक्तियों को उत्पन्न करता है जैसा कि आप योजनाओं में देख सकते हैं। यह तालिका ProductID
में शामिल प्रत्येक के लिए तिथियां उत्पन्न करता है Product
, भले ही ProductID
उसका कोई लेनदेन न हो। Product
तालिका में 504 पंक्तियाँ हैं , लेकिन केवल 441 उत्पादों में लेनदेन होता है TransactionHistory
। इसके अलावा, यह प्रत्येक उत्पाद के लिए समान तारीखें बनाता है, जो आवश्यकता से अधिक है। यदि TransactionHistory
एक लंबा समग्र इतिहास होता, तो प्रत्येक व्यक्ति के उत्पाद में अपेक्षाकृत कम इतिहास होता, अतिरिक्त अनावश्यक पंक्तियों की संख्या और भी अधिक होती।
दूसरी ओर, बस एक और, अधिक संकीर्ण सूचकांक बनाकर दो-स्कैन संस्करण को थोड़ा और अनुकूलित करना संभव है (ProductID, TransactionDate)
। इस सूचकांक का उपयोग प्रत्येक उत्पाद के लिए प्रारंभ / समाप्ति तिथियों की गणना करने के लिए किया जाएगा ( CTE_Products
और इसमें अनुक्रमणिका को कवर करने की तुलना में कम पृष्ठ होंगे और इसके परिणामस्वरूप परिणाम पढ़ता है।
तो, हम चुन सकते हैं, या तो एक अतिरिक्त स्पष्ट सरल स्कैन है, या एक अंतर्निहित कार्य करने योग्य है।
BTW, यदि केवल दैनिक सारांश के साथ परिणाम देना ठीक है, तो एक इंडेक्स बनाना बेहतर होता है जिसमें शामिल नहीं है ReferenceOrderID
। यह कम पृष्ठों => कम IO का उपयोग करेगा।
CREATE NONCLUSTERED INDEX [i2] ON [Production].[TransactionHistory]
(
[ProductID] ASC,
[TransactionDate] ASC
)
INCLUDE ([ActualCost])
CROSS APPLY का उपयोग करके एकल पास समाधान
यह वास्तव में एक लंबा जवाब हो जाता है, लेकिन यहां एक और संस्करण है जो केवल दैनिक सारांश को फिर से लौटाता है, लेकिन यह डेटा का केवल एक स्कैन करता है और इसके लिए किसी तिथि या उत्पाद की सूची की बाहरी जानकारी की आवश्यकता नहीं होती है। यह मध्यवर्ती सॉर्ट भी नहीं करता है। कुल मिलाकर प्रदर्शन पिछले वेरिएंट की तरह ही है, हालांकि यह थोड़ा खराब लगता है।
मुख्य विचार पंक्तियों को उत्पन्न करने के लिए संख्याओं की तालिका का उपयोग करना है जो तारीखों में अंतराल को भर देगा। प्रत्येक मौजूदा तिथि के लिए 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
स्तंभ) अनावश्यक है। यदि आप इसे छोड़ देते हैं तो आप निरर्थक पंक्तियाँ प्राप्त नहीं करने वाले हैं, क्योंकि आपकी आंतरिक जुड़ने की स्थिति में दिनांक स्तंभ शामिल है - इसलिए परिणाम सेट में वे दिनांक नहीं हो सकते हैं जो मूल रूप से स्रोत में नहीं थे।