Скользящая сумма диапазона дат с использованием оконных функций

57

Мне нужно рассчитать скользящую сумму по диапазону дат. Чтобы проиллюстрировать это, используя пример базы данных AdventureWorks , следующий гипотетический синтаксис сделал бы именно то, что мне нужно:

SELECT
    TH.ProductID,
    TH.TransactionDate,
    TH.ActualCost,
    RollingSum45 = SUM(TH.ActualCost) OVER (
        PARTITION BY TH.ProductID
        ORDER BY TH.TransactionDate
        RANGE BETWEEN 
            INTERVAL 45 DAY PRECEDING
            AND CURRENT ROW)
FROM Production.TransactionHistory AS TH
ORDER BY
    TH.ProductID,
    TH.TransactionDate,
    TH.ReferenceOrderID;

К сожалению, RANGEэкстент фрейма окна в настоящее время не допускает интервал в SQL Server.

Я знаю, что могу написать решение, используя подзапрос и обычный (не оконный) агрегат:

SELECT 
    TH.ProductID,
    TH.TransactionDate,
    TH.ActualCost,
    RollingSum45 =
    (
        SELECT SUM(TH2.ActualCost)
        FROM Production.TransactionHistory AS TH2
        WHERE
            TH2.ProductID = TH.ProductID
            AND TH2.TransactionDate <= TH.TransactionDate
            AND TH2.TransactionDate >= DATEADD(DAY, -45, TH.TransactionDate)
    )
FROM Production.TransactionHistory AS TH
ORDER BY
    TH.ProductID,
    TH.TransactionDate,
    TH.ReferenceOrderID;

Учитывая следующий индекс:

CREATE UNIQUE INDEX i
ON Production.TransactionHistory
    (ProductID, TransactionDate, ReferenceOrderID)
INCLUDE
    (ActualCost);

План выполнения:

План выполнения

Хотя это и не совсем неэффективно, похоже, что этот запрос можно выразить, используя только агрегат окон и аналитические функции, поддерживаемые в SQL Server 2012, 2014 или 2016 (пока).

Для ясности я ищу решение, которое выполняет один проход по данным.

В T-SQL , это может означать , что пункт будет делать работу, и план выполнения будет отличать Window Катушки и Window агрегаты. Все языковые элементы, которые используют это предложение, являются честной игрой. Решение SQLCLR является приемлемым, при условии, что оно гарантирует получение правильных результатов.OVEROVER

Для решений T-SQL, чем меньше в плане выполнения хэшей, сортировок и оконных катушек / агрегатов, тем лучше. Не стесняйтесь добавлять индексы, но отдельные структуры не допускаются (поэтому, например, предварительно вычисленные таблицы не синхронизируются с триггерами). Допускаются справочные таблицы (таблицы чисел, дат и т. Д.)

В идеале решения будут давать точно такие же результаты в том же порядке, что и версия подзапроса, приведенная выше, но все, что возможно правильно, также допустимо. Производительность всегда учитывается, поэтому решения должны быть как минимум достаточно эффективными.

Выделенный чат: я создал общедоступный чат для обсуждения этого вопроса и его ответов. Принять участие может любой пользователь, имеющий не менее 20 очков репутации . Пожалуйста, напишите мне в комментарии ниже, если у вас менее 20 представителей и вы хотели бы принять участие.

Пол Уайт
источник

Ответы:

42

Отличный вопрос, Пол! Я использовал несколько разных подходов, один в T-SQL и один в CLR.

Краткое описание T-SQL

Подход T-SQL можно обобщить в виде следующих шагов:

  • Возьмите перекрестный продукт продуктов / даты
  • Слияние наблюдаемых данных о продажах
  • Объединить эти данные с уровнем продукта / даты
  • Рассчитать скользящие суммы за последние 45 дней на основе этих агрегированных данных (которые содержат заполненные «пропущенные» дни)
  • Отфильтруйте эти результаты только по тем парам продукт / дата, у которых был один или несколько продаж

Используя SET STATISTICS IO ON, этот подход сообщает Table 'TransactionHistory'. Scan count 1, logical reads 484, что подтверждает «один проход» по таблице. Для справки, оригинальные отчеты о поиске цикла Table 'TransactionHistory'. Scan count 113444, logical reads 438366.

Как сообщается SET STATISTICS TIME ON, время процессора составляет 514ms. Это выгодно отличается 2231msот оригинального запроса.

CLR краткое резюме

Сводка CLR может быть сведена к следующим этапам:

  • Считайте данные в память, упорядоченные по продукту и дате
  • При обработке каждой транзакции добавьте к промежуточной итоговой стоимости. Всякий раз, когда транзакция отличается от предыдущей транзакции, сбросьте промежуточную сумму на 0.
  • Поддерживать указатель на первую транзакцию, которая имеет тот же (продукт, дата), что и текущая транзакция. Всякий раз, когда встречается последняя транзакция с этим (продукт, дата), вычислите скользящую сумму для этой транзакции и примените ее ко всем транзакциям с таким же (продукт, дата)
  • Верните все результаты пользователю!

