Kisaran jumlah bergulir tanggal menggunakan fungsi jendela

57

Saya perlu menghitung jumlah bergulir selama rentang tanggal. Untuk mengilustrasikan, menggunakan database sampel AdventureWorks , sintaksis hipotetis berikut akan melakukan apa yang saya butuhkan:

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;

Sayangnya, itu RANGE luas bingkai jendela saat ini tidak memungkinkan interval dalam SQL Server.

Saya tahu saya bisa menulis solusi menggunakan subquery dan agregat (non-jendela) biasa:

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;

Diberikan indeks berikut:

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

Rencana pelaksanaannya adalah:

Rencana eksekusi

Meskipun tidak terlalu tidak efisien, sepertinya harus dimungkinkan untuk mengungkapkan kueri ini hanya menggunakan agregat jendela dan fungsi analitik yang didukung dalam SQL Server 2012, 2014, atau 2016 (sejauh ini).

Untuk kejelasan, saya mencari solusi yang melakukan satu kali melewati data.

Dalam T-SQL ini mungkin berarti bahwa para OVERklausul akan melakukan pekerjaan, dan rencana eksekusi akan menampilkan Jendela kelos dan Window Agregat. Semua elemen bahasa yang menggunakan OVERklausa adalah permainan yang adil. Solusi SQLCLR dapat diterima, asalkan dijamin untuk menghasilkan hasil yang benar.

Untuk solusi T-SQL, semakin sedikit Hash, Urutkan, dan Kumparan Jendela / Agregat dalam rencana eksekusi, semakin baik. Jangan ragu untuk menambahkan indeks, tetapi struktur terpisah tidak diizinkan (jadi, tidak ada tabel yang dihitung sebelumnya yang disinkronkan dengan pemicu, misalnya). Tabel referensi diperbolehkan (tabel angka, tanggal, dll.)

Idealnya, solusi akan menghasilkan hasil yang persis sama dalam urutan yang sama dengan versi subquery di atas, tetapi segala sesuatu yang bisa dibilang benar juga dapat diterima. Kinerja selalu menjadi pertimbangan, jadi solusi harus setidaknya cukup efisien.

Ruang obrolan khusus: Saya telah membuat ruang obrolan publik untuk diskusi terkait pertanyaan ini dan jawabannya. Setiap pengguna dengan setidaknya 20 poin reputasi dapat mengambil bagian secara langsung. Harap ping saya di komentar di bawah jika Anda memiliki kurang dari 20 perwakilan dan ingin mengambil bagian.

Paul White
sumber

Jawaban:

42

Pertanyaan bagus, Paul! Saya menggunakan beberapa pendekatan yang berbeda, satu di T-SQL dan satu di CLR.

Ringkasan cepat T-SQL

Pendekatan T-SQL dapat diringkas sebagai langkah-langkah berikut:

  • Ambil produk silang produk / tanggal
  • Gabungkan data penjualan yang diamati
  • Gabungkan data itu ke tingkat produk / tanggal
  • Hitung jumlah bergulir selama 45 hari terakhir berdasarkan data agregat ini (yang berisi hari-hari "hilang" yang diisi)
  • Saring hasil itu hanya untuk pasangan produk / tanggal yang memiliki satu atau lebih penjualan

Dengan menggunakan SET STATISTICS IO ON, pendekatan ini melaporkan Table 'TransactionHistory'. Scan count 1, logical reads 484, yang mengkonfirmasi "pass tunggal" di atas tabel. Untuk referensi, laporan kueri pencarian-lingkaran asli Table 'TransactionHistory'. Scan count 113444, logical reads 438366.

Seperti yang dilaporkan oleh SET STATISTICS TIME ON, waktu CPU adalah 514ms. Ini lebih baik dibandingkan dengan 2231mspermintaan asli.

Ringkasan cepat CLR

Ringkasan CLR dapat diringkas sebagai langkah-langkah berikut:

  • Baca data ke dalam memori, diurutkan berdasarkan produk dan tanggal
  • Saat memproses setiap transaksi, tambahkan total biaya yang berjalan. Setiap kali transaksi merupakan produk yang berbeda dari transaksi sebelumnya, reset total berjalan ke 0.
  • Pertahankan pointer ke transaksi pertama yang sama (produk, tanggal) dengan transaksi saat ini. Setiap kali transaksi terakhir dengan itu (produk, tanggal) ditemui, hitung jumlah bergulir untuk transaksi itu dan terapkan pada semua transaksi dengan yang sama (produk, tanggal)
  • Kembalikan semua hasil ke pengguna!

