Это длинный ответ, поэтому я решил добавить резюме здесь.
- Сначала я представляю решение, которое дает точно такой же результат в том же порядке, что и в вопросе. Он просматривает основную таблицу 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);
Оба запроса возвращают один и тот же результат в одинаковом порядке.
сравнение
Вот статистика времени и IO.
Вариант с двумя сканированиями немного быстрее и имеет меньше операций чтения, потому что вариант с одним сканированием должен часто использовать Worktable. Кроме того, вариант одного сканирования генерирует больше строк, чем необходимо, как вы можете видеть в планах. Он генерирует даты для каждого , ProductID
который находится в Product
таблице, даже если ProductID
не имеет каких - либо операций. В Product
таблице 504 строки , но только в 441 товарах есть транзакции TransactionHistory
. Кроме того, он генерирует одинаковый диапазон дат для каждого продукта, что больше, чем нужно. Если TransactionHistory
бы общая история была более длинной, а каждый отдельный продукт имел относительно короткую историю, число лишних ненужных строк было бы еще выше.
С другой стороны, можно оптимизировать вариант двухсканирования немного дальше, создав еще один, более узкий индекс для just (ProductID, TransactionDate)
. Этот индекс будет использоваться для расчета дат начала / окончания для каждого продукта ( CTE_Products
), и он будет иметь меньше страниц, чем охватывающий индекс, и в результате приведет к меньшему числу чтений.
Таким образом, мы можем выбрать либо дополнительное явное простое сканирование, либо неявный рабочий стол.
Кстати, если все в порядке, чтобы иметь результат только с ежедневными сводками, то лучше создать индекс, который не включает 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
столбец) не является необходимым. Вы не получите лишних строк, если отбросите их, потому что ваше внутреннее условие соединения включает столбец даты - поэтому в результирующем наборе не может быть дат, которых изначально не было в источнике.