При использовании SET STATISTICS IO ONэтого подхода сообщается, что логического ввода-вывода не произошло! Вау, идеальное решение! (На самом деле, кажется, что SET STATISTICS IOон не сообщает о операциях ввода-вывода, произошедших в CLR. Но из кода легко увидеть, что выполняется ровно одно сканирование таблицы, и данные извлекаются в порядке по индексу, предложенному Полом.

Как сообщается SET STATISTICS TIME ON, время процессора сейчас 187ms. Так что это значительное улучшение по сравнению с подходом T-SQL. К сожалению, общее истекшее время обоих подходов очень схоже и составляет примерно полсекунды каждый. Однако подход, основанный на CLR, должен выводить на консоль 113K строк (вместо 52K для подхода T-SQL, который группирует по продукту / дате), поэтому я сосредоточился вместо этого на времени процессора.

Другим большим преимуществом этого подхода является то, что он дает те же результаты, что и исходный подход цикла / поиска, включая строку для каждой транзакции даже в тех случаях, когда продукт продается несколько раз в один и тот же день. (На AdventureWorks я специально сравнил построчные результаты и подтвердил, что они соответствуют первоначальному запросу Пола.)

Недостаток этого подхода, по крайней мере в его нынешнем виде, заключается в том, что он читает все данные в памяти. Тем не менее, алгоритм, который был разработан только строго требует текущего окна в памяти в любой момент времени и может быть обновлен для работы с наборами данных, которые превышают память. Пол проиллюстрировал это в своем ответе, представив реализацию этого алгоритма, которая хранит в памяти только скользящее окно. Это происходит за счет предоставления более высоких разрешений для сборки CLR, но определенно стоило бы расширить это решение до произвольно больших наборов данных.


T-SQL - один скан, сгруппированный по дате

Начальная настройка

USE AdventureWorks2012
GO
-- Create Paul's index
CREATE UNIQUE INDEX i
ON Production.TransactionHistory (ProductID, TransactionDate, ReferenceOrderID)
INCLUDE (ActualCost);
GO
-- Build calendar table for 2000 ~ 2020
CREATE TABLE dbo.calendar (d DATETIME NOT NULL CONSTRAINT PK_calendar PRIMARY KEY)
GO
DECLARE @d DATETIME = '1/1/2000'
WHILE (@d < '1/1/2021')
BEGIN
    INSERT INTO dbo.calendar (d) VALUES (@d)
    SELECT @d =  DATEADD(DAY, 1, @d)
END
GO

Запрос

DECLARE @minAnalysisDate DATE = '2007-09-01', -- Customizable start date depending on business needs
        @maxAnalysisDate DATE = '2008-09-03'  -- Customizable end date depending on business needs
SELECT 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.d AS TransactionDate,
            COUNT(TH.ProductId) AS NumOrders, SUM(TH.ActualCost) AS ActualCost
        FROM Production.Product p
        JOIN dbo.calendar c
            ON c.d BETWEEN @minAnalysisDate AND @maxAnalysisDate
        LEFT OUTER JOIN Production.TransactionHistory TH
            ON TH.ProductId = p.productId
            AND TH.TransactionDate = c.d
        GROUP BY P.ProductID, c.d
    ) aggsByDay
) rollingSums
WHERE NumOrders > 0
ORDER BY ProductID, TransactionDate
-- MAXDOP 1 to avoid parallel scan inflating the scan count
OPTION (MAXDOP 1)

План выполнения

Из плана выполнения мы видим, что исходного индекса, предложенного Полом, достаточно, чтобы позволить нам выполнить одно заказное сканирование Production.TransactionHistory, используя объединение слиянием, чтобы объединить историю транзакций с каждой возможной комбинацией продукта / даты.

введите описание изображения здесь

Предположения

Есть несколько существенных допущений в этом подходе. Я полагаю, что именно Пол решит, приемлемы ли они :)

  • Я использую Production.Productтаблицу. Эта таблица находится в свободном доступе, AdventureWorks2012и связь обеспечивается внешним ключом от Production.TransactionHistory, поэтому я интерпретировал это как честную игру.
  • Этот подход основан на том факте, что транзакции не имеют временной компоненты AdventureWorks2012; если бы они это сделали, генерация полного набора комбинаций продукт / дата была бы невозможна без предварительного просмотра истории транзакций.
  • Я создаю набор строк, который содержит только одну строку на пару продукт / дата. Я думаю, что это «возможно правильно» и во многих случаях более желательный результат для возвращения. Для каждого продукта / даты я добавил NumOrdersстолбец, чтобы указать, сколько продаж произошло. На следующем снимке экрана показано сравнение результатов исходного запроса с предложенным в тех случаях, когда продукт был продан несколько раз в одну и ту же дату (например, 319/ 2007-09-05 00:00:00.000).

введите описание изображения здесь


CLR - одно сканирование, полный разгруппированный набор результатов

Основная функция тела

Там нет тонны, чтобы увидеть здесь; основная часть функции объявляет входные данные (которые должны соответствовать соответствующей функции SQL), устанавливает соединение SQL и открывает SQLReader.

// SQL CLR function for rolling SUMs on AdventureWorks2012.Production.TransactionHistory
[SqlFunction(DataAccess = DataAccessKind.Read,
    FillRowMethodName = "RollingSum_Fill",
    TableDefinition = "ProductId INT, TransactionDate DATETIME, ReferenceOrderID INT," +
                      "ActualCost FLOAT, PrevCumulativeSum FLOAT, RollingSum FLOAT")]
public static IEnumerable RollingSumTvf(SqlInt32 rollingPeriodDays) {
    using (var connection = new SqlConnection("context connection=true;")) {
        connection.Open();
        List<TrxnRollingSum> trxns;
        using (var cmd = connection.CreateCommand()) {
            //Read the transaction history (note: the order is important!)
            cmd.CommandText = @"SELECT ProductId, TransactionDate, ReferenceOrderID,
                                    CAST(ActualCost AS FLOAT) AS ActualCost 
                                FROM Production.TransactionHistory 
                                ORDER BY ProductId, TransactionDate";
            using (var reader = cmd.ExecuteReader()) {
                trxns = ComputeRollingSums(reader, rollingPeriodDays.Value);
            }
        }

        return trxns;
    }
}