Dengan menggunakan SET STATISTICS IO ON, pendekatan ini melaporkan bahwa tidak ada I / O logis telah terjadi! Wow, solusi sempurna! (Sebenarnya, sepertinya begituSET STATISTICS IO tidak melaporkan I / O yang terjadi dalam CLR. Tetapi dari kode, mudah untuk melihat bahwa satu pemindaian tabel dibuat dan mengambil data sesuai dengan indeks yang disarankan oleh Paul.

Seperti yang dilaporkan oleh SET STATISTICS TIME ON, waktu CPU sekarang187ms . Jadi ini merupakan peningkatan dari pendekatan T-SQL. Sayangnya, keseluruhan waktu yang telah berlalu dari kedua pendekatan ini sangat mirip, masing-masing sekitar setengah detik. Namun, pendekatan berbasis CLR memang harus mengeluarkan baris 113K ke konsol (vs. hanya 52K untuk pendekatan T-SQL yang dikelompokkan berdasarkan produk / tanggal), jadi itu sebabnya saya lebih fokus pada waktu CPU.

Keuntungan besar lain dari pendekatan ini adalah menghasilkan hasil yang persis sama dengan pendekatan loop / seek asli, termasuk baris untuk setiap transaksi bahkan dalam kasus di mana suatu produk dijual beberapa kali pada hari yang sama. (Di AdventureWorks, saya secara khusus membandingkan hasil baris demi baris dan mengkonfirmasi bahwa mereka cocok dengan permintaan asli Paul.)

Kelemahan dari pendekatan ini, setidaknya dalam bentuk saat ini, adalah bahwa ia membaca semua data dalam memori. Namun, algoritma yang telah dirancang hanya benar-benar membutuhkan bingkai jendela saat ini di memori pada waktu tertentu dan dapat diperbarui untuk bekerja untuk set data yang melebihi memori. Paul telah mengilustrasikan poin ini dalam jawabannya dengan menghasilkan implementasi algoritma ini yang hanya menyimpan jendela geser dalam memori. Ini datang dengan mengorbankan pemberian izin yang lebih tinggi untuk perakitan CLR, tetapi pasti akan bermanfaat dalam meningkatkan solusi ini hingga set data besar secara sewenang-wenang.


T-SQL - satu pemindaian, dikelompokkan berdasarkan tanggal

Pengaturan awal

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

Kueri

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)

Rencana eksekusi

Dari rencana eksekusi, kita melihat bahwa indeks asli yang diusulkan oleh Paul cukup untuk memungkinkan kita melakukan pemindaian tunggal Production.TransactionHistory, menggunakan gabungan gabungan untuk menggabungkan riwayat transaksi dengan setiap kombinasi produk / tanggal yang memungkinkan.

masukkan deskripsi gambar di sini

Asumsi

Ada beberapa asumsi penting yang dimasukkan ke dalam pendekatan ini. Saya kira itu terserah Paul untuk memutuskan apakah mereka dapat diterima :)

  • Saya menggunakan Production.Producttabel. Tabel ini tersedia secara gratis AdventureWorks2012dan hubungannya dipaksakan dengan kunci asing Production.TransactionHistory, jadi saya menafsirkan ini sebagai permainan yang adil.
  • Pendekatan ini bergantung pada fakta bahwa transaksi tidak memiliki komponen waktu AdventureWorks2012; jika mereka melakukannya, menghasilkan set lengkap kombinasi produk / tanggal tidak akan mungkin lagi tanpa terlebih dahulu mengambil alih sejarah transaksi.
  • Saya memproduksi rowset yang hanya berisi satu baris per produk / pasangan tanggal. Saya pikir ini "bisa dibilang benar" dan dalam banyak kasus hasil yang lebih diinginkan untuk kembali. Untuk setiap produk / tanggal, saya telah menambahkan NumOrderskolom untuk menunjukkan berapa banyak penjualan yang terjadi. Lihat tangkapan layar berikut untuk perbandingan hasil permintaan asli vs permintaan yang diajukan dalam kasus di mana produk dijual beberapa kali pada tanggal yang sama (misalnya, 319/ 2007-09-05 00:00:00.000)

masukkan deskripsi gambar di sini


CLR - satu pemindaian, set hasil yang tidak dikelompokkan penuh

Fungsi utama badan

Tidak ada satu ton pun untuk dilihat di sini; bagian utama dari fungsi menyatakan input (yang harus cocok dengan fungsi SQL yang sesuai), mengatur koneksi SQL, dan membuka 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;
    }
}

