Це довга відповідь, тому я вирішив додати тут резюме.
- Спочатку я представляю рішення, яке дає точно такий же результат у тому ж порядку, що і в питанні. 3 рази сканується основна таблиця: отримати список
ProductIDs
із діапазоном дат для кожного Товару, підбити підсумки за кожен день (оскільки є кілька транзакцій з однаковими датами), щоб об'єднати результат з оригінальними рядками.
- Далі я порівнюю два підходи, які спрощують завдання і уникають останнього сканування головної таблиці. Їх результатом є щоденне підсумок, тобто, якщо кілька транзакцій на Товарі мають одну і ту ж дату, вони об'єднуються в один ряд. Мій підхід з попереднього кроку сканує таблицю двічі. Підхід Джеффа Паттерсона один раз сканує таблицю, оскільки він використовує зовнішні знання про діапазон дат та список продуктів.
- Нарешті я представляю єдине рішення, яке знову повертає щоденний підсумок, але воно не вимагає зовнішніх знань про діапазон дат або список
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
пункт має посилання на чудову публікацію в блозі про функції вікон Іціка Бен-Гана. У цій публікації він пояснює, як 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);
Обидва запити повертають однаковий результат в одному порядку.
Порівняння
Ось час і ІО статистика.
Варіант двох сканувань трохи швидший і має меншу кількість читань, оскільки для одного сканування варіант повинен багато використовувати Worktable. Крім того, варіант з одним скануванням генерує більше рядків, ніж потрібно, як ви бачите в планах. Він генерує дати для кожної 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
Це стає дійсно довгою відповіддю, але ось ще один варіант, який повертає лише щоденний підсумок знову, але він робить лише одне сканування даних і не вимагає зовнішніх знань про діапазон дат або список 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
стовпець) НЕ є необхідним. Якщо ви скинете його, ви не збираєтеся отримувати зайві рядки, тому що ваш внутрішній стан з'єднання включає стовпчик дати - тому набір результатів не може мати дат, які не були спочатку в джерелі.