Основная логика

Я выделил основную логику, чтобы на ней было проще сосредоточиться:

// Given a SqlReader with transaction history data, computes / returns the rolling sums
private static List<TrxnRollingSum> ComputeRollingSums(SqlDataReader reader,
                                                        int rollingPeriodDays) {
    var startIndexOfRollingPeriod = 0;
    var rollingSumIndex = 0;
    var trxns = new List<TrxnRollingSum>();

    // Prior to the loop, initialize "next" to be the first transaction
    var nextTrxn = GetNextTrxn(reader, null);
    while (nextTrxn != null)
    {
        var currTrxn = nextTrxn;
        nextTrxn = GetNextTrxn(reader, currTrxn);
        trxns.Add(currTrxn);

        // If the next transaction is not the same product/date as the current
        // transaction, we can finalize the rolling sum for the current transaction
        // and all previous transactions for the same product/date
        var finalizeRollingSum = nextTrxn == null || (nextTrxn != null &&
                                (currTrxn.ProductId != nextTrxn.ProductId ||
                                currTrxn.TransactionDate != nextTrxn.TransactionDate));
        if (finalizeRollingSum)
        {
            // Advance the pointer to the first transaction (for the same product)
            // that occurs within the rolling period
            while (startIndexOfRollingPeriod < trxns.Count
                && trxns[startIndexOfRollingPeriod].TransactionDate <
                    currTrxn.TransactionDate.AddDays(-1 * rollingPeriodDays))
            {
                startIndexOfRollingPeriod++;
            }

            // Compute the rolling sum as the cumulative sum (for this product),
            // minus the cumulative sum for prior to the beginning of the rolling window
            var sumPriorToWindow = trxns[startIndexOfRollingPeriod].PrevSum;
            var rollingSum = currTrxn.ActualCost + currTrxn.PrevSum - sumPriorToWindow;
            // Fill in the rolling sum for all transactions sharing this product/date
            while (rollingSumIndex < trxns.Count)
            {
                trxns[rollingSumIndex++].RollingSum = rollingSum;
            }
        }

        // If this is the last transaction for this product, reset the rolling period
        if (nextTrxn != null && currTrxn.ProductId != nextTrxn.ProductId)
        {
            startIndexOfRollingPeriod = trxns.Count;
        }
    }

    return trxns;
}

Помощники

Следующая логика может быть написана внутри строки, но ее немного легче читать, когда они разделены на свои собственные методы.

private static TrxnRollingSum GetNextTrxn(SqlDataReader r, TrxnRollingSum currTrxn) {
    TrxnRollingSum nextTrxn = null;
    if (r.Read()) {
        nextTrxn = new TrxnRollingSum {
            ProductId = r.GetInt32(0),
            TransactionDate = r.GetDateTime(1),
            ReferenceOrderId = r.GetInt32(2),
            ActualCost = r.GetDouble(3),
            PrevSum = 0 };
        if (currTrxn != null) {
            nextTrxn.PrevSum = (nextTrxn.ProductId == currTrxn.ProductId)
                    ? currTrxn.PrevSum + currTrxn.ActualCost : 0;
        }
    }
    return nextTrxn;
}

// Represents the output to be returned
// Note that the ReferenceOrderId/PrevSum fields are for debugging only
private class TrxnRollingSum {
    public int ProductId { get; set; }
    public DateTime TransactionDate { get; set; }
    public int ReferenceOrderId { get; set; }
    public double ActualCost { get; set; }
    public double PrevSum { get; set; }
    public double RollingSum { get; set; }
}

// The function that generates the result data for each row
// (Such a function is mandatory for SQL CLR table-valued functions)
public static void RollingSum_Fill(object trxnWithRollingSumObj,
                                    out int productId,
                                    out DateTime transactionDate, 
                                    out int referenceOrderId, out double actualCost,
                                    out double prevCumulativeSum,
                                    out double rollingSum) {
    var trxn = (TrxnRollingSum)trxnWithRollingSumObj;
    productId = trxn.ProductId;
    transactionDate = trxn.TransactionDate;
    referenceOrderId = trxn.ReferenceOrderId;
    actualCost = trxn.ActualCost;
    prevCumulativeSum = trxn.PrevSum;
    rollingSum = trxn.RollingSum;
}

Связывая все это вместе в SQL

Все до этого момента было в C #, поэтому давайте посмотрим на фактический SQL. (В качестве альтернативы вы можете использовать этот сценарий развертывания для создания сборки непосредственно из кусочков моей сборки, а не для компиляции самостоятельно.)

USE AdventureWorks2012; /* GPATTERSON2\SQL2014DEVELOPER */
GO

-- Enable CLR
EXEC sp_configure 'clr enabled', 1;
GO
RECONFIGURE;
GO

-- Create the assembly based on the dll generated by compiling the CLR project
-- I've also included the "assembly bits" version that can be run without compiling
CREATE ASSEMBLY ClrPlayground
-- See http://pastebin.com/dfbv1w3z for a "from assembly bits" version
FROM 'C:\FullPathGoesHere\ClrPlayground\bin\Debug\ClrPlayground.dll'
WITH PERMISSION_SET = safe;
GO

