นี่เป็นคำตอบที่ยาวดังนั้นฉันตัดสินใจที่จะเพิ่มบทสรุปที่นี่
- ตอนแรกฉันนำเสนอวิธีแก้ปัญหาที่ให้ผลลัพธ์ที่เหมือนกันในลำดับเดียวกันกับคำถาม มันสแกนตารางหลัก 3 ครั้ง: เพื่อรับรายการที่
ProductIDs
มีช่วงวันที่สำหรับแต่ละผลิตภัณฑ์เพื่อสรุปค่าใช้จ่ายในแต่ละวัน (เนื่องจากมีหลายธุรกรรมที่มีวันที่เดียวกัน) เพื่อเข้าร่วมกับแถวเดิม
- ต่อไปฉันเปรียบเทียบสองวิธีที่ทำให้งานง่ายขึ้นและหลีกเลี่ยงการสแกนครั้งสุดท้ายของตารางหลัก ผลลัพธ์ของพวกเขาคือสรุปรายวันคือถ้ามีหลายธุรกรรมในผลิตภัณฑ์มีวันที่เหมือนกันพวกเขาจะถูกรีดเป็นแถวเดียว แนวทางของฉันจากขั้นตอนก่อนหน้าสแกนตารางสองครั้ง วิธีการโดย Geoff Patterson สแกนตารางหนึ่งครั้งเนื่องจากเขาใช้ความรู้ภายนอกเกี่ยวกับช่วงวันที่และรายการผลิตภัณฑ์
- ที่สุดท้ายที่ผมนำเสนอวิธีการแก้ปัญหาผ่านเดียวอีกครั้งว่าผลตอบแทนที่ได้สรุปทุกวัน
ProductIDs
แต่ก็ไม่จำเป็นต้องมีความรู้เกี่ยวกับภายนอกช่วงของวันที่หรือรายการ
ฉันจะใช้ฐานข้อมูลAdventureWorks2014และ SQL Server Express 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);
บทความ MSDN เกี่ยวกับOVER
clause มีลิงค์ไปยังบล็อกโพสต์ที่ยอดเยี่ยมเกี่ยวกับฟังก์ชั่นหน้าต่างโดย 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
;
ฉันยืนยันว่าแบบสอบถามนี้สร้างผลลัพธ์เช่นเดียวกับแนวทางจากคำถามที่ใช้แบบสอบถามย่อย
แผนการดำเนินการ
แบบสอบถามแรกใช้แบบสอบถามย่อยสอง - วิธีนี้ คุณจะเห็นว่าระยะเวลาและจำนวนการอ่านมีน้อยกว่าในแนวทางนี้ ค่าใช้จ่ายส่วนใหญ่โดยประมาณในวิธีการนี้เป็นขั้นตอนสุดท้าย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
จะไม่มีธุรกรรมใด ๆ มี 504 แถวในมีProduct
โต๊ะ แต่เพียง 441 TransactionHistory
ผลิตภัณฑ์ในการทำธุรกรรมได้ นอกจากนี้มันยังสร้างช่วงวันที่เดียวกันสำหรับแต่ละผลิตภัณฑ์ซึ่งเกินความจำเป็น หากTransactionHistory
มีประวัติโดยรวมที่ยาวนานขึ้นโดยแต่ละผลิตภัณฑ์มีประวัติค่อนข้างสั้นจำนวนแถวที่ไม่จำเป็นเพิ่มขึ้นจะยิ่งสูงขึ้น
ในทางตรงกันข้ามมันเป็นไปได้ที่จะเพิ่มประสิทธิภาพตัวแปรสแกนสองตัวเพิ่มขึ้นอีกเล็กน้อยโดยการสร้างดัชนีที่แคบ(ProductID, TransactionDate)
กว่า ดัชนีนี้จะใช้ในการคำนวณวันที่เริ่มต้น / สิ้นสุดสำหรับแต่ละผลิตภัณฑ์ ( CTE_Products
) และมันจะมีหน้าน้อยกว่าที่ครอบคลุมดัชนีและเป็นผลทำให้อ่านน้อยลง
ดังนั้นเราสามารถเลือกได้ว่าจะสแกนอย่างชัดเจนเป็นพิเศษอย่างชัดเจนหรือมี Worktable โดยนัย
BTW ReferenceOrderID
ถ้ามันเป็นความตกลงที่จะมีผลให้มีการสรุปรายวันแล้วมันจะดีกว่าที่จะสร้างดัชนีที่ไม่รวม มันจะใช้หน้าน้อย = = IO น้อย
CREATE NONCLUSTERED INDEX [i2] ON [Production].[TransactionHistory]
(
[ProductID] ASC,
[TransactionDate] ASC
)
INCLUDE ([ActualCost])
ทางออกเดียวที่ใช้ CROSS ใช้เท่านั้น
มันกลายเป็นคำตอบที่ยาวมาก ๆ แต่นี่เป็นอีกหนึ่งตัวแปรที่คืนค่าสรุปรายวันอีกครั้ง แต่ทำการสแกนข้อมูลเพียงครั้งเดียวและไม่ต้องการความรู้ภายนอกเกี่ยวกับช่วงวันที่หรือรายการ 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
คอลัมน์) ที่ไม่จำเป็น คุณจะไม่ได้รับแถวที่ซ้ำซ้อนหากคุณวางไว้เนื่องจากเงื่อนไขการรวมภายในของคุณมีคอลัมน์วันที่ดังนั้นชุดผลลัพธ์ไม่สามารถมีวันที่ที่ไม่ได้อยู่ในแหล่งที่มา