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 daftar
ProductIDs
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 datetime
ke 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 OVER
klausa memiliki tautan ke posting blog yang bagus tentang fungsi jendela oleh Itzik Ben-Gan. Dalam posting itu ia menjelaskan cara OVER
kerjanya, perbedaan antara ROWS
dan RANGE
opsi serta menyebutkan masalah penghitungan jumlah bergulir selama rentang tanggal. Dia menyebutkan bahwa versi SQL Server saat ini tidak menerapkan RANGE
secara penuh dan tidak menerapkan tipe data interval waktu. Penjelasannya tentang perbedaan antara ROWS
dan RANGE
memberi saya ide.
Tanggal tanpa celah dan duplikat
Jika TransactionHistory
tabel 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 Calendar
tabel untuk menghasilkan satu set tanggal tanpa celah, kemudian LEFT JOIN
data 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 GROUP
data asli dengan ProductID, TransactionDate
menghasilkan seperangkat tanggal tanpa duplikat. Kemudian gunakan Calendar
tabel untuk menghasilkan satu set tanggal tanpa celah. Lalu kita bisa menggunakan kueri dengan ROWS BETWEEN 45 PRECEDING AND CURRENT ROW
untuk 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
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.
Pendekatan subquery memiliki rencana sederhana dengan loop bersarang dan O(n*n)
kompleksitas.
Rencana untuk pendekatan ini memindai TransactionHistory
beberapa kali, tetapi tidak ada loop. Seperti yang Anda lihat, lebih dari 70% perkiraan biaya adalah Sort
untuk final ORDER BY
.
Hasil teratas - subquery
, bawah - OVER
.
Menghindari pemindaian ekstra
Pemindaian Indeks terakhir, Gabung Bergabung dan Urutkan dalam rencana di atas disebabkan oleh final INNER JOIN
dengan tabel asli untuk membuat hasil akhir persis sama dengan pendekatan lambat dengan subquery. Jumlah baris yang dikembalikan sama dengan dalam TransactionHistory
tabel. Ada baris TransactionHistory
ketika beberapa transaksi terjadi pada hari yang sama untuk produk yang sama. Jika OK untuk hanya menampilkan ringkasan harian dalam hasil, maka tugas akhir ini JOIN
dapat 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
;
Tetap saja, TransactionHistory
dipindai 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 Product
yang semuanya ProductIDs
harus 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);
Kedua kueri mengembalikan hasil yang sama dalam urutan yang sama.
Perbandingan
Berikut adalah statistik waktu dan IO.
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 ProductID
yang ada di dalam Product
tabel, bahkan jika a ProductID
tidak memiliki transaksi. Ada 504 baris dalam Product
tabel, tetapi hanya 441 produk yang bertransaksi TransactionHistory
. Selain itu, menghasilkan rentang tanggal yang sama untuk setiap produk, yang lebih dari yang dibutuhkan. Jika TransactionHistory
memiliki 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 LEAD
untuk menghitung ukuran kesenjangan dalam beberapa hari dan kemudian gunakan CROSS APPLY
untuk 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 ( LEAD
dan SUM
).
RunningTotal.TBE IS NOT NULL
kondisi (dan, akibatnya,TBE
kolom) 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.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
TransactionDate
hanya 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 berdasarkanTransactionDate
.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: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:Tambahkan salinan data kedua. Salinan kedua memiliki 46 hari ditambahkan ke tanggal dan biaya dikalikan -1:
Ambil jumlah running yang dipesan dengan
Date
naik danCopiedRow
turun:Saring baris yang disalin untuk mendapatkan hasil yang diinginkan:
SQL berikut adalah salah satu cara untuk mengimplementasikan algoritma di atas:
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
TransactionDate
kolom 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 olehFilterFlag
saya menemukan bahwa SQL Server akan mengoptimalkan kolom dari semacam itu dan akan melakukan semacam eksplisit.Solusi ketika kita perlu mengembalikan set hasil dengan
TransactionDate
nilai duplikat untuk hal yang samaProductId
jauh 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
ProductId
danTransactionDate
. 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 denganLEAD
( dengan asumsiProductID
tidak pernah NULL) tanpa jenis tambahan. Untuk nilai penjumlahan berjalan terakhir, saya menggunakanMAX
sebagai fungsi jendela untuk menerapkan nilai di baris terakhir partisi ke semua baris di partisi.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.
sumber