--Create a function from the assembly
CREATE FUNCTION dbo.RollingSumTvf (@rollingPeriodDays INT)
RETURNS TABLE ( ProductId INT, TransactionDate DATETIME, ReferenceOrderID INT,
                ActualCost FLOAT, PrevCumulativeSum FLOAT, RollingSum FLOAT)
-- The function yields rows in order, so let SQL Server know to avoid an extra sort
ORDER (ProductID, TransactionDate, ReferenceOrderID)
AS EXTERNAL NAME ClrPlayground.UserDefinedFunctions.RollingSumTvf;
GO

-- Now we can actually use the TVF!
SELECT * 
FROM dbo.RollingSumTvf(45) 
ORDER BY ProductId, TransactionDate, ReferenceOrderId
GO

Предостережения

Подход CLR обеспечивает гораздо большую гибкость для оптимизации алгоритма, и он, вероятно, может быть настроен еще дальше экспертом в C #. Однако есть и недостатки в стратегии CLR. Несколько вещей, которые нужно иметь в виду:

  • Этот подход CLR сохраняет копию набора данных в памяти. Можно использовать потоковый подход, но я столкнулся с начальными трудностями и обнаружил, что существует остающаяся проблема Connect , жалующаяся на то, что изменения в SQL 2008+ затрудняют использование этого типа подхода. Это все еще возможно (как демонстрирует Пол), но требует более высокого уровня разрешений, устанавливая базу данных как TRUSTWORTHYи предоставляя EXTERNAL_ACCESSсборку CLR. Таким образом, есть некоторые хлопоты и потенциальные последствия для безопасности, но отдача - это потоковый подход, который может лучше масштабироваться до гораздо больших наборов данных, чем в AdventureWorks.
  • CLR может быть менее доступным для некоторых администраторов баз данных, что делает такую ​​функцию более «черным ящиком», который не так прозрачен, не так легко изменить, не так легко развернуть и, возможно, не так легко отладить. Это довольно большой недостаток по сравнению с подходом T-SQL.


Бонус: T-SQL # 2 - практический подход, который я бы на самом деле использовал

После некоторого творческого размышления над этой проблемой, я подумал, что я также опубликую довольно простой, практичный способ, которым я бы, вероятно, решил заняться этой проблемой, если она возникнет в моей повседневной работе. Он действительно использует функциональность окна SQL 2012+, но не таким революционным способом, на который надеялся вопрос:

-- Compute all running costs into a #temp table; Note that this query could simply read
-- from Production.TransactionHistory, but a CROSS APPLY by product allows the window 
-- function to be computed independently per product, supporting a parallel query plan
SELECT t.*
INTO #runningCosts
FROM Production.Product p
CROSS APPLY (
    SELECT t.ProductId, t.TransactionDate, t.ReferenceOrderId, t.ActualCost,
        -- Running sum of the cost for this product, including all ties on TransactionDate
        SUM(t.ActualCost) OVER (
            ORDER BY t.TransactionDate 
            RANGE UNBOUNDED PRECEDING) AS RunningCost
    FROM Production.TransactionHistory t
    WHERE t.ProductId = p.ProductId
) t
GO

-- Key the table in our output order
ALTER TABLE #runningCosts
ADD PRIMARY KEY (ProductId, TransactionDate, ReferenceOrderId)
GO

SELECT r.ProductId, r.TransactionDate, r.ReferenceOrderId, r.ActualCost,
    -- Cumulative running cost - running cost prior to the sliding window
    r.RunningCost - ISNULL(w.RunningCost,0) AS RollingSum45
FROM #runningCosts r
OUTER APPLY (
    -- For each transaction, find the running cost just before the sliding window begins
    SELECT TOP 1 b.RunningCost
    FROM #runningCosts b
    WHERE b.ProductId = r.ProductId
        AND b.TransactionDate < DATEADD(DAY, -45, r.TransactionDate)
    ORDER BY b.TransactionDate DESC
) w
ORDER BY r.ProductId, r.TransactionDate, r.ReferenceOrderId
GO

Это на самом деле дает довольно простой общий план запроса, даже если взглянуть на оба из двух соответствующих планов запроса вместе:

введите описание изображения здесь введите описание изображения здесь

Несколько причин, по которым мне нравится такой подход:

  • Он возвращает полный набор результатов, запрошенный в операторе задачи (в отличие от большинства других решений T-SQL, которые возвращают сгруппированную версию результатов).
  • Это легко объяснить, понять и отладить; Я не вернусь через год и не буду удивляться, как, черт возьми, я могу сделать небольшое изменение, не разрушая правильность или производительность
  • Он работает примерно 900msна предоставленном наборе данных, а не 2700msна исходном циклическом поиске
  • Если данные были намного плотнее (больше транзакций в день), вычислительная сложность не возрастает квадратично с количеством транзакций в скользящем окне (как это происходит для исходного запроса); Я думаю, что это решает проблему беспокойства Пола о том, что нужно избегать многократного сканирования
  • Это приводит к практически отсутствию ввода-вывода tempdb в последних обновлениях SQL 2012+ из-за новой функции отложенной записи в tempdb
  • Для очень больших наборов данных тривиально разделить работу на отдельные партии для каждого продукта, если нехватка памяти станет проблемой