Logika inti

Saya telah memisahkan logika utama sehingga lebih mudah untuk fokus pada:

// 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;
}

Pembantu

Logika berikut dapat ditulis sebaris, tetapi sedikit lebih mudah dibaca ketika mereka dibagi menjadi metode mereka sendiri.

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

Mengikat semuanya dalam SQL

Semuanya hingga saat ini telah di C #, jadi mari kita lihat SQL sebenarnya yang terlibat. (Atau, Anda dapat menggunakan skrip penerapan ini untuk membuat rakitan langsung dari bit rakitan saya daripada mengkompilasi sendiri.)

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

Peringatan

Pendekatan CLR memberikan lebih banyak fleksibilitas untuk mengoptimalkan algoritma, dan mungkin bisa disetel lebih jauh oleh seorang ahli di C #. Namun, ada juga kelemahan strategi CLR. Beberapa hal yang perlu diingat:

  • Pendekatan CLR ini menyimpan salinan data yang tersimpan di memori. Dimungkinkan untuk menggunakan pendekatan streaming, tetapi saya menemukan kesulitan awal dan menemukan bahwa ada masalah Connect yang beredar mengeluh bahwa perubahan dalam SQL 2008+ membuatnya lebih sulit untuk menggunakan jenis pendekatan ini. Itu masih mungkin (seperti yang diperlihatkan Paul), tetapi membutuhkan tingkat izin yang lebih tinggi dengan mengatur basis data sebagai TRUSTWORTHYdan memberikannya EXTERNAL_ACCESSkepada majelis CLR. Jadi ada beberapa kerumitan dan implikasi keamanan potensial, tetapi hasilnya adalah pendekatan streaming yang dapat lebih baik skala untuk set data yang jauh lebih besar daripada yang ada di AdventureWorks.
  • CLR mungkin kurang dapat diakses oleh beberapa DBA, membuat fungsi seperti itu lebih dari kotak hitam yang tidak transparan, tidak mudah dimodifikasi, tidak mudah dikerahkan, dan mungkin tidak mudah didebug. Ini adalah kerugian yang cukup besar jika dibandingkan dengan pendekatan T-SQL.


Bonus: T-SQL # 2 - pendekatan praktis yang sebenarnya saya gunakan

Setelah mencoba memikirkan masalah secara kreatif untuk sementara waktu, saya pikir saya juga akan memposting cara yang cukup sederhana dan praktis yang kemungkinan besar akan saya pilih untuk mengatasi masalah ini jika muncul dalam pekerjaan sehari-hari saya. Itu memanfaatkan fungsionalitas jendela SQL 2012+, tetapi tidak dalam jenis cara inovatif yang diharapkan pertanyaan:

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

Ini sebenarnya menghasilkan rencana permintaan keseluruhan yang cukup sederhana, bahkan ketika melihat kedua dari dua paket permintaan yang relevan secara bersamaan:

masukkan deskripsi gambar di sini masukkan deskripsi gambar di sini

Beberapa alasan saya suka pendekatan ini:

  • Ini menghasilkan set hasil penuh yang diminta dalam pernyataan masalah (sebagai lawan dari sebagian besar solusi T-SQL lainnya, yang mengembalikan versi hasil yang dikelompokkan).
  • Mudah untuk menjelaskan, memahami, dan men-debug; Saya tidak akan kembali setahun kemudian dan bertanya-tanya bagaimana sih saya bisa membuat perubahan kecil tanpa merusak kebenaran atau kinerja
  • Ini berjalan sekitar 900mspada set data yang disediakan, bukan dari 2700msloop-seek asli
  • Jika data lebih padat (lebih banyak transaksi per hari), kompleksitas komputasi tidak tumbuh secara kuadratik dengan jumlah transaksi di jendela geser (seperti halnya untuk permintaan awal); Saya pikir ini membahas bagian dari kekhawatiran Paul tentang ingin menghindari banyak pemindaian
  • Ini pada dasarnya tidak menghasilkan tempdb I / O dalam pembaruan terbaru dari SQL 2012+ karena fungsionalitas malas menulis tempdb baru
  • Untuk kumpulan data yang sangat besar, sepele untuk membagi pekerjaan menjadi kumpulan terpisah untuk setiap produk jika tekanan memori menjadi perhatian.

