Підсумкова сума сукупності даних за допомогою функцій вікна


56

Мені потрібно обчислити постійну суму за діапазон дат. Для ілюстрації, використовуючи зразок бази даних 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 може бути менш доступним для деяких DBA, що робить таку функцію більш чорною скринькою, яка не настільки прозора, не настільки легко модифікована, не так легко розгорнута і, можливо, не настільки легко налагоджена. Це досить великий мінус у порівнянні з 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);

односкандоване

Обидва запити повертають однаковий результат в одному порядку.

Порівняння

Ось час і ІО статистика.

статистика2

іо2

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

хрест застосовувати

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

План статистики продуктивності Провідника

Профілер логічно читає: 481

Основна перевага цієї реалізації полягає в тому, що вона швидша, ніж використання контекстного з'єднання, і при цьому використовується менше пам'яті. Він пам'ять зберігає лише дві речі за один раз:

  1. Будь-які повторювані рядки (той самий товар і дата транзакції). Це потрібно, оскільки доки продукт чи дата не зміниться, ми не знаємо, якою буде кінцева поточна сума. У зразках даних є одна комбінація товару та дати, що має 64 ряди.
  2. Розсувний 45-денний діапазон вартості та дати трансакції для поточного продукту. Це необхідно для коригування простої суми ходу для рядків, які залишають 45-денне розсувне вікно.

Цей мінімальний кешування повинен забезпечити, щоб цей метод добре масштабував; безумовно, краще, ніж намагатися утримувати весь набір входів у пам'яті CLR.

Вихідний код


17

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

Спочатку потрібно включити OLTP In-Memory в базі даних 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;

Параметр процедури - це змінна таблиця в пам'яті, яку потрібно визначити як тип.

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);

Ідентифікатор не унікальний у цій таблиці, він унікальний для кожної комбінації 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. Я це зробив, поєднуючи таблиці продуктів, календаря та TransactionHistory в CTE. Тоді мені довелося створити ще один CTE для генерації прокатної інформації. Мені довелося це зробити, тому що якщо я приєднався до нього безпосередньо до оригінальної таблиці, я отримав би усунення рядків, які відкинули мої результати. Після цього було простою справою повернення мого другого CTE до оригінальної таблиці. Я додавав TBEстовпчик (для усунення), щоб позбутися порожніх рядків, створених у CTE. Також я використовував a CROSS APPLYв початковому CTE для створення меж для моєї календарної таблиці.

Потім я додав рекомендований індекс:

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

І отримали остаточний план виконання:

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

EDIT: Врешті-решт я додав в таблицю календаря індекс, що збільшив ефективність за розумним запасом.

CREATE INDEX ix_calendar ON calendar(d)

2
RunningTotal.TBE IS NOT NULLУмова (і, отже, TBEстовпець) НЕ є необхідним. Якщо ви скинете його, ви не збираєтеся отримувати зайві рядки, тому що ваш внутрішній стан з'єднання включає стовпчик дати - тому набір результатів не може мати дат, які не були спочатку в джерелі.
Андрій М

2
Так. Я згоден повністю. І все ж це все-таки змусило мене набрати приблизно на 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, щоб отримати бажаний результат.

Використовуючи наш веб-сайт, ви визнаєте, що прочитали та зрозуміли наші Політику щодо файлів cookie та Політику конфіденційності.
Licensed under cc by-sa 3.0 with attribution required.