Пара потенциальных предостережений:

  • Хотя технически он сканирует Production.TransactionHistory только один раз, на самом деле он не является подходом «одного сканирования», поскольку таблица #temp аналогичного размера и должна будет также выполнить дополнительный логический ввод-вывод для этой таблицы. Тем не менее, я не вижу в этом особой отличия от рабочей таблицы, над которой у нас больше ручного управления, поскольку мы определили ее точную структуру
  • В зависимости от вашей среды, использование базы данных tempdb может рассматриваться как положительное (например, оно находится на отдельном наборе SSD-дисков) или отрицательное (высокий уровень параллелизма на сервере, большое количество конфликтов tempdb уже)
Джефф Паттерсон
источник
25

Это длинный ответ, поэтому я решил добавить резюме здесь.

  • Сначала я представляю решение, которое дает точно такой же результат в том же порядке, что и в вопросе. Он просматривает основную таблицу 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.

stats2

io2

Вариант с двумя сканированиями немного быстрее и имеет меньше операций чтения, потому что вариант с одним сканированием должен часто использовать 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).

крест применить

ca статистика

ка я

Владимир Баранов
источник
23

Альтернативное решение SQLCLR, которое выполняется быстрее и требует меньше памяти:

Скрипт развертывания

Для этого требуется EXTERNAL_ACCESSнабор разрешений, поскольку он использует петлевое соединение с целевым сервером и базой данных вместо (медленного) контекстного соединения. Вот как вызвать функцию:

SELECT 
    RS.ProductID,
    RS.TransactionDate,
    RS.ActualCost,
    RS.RollingSum45
FROM dbo.RollingSum
(
    N'.\SQL2014',           -- Instance name
    N'AdventureWorks2012'   -- Database name
) AS RS 
ORDER BY
    RS.ProductID,
    RS.TransactionDate,
    RS.ReferenceOrderID;

Выдает точно такие же результаты, в том же порядке, что и вопрос.

План выполнения:

План выполнения SQLCLR TVF

План выполнения исходного запроса SQLCLR

Статистика производительности Plan Explorer

Профилировщик логических прочтений: 481

Основным преимуществом этой реализации является то, что она быстрее, чем при использовании контекстного соединения, и использует меньше памяти. В памяти одновременно хранятся только две вещи:

  1. Любые повторяющиеся строки (тот же продукт и дата транзакции). Это необходимо, потому что до тех пор, пока продукт или дата не изменятся, мы не знаем, какова будет окончательная текущая сумма. В данных примера есть одна комбинация продукта и даты, которая имеет 64 строки.
  2. Скользящий 45-дневный диапазон стоимости и даты транзакции только для текущего продукта. Это необходимо для корректировки простой промежуточной суммы для строк, которые выходят за 45-дневное скользящее окно.

Это минимальное кэширование должно гарантировать, что этот метод хорошо масштабируется; конечно, лучше, чем пытаться сохранить весь набор входных данных в памяти CLR.

Исходный код

Пол Уайт
источник
17

Если вы используете 64-разрядную версию SQL Server 2014 Enterprise, Developer или Evaluation, вы можете использовать встроенную память OLTP . Решение не будет одним сканированием и вряд ли будет использовать какие-либо оконные функции вообще, но это может добавить некоторую ценность этому вопросу, и используемый алгоритм может быть использован как вдохновение для других решений.

Для начала вам нужно включить In-Memory OLTP в базе данных AdventureWorks.

alter database AdventureWorks2014 
  add filegroup InMem contains memory_optimized_data;

alter database AdventureWorks2014 
  add file (name='AW2014_InMem', 
            filename='D:\SQL Server\MSSQL12.MSSQLSERVER\MSSQL\DATA\AW2014') 
    to filegroup InMem;

alter database AdventureWorks2014 
  set memory_optimized_elevate_to_snapshot = on;

Параметром процедуры является переменная таблицы In-Memory, которая должна быть определена как тип.

create type dbo.TransHistory as table
(
  ID int not null,
  ProductID int not null,
  TransactionDate datetime not null,
  ReferenceOrderID int not null,
  ActualCost money not null,
  RunningTotal money not null,
  RollingSum45 money not null,

  -- Index used in while loop
  index IX_T1 nonclustered hash (ID) with (bucket_count = 1000000),

  -- Used to lookup the running total as it was 45 days ago (or more)
  index IX_T2 nonclustered (ProductID, TransactionDate desc)
) with (memory_optimized = on);

ID не уникален в этой таблице, он уникален для каждой комбинации ProductIDи TransactionDate.

В этой процедуре есть некоторые комментарии, которые говорят вам, что она делает, но в целом она вычисляет промежуточную сумму в цикле и для каждой итерации выполняет поиск промежуточной суммы, как это было 45 дней назад (или более).

Текущий промежуточный итог минус промежуточный итог, как это было 45 дней назад, - это скользящая сумма за 45 дней, которую мы ищем.

create procedure dbo.GetRolling45
  @TransHistory dbo.TransHistory readonly