Beberapa potensi peringatan:

  • Meskipun secara teknis memindai Production.TransactionHistory hanya sekali, itu tidak benar-benar pendekatan "satu pemindaian" karena tabel #temp dengan ukuran yang sama dan perlu melakukan logika tambahan I / O pada tabel itu juga. Namun, saya tidak melihat ini terlalu berbeda dari meja kerja yang kami punya lebih banyak kontrol manual karena kami telah menetapkan struktur yang tepat
  • Bergantung pada lingkungan Anda, penggunaan tempdb dapat dipandang sebagai positif (mis., Itu pada drive SSD yang terpisah) atau negatif (konkurensi tinggi pada server, banyak anggapan tempdb sudah)
Geoff Patterson
sumber
25

Ini jawaban yang panjang, jadi saya memutuskan untuk menambahkan ringkasan di sini.

  • Pada awalnya saya menyajikan solusi yang menghasilkan hasil yang persis sama dalam urutan yang sama seperti pada pertanyaan. Ini memindai tabel utama 3 kali: untuk mendapatkan daftarProductIDs dengan kisaran tanggal untuk setiap Produk, untuk menjumlahkan biaya untuk setiap hari (karena ada beberapa transaksi dengan tanggal yang sama), untuk bergabung dengan hasil dengan baris asli.
  • Selanjutnya saya membandingkan dua pendekatan yang menyederhanakan tugas dan menghindari satu pemindaian terakhir dari tabel utama. Hasilnya adalah ringkasan harian, yaitu jika beberapa transaksi pada suatu Produk memiliki tanggal yang sama mereka digulung menjadi satu baris. Pendekatan saya dari langkah sebelumnya memindai tabel dua kali. Pendekatan oleh Geoff Patterson memindai tabel sekali, karena dia menggunakan pengetahuan eksternal tentang kisaran tanggal dan daftar Produk.
  • Akhirnya saya menyajikan solusi single pass yang kembali menghasilkan ringkasan harian, tetapi tidak memerlukan pengetahuan eksternal tentang kisaran tanggal atau daftar ProductIDs.

Saya akan menggunakan database AdventureWorks2014 dan SQL Server Express 2014.

Perubahan ke database asli:

  • Jenis berubah dari [Production].[TransactionHistory].[TransactionDate]dari datetimeke date. Komponen waktu itu nol.
  • Tabel kalender ditambahkan [dbo].[Calendar]
  • Menambahkan indeks ke [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);

Artikel MSDN tentang OVERklausa memiliki tautan ke posting blog yang bagus tentang fungsi jendela oleh Itzik Ben-Gan. Dalam posting itu ia menjelaskan cara OVERkerjanya, perbedaan antara ROWSdan RANGEopsi serta menyebutkan masalah penghitungan jumlah bergulir selama rentang tanggal. Dia menyebutkan bahwa versi SQL Server saat ini tidak menerapkan RANGEsecara penuh dan tidak menerapkan tipe data interval waktu. Penjelasannya tentang perbedaan antara ROWSdan RANGEmemberi saya ide.

Tanggal tanpa celah dan duplikat

Jika TransactionHistorytabel berisi tanggal tanpa celah dan tanpa duplikat, maka kueri berikut akan menghasilkan hasil yang benar:

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;

Memang, jendela 45 baris akan mencakup tepat 45 hari.

Tanggal dengan celah tanpa duplikat

Sayangnya, data kami memiliki kesenjangan dalam tanggal. Untuk mengatasi masalah ini, kita bisa menggunakan Calendartabel untuk menghasilkan satu set tanggal tanpa celah, kemudian LEFT JOINdata asli ke set ini dan menggunakan kueri yang sama dengannya ROWS BETWEEN 45 PRECEDING AND CURRENT ROW. Ini akan menghasilkan hasil yang benar hanya jika tanggal tidak berulang (sama ProductID).

Tanggal dengan celah dengan duplikat

Sayangnya, data kami memiliki celah dalam tanggal dan tanggal yang dapat diulang dalam waktu yang sama ProductID. Untuk mengatasi masalah ini, kita dapat GROUPdata asli dengan ProductID, TransactionDatemenghasilkan seperangkat tanggal tanpa duplikat. Kemudian gunakan Calendartabel untuk menghasilkan satu set tanggal tanpa celah. Lalu kita bisa menggunakan kueri dengan ROWS BETWEEN 45 PRECEDING AND CURRENT ROWuntuk menghitung rolling SUM. Ini akan menghasilkan hasil yang benar. Lihat komentar dalam kueri di bawah ini.

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
;

Saya mengonfirmasi bahwa kueri ini menghasilkan hasil yang sama dengan pendekatan dari pertanyaan yang menggunakan subquery.

Rencana eksekusi

statistik