with native_compilation, schemabinding, execute as owner as
begin atomic with(transaction isolation level = snapshot, language = N'us_english')

  -- Table to hold the result
  declare @TransRes dbo.TransHistory;

  -- Loop variable
  declare @ID int = 0;

  -- Current ProductID
  declare @ProductID int = -1;

  -- Previous ProductID used to restart the running total
  declare @PrevProductID int;

  -- Current transaction date used to get the running total 45 days ago (or more)
  declare @TransactionDate datetime;

  -- Sum of actual cost for the group ProductID and TransactionDate
  declare @ActualCost money;

  -- Running total so far
  declare @RunningTotal money = 0;

  -- Running total as it was 45 days ago (or more)
  declare @RunningTotal45 money = 0;

  -- While loop for each unique occurence of the combination of ProductID, TransactionDate
  while @ProductID <> 0
  begin
    set @ID += 1;
    set @PrevProductID = @ProductID;

    -- Get the current values
    select @ProductID = min(ProductID),
           @TransactionDate = min(TransactionDate),
           @ActualCost = sum(ActualCost)
    from @TransHistory 
    where ID = @ID;

    if @ProductID <> 0
    begin
      set @RunningTotal45 = 0;

      if @ProductID <> @PrevProductID
      begin
        -- New product, reset running total
        set @RunningTotal = @ActualCost;
      end
      else
      begin
        -- Same product as last row, aggregate running total
        set @RunningTotal += @ActualCost;

        -- Get the running total as it was 45 days ago (or more)
        select top(1) @RunningTotal45 = TR.RunningTotal
        from @TransRes as TR
        where TR.ProductID = @ProductID and
              TR.TransactionDate < dateadd(day, -45, @TransactionDate)
        order by TR.TransactionDate desc;

      end;

      -- Add all rows that match ID to the result table
      -- RollingSum45 is calculated by using the current running total and the running total as it was 45 days ago (or more)
      insert into @TransRes(ID, ProductID, TransactionDate, ReferenceOrderID, ActualCost, RunningTotal, RollingSum45)
      select @ID, 
             @ProductID, 
             @TransactionDate, 
             TH.ReferenceOrderID, 
             TH.ActualCost, 
             @RunningTotal, 
             @RunningTotal - @RunningTotal45
      from @TransHistory as TH
      where ID = @ID;

    end
  end;

  -- Return the result table to caller
  select TR.ProductID, TR.TransactionDate, TR.ReferenceOrderID, TR.ActualCost, TR.RollingSum45
  from @TransRes as TR
  order by TR.ProductID, TR.TransactionDate, TR.ReferenceOrderID;

end;

Запустите процедуру, как это.

-- Parameter to stored procedure GetRollingSum
declare @T dbo.TransHistory;

-- Load data to in-mem table
-- ID is unique for each combination of ProductID, TransactionDate
insert into @T(ID, ProductID, TransactionDate, ReferenceOrderID, ActualCost, RunningTotal, RollingSum45)
select dense_rank() over(order by TH.ProductID, TH.TransactionDate),
       TH.ProductID, 
       TH.TransactionDate, 
       TH.ReferenceOrderID,
       TH.ActualCost,
       0, 
       0
from Production.TransactionHistory as TH;

-- Get the rolling 45 days sum
exec dbo.GetRolling45 @T;

Тестируя это на моем компьютере, клиентская статистика сообщает об общем времени выполнения около 750 миллисекунд. Для сравнения версия подзапроса занимает 3,5 секунды.

Дополнительные звонки:

Этот алгоритм может также использоваться обычным T-SQL. Вычислите промежуточный итог, используя rangeне строки, и сохраните результат во временной таблице. Затем вы можете запросить эту таблицу с самостоятельным присоединением к промежуточной сумме, как это было 45 дней назад, и рассчитать скользящую сумму. Однако реализация по rangeсравнению с rowsдовольно медленной из-за того, что необходимо по-разному обрабатывать дубликаты порядка с помощью предложения, поэтому я не смог добиться такой хорошей производительности при таком подходе. Обходным last_value()путем может быть использование другой оконной функции, например, над вычисленной промежуточной суммой, используемой rowsдля имитации rangeпромежуточной суммы. Другой способ заключается в использовании max() over(). У обоих были некоторые проблемы. Нахождение подходящего индекса для использования, чтобы избежать сортировки и избежать катушек сmax() over()версия. Я отказался от оптимизации этих вещей, но если вы заинтересованы в коде, который у меня есть, пожалуйста, дайте мне знать.

Микаэль Эрикссон
источник
13

Ну, это было весело :) Мое решение немного медленнее, чем у @ GeoffPatterson, но отчасти это тот факт, что я привязываюсь к исходной таблице, чтобы устранить одно из предположений Джеффа (то есть по одной строке на пару продукт / дата) , Я исходил из предположения, что это упрощенная версия окончательного запроса и может потребоваться дополнительная информация из исходной таблицы.

Примечание: я позаимствовал таблицу календаря Джеффа и на самом деле получил очень похожее решение:

-- Build calendar table for 2000 ~ 2020
CREATE TABLE dbo.calendar (d DATETIME NOT NULL CONSTRAINT PK_calendar PRIMARY KEY)
GO
DECLARE @d DATETIME = '1/1/2000'
WHILE (@d < '1/1/2021')
BEGIN
    INSERT INTO dbo.calendar (d) VALUES (@d)
    SELECT @d =  DATEADD(DAY, 1, @d)
END

Вот сам запрос:

WITH myCTE AS (SELECT PP.ProductID, calendar.d AS TransactionDate, 
                    SUM(ActualCost) AS CostPerDate
                FROM Production.Product PP
                CROSS JOIN calendar
                LEFT OUTER JOIN Production.TransactionHistory PTH
                    ON PP.ProductID = PTH.ProductID
                    AND calendar.d = PTH.TransactionDate
                CROSS APPLY (SELECT MAX(TransactionDate) AS EndDate,
                                MIN(TransactionDate) AS StartDate
                            FROM Production.TransactionHistory) AS Boundaries
                WHERE calendar.d BETWEEN Boundaries.StartDate AND Boundaries.EndDate
                GROUP BY PP.ProductID, calendar.d),
    RunningTotal AS (
        SELECT ProductId, TransactionDate, CostPerDate AS TBE,
                SUM(myCTE.CostPerDate) OVER (
                    PARTITION BY myCTE.ProductID
                    ORDER BY myCTE.TransactionDate
                    ROWS BETWEEN 
                        45 PRECEDING
                        AND CURRENT ROW) AS RollingSum45
        FROM myCTE)
SELECT 
    TH.ProductID,
    TH.TransactionDate,
    TH.ActualCost,
    RollingSum45
FROM Production.TransactionHistory AS TH
JOIN RunningTotal
    ON TH.ProductID = RunningTotal.ProductID
    AND TH.TransactionDate = RunningTotal.TransactionDate
WHERE RunningTotal.TBE IS NOT NULL
ORDER BY
    TH.ProductID,
    TH.TransactionDate,
    TH.ReferenceOrderID;

По сути, я решил, что самым простым способом борьбы с этим является использование опция для предложения ROWS. Но это требует , чтобы я только одну строку для каждого ProductID, TransactionDateкомбинации и не только это, но я должен был иметь одну строку для каждого ProductIDи possible date. Я сделал это, объединив таблицы Product, calendar и TransactionHistory в CTE. Затем мне пришлось создать еще один CTE для генерации скользящей информации. Я должен был сделать это, потому что, если я присоединился к исходной таблице напрямую, я получил удаление строк, которое отбросило мои результаты. После этого было просто присоединить мой второй CTE к исходному столу. Я добавил TBEстолбец (будет исключен), чтобы избавиться от пустых строк, созданных в CTE. Также я использовал CROSS APPLYв начальном CTE для создания границ для моей календарной таблицы.

Затем я добавил рекомендуемый индекс:

CREATE NONCLUSTERED INDEX [TransactionHistory_IX1]
ON [Production].[TransactionHistory] ([TransactionDate])
INCLUDE ([ProductID],[ReferenceOrderID],[ActualCost])

И получил окончательный план исполнения:

введите описание изображения здесь введите описание изображения здесь введите описание изображения здесь

РЕДАКТИРОВАТЬ: В конце я добавил индекс в таблицу календаря, который ускорил производительность с разумным запасом.

CREATE INDEX ix_calendar ON calendar(d)
Кеннет Фишер
источник
2
RunningTotal.TBE IS NOT NULLУсловие (и, следовательно, TBEстолбец) не является необходимым. Вы не получите лишних строк, если отбросите их, потому что ваше внутреннее условие соединения включает столбец даты - поэтому в результирующем наборе не может быть дат, которых изначально не было в источнике.
Андрей М,
2
Ага. Я полностью согласен. И все же это заставило меня набрать примерно 0,2 секунды. Я думаю, что это позволило оптимизатору узнать некоторую дополнительную информацию.
Кеннет Фишер
4

У меня есть несколько альтернативных решений, которые не используют индексы или справочные таблицы. Возможно, они могут быть полезны в ситуациях, когда у вас нет доступа к каким-либо дополнительным таблицам и вы не можете создавать индексы. По-видимому, можно получить правильные результаты при группировании TransactionDateвсего за один проход данных и с помощью одной оконной функции. Однако я не мог найти способ сделать это с помощью только одной оконной функции, когда вы не можете группировать по TransactionDate.

Чтобы обеспечить ориентир, на моей машине исходное решение, опубликованное в вопросе, имеет время ЦП 2808 мс без индекса покрытия и 1950 мс с индексом покрытия. Я тестирую с базой данных AdventureWorks2014 и SQL Server Express 2014.

Давайте начнем с решения, когда мы можем группировать по TransactionDate. Промежуточная сумма за последние X дней также может быть выражена следующим образом:

Промежуточная сумма для строки = промежуточная сумма всех предыдущих строк - промежуточная сумма всех предыдущих строк, для которых дата находится за пределами окна даты.

В SQL одним из способов выразить это является создание двух копий ваших данных, а для второй копии умножение стоимости на -1 и добавление X + 1 дней к столбцу даты. Вычисление промежуточной суммы по всем данным позволит реализовать приведенную выше формулу. Я покажу это для некоторых примеров данных. Ниже приведен пример даты для сингла ProductID. Я представляю даты в виде чисел, чтобы облегчить вычисления. Начальные данные:

╔══════╦══════╗
 Date  Cost 
╠══════╬══════╣
    1     3 
    2     6 
   20     1 
   45    -4 
   47     2 
   64     2 
╚══════╩══════╝

Добавьте вторую копию данных. Во второй копии к дате добавлено 46 дней, а стоимость умножена на -1:

╔══════╦══════╦═══════════╗
 Date  Cost  CopiedRow 
╠══════╬══════╬═══════════╣
    1     3          0 
    2     6          0 
   20     1          0 
   45    -4          0 
   47    -3          1 
   47     2          0 
   48    -6          1 
   64     2          0 
   66    -1          1 
   91     4          1 
   93    -2          1 
  110    -2          1 
╚══════╩══════╩═══════════╝

Возьмите текущую сумму, упорядоченную по Dateвозрастанию и CopiedRowубыванию:

╔══════╦══════╦═══════════╦════════════╗
 Date  Cost  CopiedRow  RunningSum 
╠══════╬══════╬═══════════╬════════════╣
    1     3          0           3 
    2     6          0           9 
   20     1          0          10 
   45    -4          0           6 
   47    -3          1           3 
   47     2          0           5 
   48    -6          1          -1 
   64     2          0           1 
   66    -1          1           0 
   91     4          1           4 
   93    -2          1           0 
  110    -2          1           0 