Permintaan pertama menggunakan subquery, kedua - pendekatan ini. Anda dapat melihat bahwa durasi dan jumlah bacaan jauh lebih sedikit dalam pendekatan ini. Mayoritas perkiraan biaya dalam pendekatan ini adalah final ORDER BY, lihat di bawah.

subquery

Pendekatan subquery memiliki rencana sederhana dengan loop bersarang dan O(n*n)kompleksitas.

lebih

Rencana untuk pendekatan ini memindai TransactionHistorybeberapa kali, tetapi tidak ada loop. Seperti yang Anda lihat, lebih dari 70% perkiraan biaya adalah Sortuntuk final ORDER BY.

io

Hasil teratas - subquery, bawah - OVER.


Menghindari pemindaian ekstra

Pemindaian Indeks terakhir, Gabung Bergabung dan Urutkan dalam rencana di atas disebabkan oleh final INNER JOINdengan tabel asli untuk membuat hasil akhir persis sama dengan pendekatan lambat dengan subquery. Jumlah baris yang dikembalikan sama dengan dalam TransactionHistorytabel. Ada baris TransactionHistoryketika beberapa transaksi terjadi pada hari yang sama untuk produk yang sama. Jika OK untuk hanya menampilkan ringkasan harian dalam hasil, maka tugas akhir ini JOINdapat dihapus dan kueri menjadi sedikit lebih sederhana dan sedikit lebih cepat. Pemindaian Indeks terakhir, Gabung Gabung, dan Urutkan dari paket sebelumnya diganti dengan Filter, yang menghilangkan baris yang ditambahkan oleh 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
;

dua pemindaian

Tetap saja, TransactionHistorydipindai dua kali. Satu pemindaian tambahan diperlukan untuk mendapatkan rentang tanggal untuk setiap produk. Saya tertarik untuk membandingkannya dengan pendekatan lain, di mana kami menggunakan pengetahuan eksternal tentang rentang tanggal global TransactionHistory, ditambah tabel tambahan Productyang semuanya ProductIDsharus menghindari pemindaian ekstra. Saya menghapus perhitungan jumlah transaksi per hari dari kueri ini untuk membuat perbandingan valid. Itu dapat ditambahkan di kedua kueri, tetapi saya ingin membuatnya tetap sederhana untuk perbandingan. Saya juga harus menggunakan tanggal lain, karena saya menggunakan database versi 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);

satu-scan

Kedua kueri mengembalikan hasil yang sama dalam urutan yang sama.

Perbandingan

Berikut adalah statistik waktu dan IO.

stats2

io2

Varian dua pemindaian sedikit lebih cepat dan memiliki lebih sedikit bacaan, karena varian satu pemindaian harus banyak menggunakan Meja Kerja. Selain itu, varian satu pemindaian menghasilkan lebih banyak baris daripada yang diperlukan seperti yang Anda lihat dalam paket. Ini menghasilkan tanggal untuk masing-masing ProductIDyang ada di dalam Producttabel, bahkan jika a ProductIDtidak memiliki transaksi. Ada 504 baris dalam Producttabel, tetapi hanya 441 produk yang bertransaksi TransactionHistory. Selain itu, menghasilkan rentang tanggal yang sama untuk setiap produk, yang lebih dari yang dibutuhkan. Jika TransactionHistorymemiliki sejarah keseluruhan yang lebih panjang, dengan setiap produk individual memiliki sejarah yang relatif singkat, jumlah baris tambahan yang tidak dibutuhkan akan lebih tinggi.

Di sisi lain, dimungkinkan untuk mengoptimalkan varian dua pemindaian sedikit lebih jauh dengan membuat indeks lain yang lebih sempit hanya pada indeks (ProductID, TransactionDate). Indeks ini akan digunakan untuk menghitung tanggal Mulai / Akhir untuk setiap produk ( CTE_Products) dan itu akan memiliki lebih sedikit halaman daripada mencakup indeks dan sebagai hasilnya menyebabkan lebih sedikit pembacaan.

Jadi, kita dapat memilih, apakah memiliki pemindaian sederhana ekstra eksplisit, atau memiliki Meja Kerja implisit.

BTW, jika boleh memiliki hasil dengan hanya ringkasan harian, maka lebih baik untuk membuat indeks yang tidak termasuk ReferenceOrderID. Itu akan menggunakan lebih sedikit halaman => lebih sedikit IO.

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

Solusi single pass dengan menggunakan CROSS APPLY

Itu menjadi jawaban yang sangat panjang, tetapi di sini ada satu varian lagi yang hanya mengembalikan ringkasan harian, tetapi hanya melakukan satu pemindaian data dan tidak memerlukan pengetahuan eksternal tentang rentang tanggal atau daftar ProductID. Itu tidak melakukan Urusan menengah juga. Performa keseluruhan mirip dengan varian sebelumnya, meskipun tampaknya sedikit lebih buruk.

Gagasan utamanya adalah menggunakan tabel angka untuk menghasilkan baris yang akan mengisi celah dalam tanggal. Untuk setiap tanggal yang ada gunakan LEADuntuk menghitung ukuran kesenjangan dalam beberapa hari dan kemudian gunakan CROSS APPLYuntuk menambahkan jumlah baris yang diperlukan ke dalam set hasil. Awalnya saya mencobanya dengan tabel angka permanen. Rencananya menunjukkan sejumlah besar bacaan dalam tabel ini, meskipun durasinya sebenarnya hampir sama, seperti ketika saya menghasilkan angka dengan cepat menggunakan 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
;

Paket ini "lebih panjang", karena kueri menggunakan dua fungsi jendela ( LEADdan SUM).

silang berlaku

statistik ca

ca io

Vladimir Baranov
sumber
23

Solusi SQLCLR alternatif yang mengeksekusi lebih cepat dan membutuhkan lebih sedikit memori:

Script Penempatan

Itu memerlukan EXTERNAL_ACCESSizin yang ditetapkan karena menggunakan koneksi loopback ke server target dan basis data alih-alih koneksi konteks (lambat). Ini adalah bagaimana memanggil fungsi:

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;

Menghasilkan hasil yang persis sama, dalam urutan yang sama, seperti pertanyaan.

Rencana eksekusi:

Rencana pelaksanaan SQLCLR TVF

Rencana pelaksanaan kueri sumber SQLCLR

Merencanakan statistik kinerja Explorer

Logika profiler berbunyi: 481

Keuntungan utama dari implementasi ini adalah lebih cepat daripada menggunakan koneksi konteks, dan menggunakan lebih sedikit memori. Itu hanya menyimpan dua hal dalam memori sekaligus:

  1. Baris duplikat apa pun (produk dan tanggal transaksi yang sama). Ini diperlukan karena sampai produk atau tanggal berubah, kita tidak tahu berapa jumlah final runningnya. Dalam data sampel, ada satu kombinasi produk dan tanggal yang memiliki 64 baris.
  2. Kisaran geser 45 hari dari biaya dan tanggal transaksi saja, untuk produk saat ini. Ini diperlukan untuk menyesuaikan jumlah lari sederhana untuk baris yang meninggalkan jendela geser 45 hari.

Caching minimal ini harus memastikan metode ini berkembang dengan baik; tentu lebih baik daripada mencoba menahan seluruh input yang diatur dalam memori CLR.

Kode sumber

Paul White
sumber
17

Jika Anda menggunakan SQL Server 2014 edisi Enterprise, Developer, atau Evaluation, Anda dapat menggunakan OLTP Dalam Memori . Solusinya tidak akan menjadi pemindaian tunggal dan dan hampir tidak akan menggunakan fungsi jendela sama sekali tetapi mungkin menambah nilai untuk pertanyaan ini dan algoritma yang digunakan mungkin dapat digunakan sebagai inspirasi untuk solusi lain.

Pertama, Anda perlu mengaktifkan OLTP Dalam Memori pada basis data 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;

Parameter untuk prosedur adalah variabel tabel In-Memory dan yang harus didefinisikan sebagai tipe.

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 tidak unik dalam tabel ini, ini unik untuk setiap kombinasi ProductIDdan TransactionDate.

Ada beberapa komentar dalam prosedur yang memberi tahu Anda apa yang dilakukannya, tetapi secara keseluruhan menghitung total berjalan dalam satu lingkaran dan untuk setiap iterasi itu melakukan pencarian untuk total berjalan seperti 45 hari yang lalu (atau lebih).

Total berjalan saat ini dikurangi total berjalan seperti 45 hari yang lalu adalah jumlah 45 hari bergulir yang kami cari.

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;

Aktifkan prosedur seperti ini.

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

Menguji ini di komputer saya, Statistik Klien melaporkan Total waktu eksekusi sekitar 750 milidetik. Untuk perbandingan, versi sub-permintaan membutuhkan waktu 3,5 detik.

Ocehan ekstra:

Algoritma ini juga dapat digunakan oleh T-SQL biasa. Hitung total yang berjalan, gunakan rangebukan baris, dan simpan hasilnya dalam tabel temp. Kemudian Anda dapat meminta tabel itu dengan bergabung sendiri ke total berjalan seperti 45 hari yang lalu dan menghitung jumlah yang bergulir. Namun, implementasi rangedibandingkan dengan rowscukup lambat karena fakta bahwa perlunya memperlakukan duplikat pesanan dengan klausa berbeda sehingga saya tidak mendapatkan semua kinerja yang baik dengan pendekatan ini. Solusi untuk itu bisa menggunakan fungsi jendela lain seperti last_value()lebih dari total berjalan dihitung menggunakan rowsuntuk mensimulasikan rangetotal berjalan. Cara lain adalah menggunakan versi. Saya menyerah mengoptimalkan hal-hal itu tetapi jika Anda tertarik dengan kode yang saya miliki sejauh ini, beri tahu saya.max() over() . Keduanya memiliki beberapa masalah. Menemukan indeks yang tepat untuk digunakan untuk menghindari pengurutan dan menghindari kumparan denganmax() over()

Mikael Eriksson
sumber
13

Yah itu menyenangkan :) Solusi saya sedikit lebih lambat daripada @ GeoffPatterson tetapi bagian dari itu adalah kenyataan bahwa saya mengikat kembali ke tabel asli untuk menghilangkan salah satu asumsi Geoff (yaitu satu baris per produk / pasangan tanggal) . Saya pergi dengan asumsi ini adalah versi sederhana dari permintaan akhir dan mungkin memerlukan informasi tambahan dari tabel asli.

Catatan: Saya meminjam tabel kalender Geoff dan pada kenyataannya berakhir dengan solusi yang sangat mirip:

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

Ini querynya sendiri:

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;

Pada dasarnya saya memutuskan bahwa cara termudah untuk menghadapinya adalah dengan menggunakan opsi untuk klausa ROWS. Tetapi itu mengharuskan saya hanya memiliki satu baris per ProductID, TransactionDatekombinasi dan bukan hanya itu, tetapi saya harus memiliki satu baris per ProductIDdan possible date. Saya melakukan itu dengan menggabungkan tabel Produk, kalender dan TransactionHistory dalam CTE. Kemudian saya harus membuat CTE lain untuk menghasilkan informasi bergulir. Saya harus melakukan ini karena jika saya bergabung kembali ke meja asli langsung saya mendapat penghapusan baris yang membuang hasil saya. Setelah itu, masalah sederhana bergabung dengan CTE kedua saya kembali ke meja asli. Saya memang menambahkan TBEkolom (untuk dihilangkan) untuk menyingkirkan baris kosong yang dibuat dalam CTE. Saya juga menggunakan CROSS APPLYdalam CTE awal untuk menghasilkan batas-batas untuk tabel kalender saya.

Saya kemudian menambahkan indeks yang direkomendasikan:

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

Dan mendapat rencana eksekusi akhir:

masukkan deskripsi gambar di sini masukkan deskripsi gambar di sini masukkan deskripsi gambar di sini

EDIT: Pada akhirnya saya menambahkan indeks pada tabel kalender yang mempercepat kinerja dengan margin yang masuk akal.

CREATE INDEX ix_calendar ON calendar(d)
Kenneth Fisher
sumber
2
The RunningTotal.TBE IS NOT NULLkondisi (dan, akibatnya, TBEkolom) tidak perlu. Anda tidak akan mendapatkan baris berlebihan jika Anda menjatuhkannya, karena kondisi gabungan dalam Anda menyertakan kolom tanggal - oleh karena itu set hasil tidak dapat memiliki tanggal yang tidak asli di sumber.
Andriy M
2
Ya. Aku sangat setuju. Namun itu masih menyebabkan saya memperoleh sekitar 0,2 detik. Saya pikir itu memungkinkan pengoptimal mengetahui beberapa informasi tambahan.
Kenneth Fisher
4

Saya punya beberapa solusi alternatif yang tidak menggunakan indeks atau tabel referensi. Mungkin mereka bisa berguna dalam situasi di mana Anda tidak memiliki akses ke tabel tambahan apa pun dan tidak dapat membuat indeks. Tampaknya memang mungkin untuk mendapatkan hasil yang benar ketika mengelompokkan TransactionDatehanya dengan satu pass data dan hanya satu fungsi jendela. Namun, saya tidak dapat menemukan cara untuk melakukannya hanya dengan satu fungsi jendela ketika Anda tidak dapat mengelompokkan berdasarkan TransactionDate.