╚══════╩══════╩═══════════╩════════════╝

Отфильтруйте скопированные строки, чтобы получить желаемый результат:

╔══════╦══════╦═══════════╦════════════╗
 Date  Cost  CopiedRow  RunningSum 
╠══════╬══════╬═══════════╬════════════╣
    1     3          0           3 
    2     6          0           9 
   20     1          0          10 
   45    -4          0           6 
   47     2          0           5 
   64     2          0           1 
╚══════╩══════╩═══════════╩════════════╝

Следующий SQL является одним из способов реализации вышеуказанного алгоритма:

WITH THGrouped AS 
(
    SELECT
    ProductID,
    TransactionDate,
    SUM(ActualCost) ActualCost
    FROM Production.TransactionHistory
    GROUP BY ProductID,
    TransactionDate
)
SELECT
ProductID,
TransactionDate,
ActualCost,
RollingSum45
FROM
(
    SELECT
    TH.ProductID,
    TH.ActualCost,
    t.TransactionDate,
    SUM(t.ActualCost) OVER (PARTITION BY TH.ProductID ORDER BY t.TransactionDate, t.OrderFlag) AS RollingSum45,
    t.OrderFlag,
    t.FilterFlag -- define this column to avoid another sort at the end
    FROM THGrouped AS TH
    CROSS APPLY (
        VALUES
        (TH.ActualCost, TH.TransactionDate, 1, 0),
        (-1 * TH.ActualCost, DATEADD(DAY, 46, TH.TransactionDate), 0, 1)
    ) t (ActualCost, TransactionDate, OrderFlag, FilterFlag)
) tt
WHERE tt.FilterFlag = 0
ORDER BY
tt.ProductID,
tt.TransactionDate,
tt.OrderFlag
OPTION (MAXDOP 1);

На моей машине это заняло 702 мс процессорного времени с индексом покрытия и 734 мс процессорного времени без индекса. План запроса можно найти здесь: https://www.brentozar.com/pastetheplan/?id=SJdCsGVSl

Недостатком этого решения является то, что при упорядочении по новому TransactionDateстолбцу возникает неизбежная сортировка . Я не думаю, что этот вид может быть решен путем добавления индексов, потому что нам нужно объединить две копии данных перед выполнением заказа. Мне удалось избавиться от сортировки в конце запроса, добавив в другой столбец ORDER BY. Если бы я сделал заказ, FilterFlagто обнаружил, что SQL Server оптимизирует этот столбец из сортировки и будет выполнять явную сортировку.

Решения о том, когда нам нужно возвращать набор результатов с TransactionDateодинаковыми значениями для одного и того же, ProductIdбыли намного сложнее. Я бы суммировал проблему как необходимость разделять и упорядочивать по одному столбцу. Синтаксис, предоставленный Полом, решает эту проблему, поэтому неудивительно, что это так сложно выразить с помощью текущих оконных функций, доступных в SQL Server (если бы не было сложно выразить, не было бы необходимости расширять синтаксис).

Если я использую приведенный выше запрос без группировки, то получаю разные значения для скользящей суммы, когда есть несколько строк с одинаковыми ProductIdи TransactionDate. Один из способов решения этой проблемы - выполнить те же расчеты промежуточной суммы, что и выше, но также пометить последнюю строку в разделе. Это может быть сделано с LEAD(предполагая, ProductIDчто никогда не NULL) без дополнительной сортировки. Для окончательного значения промежуточной суммы я использую MAXфункцию окна, чтобы применить значение в последней строке раздела ко всем строкам раздела.

SELECT
ProductID,
TransactionDate,
ReferenceOrderID,
ActualCost,
MAX(CASE WHEN LasttRowFlag = 1 THEN RollingSum ELSE NULL END) OVER (PARTITION BY ProductID, TransactionDate) RollingSum45
FROM
(
    SELECT
    TH.ProductID,
    TH.ActualCost,
    TH.ReferenceOrderID,
    t.TransactionDate,
    SUM(t.ActualCost) OVER (PARTITION BY TH.ProductID ORDER BY t.TransactionDate, t.OrderFlag, TH.ReferenceOrderID) RollingSum,
    CASE WHEN LEAD(TH.ProductID) OVER (PARTITION BY TH.ProductID, t.TransactionDate ORDER BY t.OrderFlag, TH.ReferenceOrderID) IS NULL THEN 1 ELSE 0 END LasttRowFlag,
    t.OrderFlag,
    t.FilterFlag -- define this column to avoid another sort at the end
    FROM Production.TransactionHistory AS TH
    CROSS APPLY (
        VALUES
        (TH.ActualCost, TH.TransactionDate, 1, 0),
        (-1 * TH.ActualCost, DATEADD(DAY, 46, TH.TransactionDate), 0, 1)
    ) t (ActualCost, TransactionDate, OrderFlag, FilterFlag)
) tt
WHERE tt.FilterFlag = 0
ORDER BY
tt.ProductID,
tt.TransactionDate,
tt.OrderFlag,
tt.ReferenceOrderID
OPTION (MAXDOP 1);  

На моей машине это заняло 2464 мс процессорного времени без индекса покрытия. Как и раньше, кажется, что это неизбежно. План запроса можно найти здесь: https://www.brentozar.com/pastetheplan/?id=HyWxhGVBl

Я думаю, что есть возможность для улучшения в вышеупомянутом запросе. Конечно, есть и другие способы использования функций Windows для получения желаемого результата.

Джо Оббиш
источник