Untuk memberikan kerangka acuan, pada mesin saya solusi asli yang diposting dalam pertanyaan memiliki waktu CPU 2808 ms tanpa indeks penutup dan 1950 ms dengan indeks penutup. Saya menguji dengan database AdventureWorks2014 dan SQL Server Express 2014.

Mari kita mulai dengan solusi ketika kita dapat mengelompokkan berdasarkan TransactionDate. Jumlah berjalan selama X hari terakhir juga dapat dinyatakan dengan cara berikut:

Menjalankan jumlah untuk satu baris = menjalankan jumlah semua baris sebelumnya - menjalankan jumlah semua baris sebelumnya yang tanggalnya berada di luar jendela tanggal.

Dalam SQL, salah satu cara untuk mengekspresikan ini adalah dengan membuat dua salinan data Anda dan untuk salinan kedua, mengalikan biaya dengan -1 dan menambahkan X + 1 hari ke kolom tanggal. Menghitung jumlah yang berjalan di semua data akan menerapkan rumus di atas. Saya akan menunjukkan ini untuk beberapa contoh data. Di bawah ini adalah beberapa tanggal sampel untuk satu orang ProductID. Saya menyatakan tanggal sebagai angka untuk mempermudah perhitungan. Mulai data:

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

Tambahkan salinan data kedua. Salinan kedua memiliki 46 hari ditambahkan ke tanggal dan biaya dikalikan -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 
╚══════╩══════╩═══════════╝

Ambil jumlah running yang dipesan dengan Datenaik dan CopiedRowturun:

╔══════╦══════╦═══════════╦════════════╗
 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 
╚══════╩══════╩═══════════╩════════════╝

Saring baris yang disalin untuk mendapatkan hasil yang diinginkan:

╔══════╦══════╦═══════════╦════════════╗
 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 berikut adalah salah satu cara untuk mengimplementasikan algoritma di atas:

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

Di komputer saya ini membutuhkan waktu CPU 702 ms dengan indeks penutup dan waktu CPU 734 ms tanpa indeks. Rencana kueri dapat ditemukan di sini: https://www.brentozar.com/pastetheplan/?id=SJdCsGVSl

Salah satu kelemahan dari solusi ini adalah bahwa tampaknya ada jenis yang tidak dapat dihindari ketika memesan oleh TransactionDatekolom baru . Saya tidak berpikir bahwa hal semacam ini dapat diselesaikan dengan menambahkan indeks karena kita perlu menggabungkan dua salinan data sebelum melakukan pemesanan. Saya bisa menyingkirkan semacam di akhir permintaan dengan menambahkan di kolom yang berbeda ke ORDER BY. Jika saya memesan oleh FilterFlagsaya menemukan bahwa SQL Server akan mengoptimalkan kolom dari semacam itu dan akan melakukan semacam eksplisit.

Solusi ketika kita perlu mengembalikan set hasil dengan TransactionDatenilai duplikat untuk hal yang sama ProductIdjauh lebih rumit. Saya akan meringkas masalah secara bersamaan perlu mempartisi dan memesan dengan kolom yang sama. Sintaks yang disediakan oleh Paul menyelesaikan masalah itu sehingga tidak mengherankan bahwa sangat sulit untuk mengekspresikan dengan fungsi jendela saat ini yang tersedia di SQL Server (jika tidak sulit untuk mengungkapkan tidak akan perlu memperluas sintaksis).

Jika saya menggunakan kueri di atas tanpa pengelompokan maka saya mendapatkan nilai yang berbeda untuk jumlah bergulir ketika ada beberapa baris dengan yang sama ProductIddan TransactionDate. Salah satu cara untuk mengatasi ini adalah dengan melakukan perhitungan jumlah running yang sama seperti di atas tetapi juga untuk menandai baris terakhir di partisi. Ini dapat dilakukan dengan LEAD( dengan asumsi ProductIDtidak pernah NULL) tanpa jenis tambahan. Untuk nilai penjumlahan berjalan terakhir, saya menggunakan MAXsebagai fungsi jendela untuk menerapkan nilai di baris terakhir partisi ke semua baris di partisi.

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

Di komputer saya ini membutuhkan waktu 2464ms CPU tanpa indeks penutup. Seperti sebelumnya tampaknya ada jenis yang tidak dapat dihindari. Rencana kueri dapat ditemukan di sini: https://www.brentozar.com/pastetheplan/?id=HyWxhGVBl

Saya pikir ada ruang untuk peningkatan dalam kueri di atas. Pasti ada cara lain untuk menggunakan fungsi windows untuk mendapatkan hasil yang diinginkan.

Joe Obbish
sumber