Pencere işlevlerini kullanarak tarih aralığı haddeleme toplamı


56

Bir tarih aralığında bir yuvarlanma toplamı hesaplamam gerekiyor. Örnek olarak, AdventureWorks örnek veritabanını kullanarak, aşağıdaki varsayımsal sözdizimi tam olarak ihtiyacım olanı yapar:

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;

Ne yazık ki, RANGEpencere çerçevesi boyutu şu anda SQL Server'da bir aralığa izin vermiyor.

Bir alt sorgu ve normal (pencere dışı) bir toplama kullanarak bir çözüm yazabileceğimi biliyorum:

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;

Aşağıdaki indeks verilen:

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

Yürütme planı:

Yürütme planı

Korkunç derecede verimsiz olmasa da, bu sorgunun yalnızca SQL Server 2012, 2014 veya 2016'da (şimdiye kadar) desteklenen pencere toplamı ve analitik işlevleri kullanılarak ifade edilmesi mümkün gibi görünüyor.

Netlik için , veriler üzerinden tek bir geçiş yapan bir çözüm arıyorum .

T-SQL'de bunun , OVERfıkranın işi yapacağı ve yürütme planında Window Spools ve Window Aggregates olacağı anlamına gelmesi muhtemeldir . OVERMaddeyi kullanan tüm dil unsurları adil bir oyundur. Bir SQLCLR çözeltisi, doğru sonuçların üretilmesi garanti altına alındığı sürece kabul edilebilir .

T-SQL çözümleri için, yürütme planında daha az Hash, Sorts ve Window Spools / Aggregates daha iyi. Dizin eklemekten çekinmeyin, ancak ayrı yapılara izin verilmez (bu nedenle örneğin önceden tetiklenmiş tabloları tetikleyicilerle senkronize halde tutmaz). Referans tablolarına izin verilir (sayı, tarih vb.)

İdeal olarak, çözümler yukarıdaki alt sorgu sürümüyle aynı sırada tamamen aynı sonuçları verecektir, ancak tartışmalı olarak doğru olan her şey de kabul edilebilir. Performans her zaman dikkate alınır, bu nedenle çözümler en azından makul derecede verimli olmalıdır.

Özel sohbet odası: Bu soru ve cevaplarıyla ilgili tartışmalar için genel bir sohbet odası yarattım. En az 20 itibar puanına sahip herhangi bir kullanıcı doğrudan katılabilir. 20'den az temsilciye sahipseniz ve katılmak istiyorsanız lütfen aşağıdaki yorumda bana ping atın.

Yanıtlar:


42

Harika soru Paul! Biri T-SQL'de diğeri de CLR'de birkaç farklı yaklaşım kullandım.

T-SQL hızlı özeti

T-SQL yaklaşımı aşağıdaki adımlar ile özetlenebilir:

  • Ürünlerin / tarihlerin çapraz ürünlerini alın
  • Gözlenen satış verilerinde birleştirme
  • Bu verileri ürün / tarih seviyesine toplayın
  • Bu toplam verilere göre (doldurulmuş "eksik" günleri içeren) son 45 gün içindeki yuvarlanma toplamlarını hesaplayın
  • Bu sonuçları yalnızca bir veya daha fazla satışı olan ürün / tarih eşleşmelerine göre filtreleyin

Kullanarak SET STATISTICS IO ON, bu yaklaşım raporları Table 'TransactionHistory'. Scan count 1, logical reads 484, "tek geçişi" masanın üzerinden doğrular. Başvuru için, orijinal döngü arama sorgusu raporları Table 'TransactionHistory'. Scan count 113444, logical reads 438366.

Tarafından bildirildiği gibi SET STATISTICS TIME ON, CPU zamanı 514ms. Bu 2231ms, orijinal sorgu için olumludur .

CLR hızlı özeti

CLR özeti aşağıdaki adımlar ile özetlenebilir:

  • Verileri belleğe, ürüne ve tarihe göre sıralanmış olarak okuyun.
  • Her bir işlemi işleme koyarken, toplam maliyetlerin toplamına ekleyin. Bir işlem önceki işlemden farklı bir ürün olduğunda, toplamı 0 olarak sıfırlayın.
  • Geçerli işlemle aynı olan (işlem, tarih) ilk işleme bir işaretçi tutun. Bu son işlem (ürün, tarih) ile karşılaşıldığında, söz konusu işlemin vade toplamını hesaplayın ve aynı işlemdeki tüm işlemlere uygulayın (ürün, tarih)
  • Tüm sonuçları kullanıcıya iade et!

Kullanıldığında SET STATISTICS IO ON, bu yaklaşım hiçbir mantıksal G / Ç olmadığını bildirir! Vay, mükemmel bir çözüm! (Aslında, SET STATISTICS IOCLR içinde meydana gelen G / Ç’leri rapor etmiyor gibi görünüyor . Ancak koddan, tablonun tam olarak bir taramasının yapıldığını ve Paul’ün önerdiği endekse göre verileri aldığını görmek kolaydır.

Tarafından bildirildiği gibi SET STATISTICS TIME ON, CPU zamanı şimdi 187ms. Yani bu, T-SQL yaklaşımında oldukça bir gelişmedir. Ne yazık ki, her iki yaklaşımın geçen geçen süresi, her biri yaklaşık yarım saniyede çok benzerdir. Bununla birlikte, CLR tabanlı yaklaşımın konsola 113K satırlar çıkarması gerekiyor (ürüne / tarihe göre gruplanan T-SQL yaklaşımı için yalnızca 52K'ya göre), bu yüzden bunun yerine CPU zamanına odaklandım.

Bu yaklaşımın bir diğer büyük avantajı, bir ürünün aynı günde birden çok kez satıldığı durumlarda bile, her işlem için bir satır da dahil olmak üzere, orijinal döngü / arama yaklaşımıyla tam olarak aynı sonuçları vermesidir. (AdventureWorks'te, özellikle satır satır sonuçları karşılaştırdım ve Paul'ün orijinal sorgusu ile bağlandıklarını onayladım.)

Bu yaklaşımın bir dezavantajı, en azından şu anki haliyle, bellekteki tüm verileri okumasıdır. Bununla birlikte, tasarlanan algoritma sadece belirli bir zamanda bellekteki mevcut pencere çerçevesine ihtiyaç duyar ve belleği aşan veri kümeleri için çalışmak üzere güncellenebilir. Paul bu noktayı, sadece sürgülü pencereyi bellekte saklayan bu algoritmanın bir uygulamasını üreterek cevabını göstermiştir. Bu, CLR montajına daha yüksek izinler verilmesi pahasına gelir, ancak bu çözümü keyfi olarak büyük veri setlerine yükseltmede kesinlikle faydalı olacaktır.


T-SQL - tarihe göre gruplandırılmış bir tarama

İlk kurulum

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

Sorgu

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)

İcra planı

Yürütme planından, Paul tarafından önerilen orijinal endeksin Production.TransactionHistoryişlem geçmişini olası her ürün / tarih kombinasyonuyla birleştirmek için bir birleştirme birleşimini kullanarak tek bir düzenli tarama yapmamıza izin vermek için yeterli olduğunu görüyoruz .

görüntü tanımını buraya girin

Varsayımlar

Bu yaklaşıma yapılan birkaç önemli varsayım vardır. Sanırım kabul edilebilir olup olmadıklarına karar vermenin Paul'e kalmış olacağını düşünüyorum :)

  • Production.ProductMasayı kullanıyorum . Bu tablo serbestçe kullanılabilir AdventureWorks2012ve ilişki bir yabancı anahtar tarafından zorlanır Production.TransactionHistory, bu yüzden adil bir oyun olarak yorumladım.
  • Bu yaklaşım, işlemlerin üzerinde bir zaman bileşeni olmadığı gerçeğine dayanmaktadır AdventureWorks2012; Bunu yaparlarsa, tam ürün / tarih kombinasyonları setini oluşturmak, ilk önce işlem geçmişini geçmeden mümkün olmazdı.
  • Ürün / tarih çifti başına yalnızca bir satır içeren bir satır kümesi üretiyorum. Bunun "tartışmalı olarak doğru" olduğunu ve çoğu durumda geri dönmenin daha arzulanan bir sonuç olduğunu düşünüyorum. Her ürün / tarih NumOrdersiçin kaç satış olduğunu gösteren bir sütun ekledim . Bir ürünün aynı tarihte birden çok kez satıldığı durumlarda, orijinal sorgunun sonuçları ile önerilen sorgunun sonuçlarının karşılaştırılması için aşağıdaki ekran görüntüsüne bakın (örn., 319/ 2007-09-05 00:00:00.000)

görüntü tanımını buraya girin


CLR - bir tarama, tam gruplanmamış sonuç seti

Ana fonksiyon gövdesi

Burada görülecek bir ton yok; işlevin ana gövdesi girişleri bildirir (karşılık gelen SQL işleviyle eşleşmelidir), bir SQL bağlantısı kurar ve SQLReader'ı açar.

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

Çekirdek mantık

Ana mantığı ayırdım, böylece odaklanmak daha kolay:

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

Yardımcılar

Aşağıdaki mantık satır içi yazılabilir, ancak kendi yöntemlerine ayrıldıkları zaman okumak biraz daha kolaydır.

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'de hepsini bir araya getirmek

Bu noktaya kadar her şey C # içerisindeydi, o yüzden ilgili gerçek SQL'i görelim. (Alternatif olarak, montajı kendiniz derlemek yerine doğrudan montajımın bitlerinden oluşturmak için bu dağıtım komut dosyasını kullanabilirsiniz .)

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

Uyarılar

CLR yaklaşımı, algoritmayı optimize etmek için daha fazla esneklik sağlar ve muhtemelen C # konusunda bir uzman tarafından daha da ayarlanabilir. Bununla birlikte, CLR stratejisinin dezavantajları da vardır. Akılda tutulması gereken birkaç şey:

  • Bu CLR yaklaşımı bellekte ayarlanan verilerin bir kopyasını tutar. Bir akış yaklaşımı kullanmak mümkün, ancak ilk zorluklarla karşılaştım ve SQL 2008+ sürümündeki değişikliklerin bu tür bir yaklaşımı kullanmayı zorlaştırdığından şikayet eden olağanüstü bir Connect sorunu olduğunu tespit ettim. Bu hala mümkün (Paul’un gösterdiği gibi), ancak veritabanını CLR derlemesine ayarlayarak TRUSTWORTHYve vererek daha yüksek düzeyde izinler gerektiriyor EXTERNAL_ACCESS. Bu nedenle, bazı güçlükler ve potansiyel güvenlik uygulamaları söz konusudur, ancak kazanç, AdventureWorks'teki verilerden daha büyük veri kümelerine göre daha iyi ölçeklenebilen bir akış yaklaşımıdır.
  • CLR, bazı DBA'lar için daha az erişilebilir olabilir; böylelikle böyle bir işlevi daha şeffaf olmayan, kolayca değiştirilemeyen, kolayca konuşlandırılmayan ve belki de kolayca hata ayıklanmayan bir kara kutu haline getirir. Bu, T-SQL yaklaşımına kıyasla oldukça büyük bir dezavantaj.


Bonus: T-SQL # 2 - Aslında kullanacağım pratik yaklaşım

Problemi bir süre yaratıcı bir şekilde düşünmeye çalıştıktan sonra, günlük işimde ortaya çıkarsa, bu problemin üstesinden gelmeyi seçeceğimi oldukça basit ve pratik bir şekilde yayınlayacağımı düşündüm. SQL 2012+ pencere işlevselliğinden yararlanır, ancak sorunun umduğu çığır açan bir yöntem değildir:

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

Bu aslında iki ilgili sorgu planının ikisine birden bakarken bile oldukça basit bir genel sorgu planı sunar:

görüntü tanımını buraya girin görüntü tanımını buraya girin

Bu yaklaşımdan hoşlanmamın birkaç nedeni:

  • Sorun açıklamasında istenen tam sonuç kümesini verir (sonuçların gruplandırılmış bir sürümünü döndüren diğer birçok T-SQL çözümünün aksine).
  • Açıklamak, anlamak ve hata ayıklamak kolaydır; Bir yıl sonra geri dönmeyeceğim ve doğruluğu veya performansı bozmadan nasıl küçük bir değişiklik yapabilirim acaba?
  • Orijinal döngü araştırmasından 900msziyade, sağlanan veri setinde çalışır.2700ms
  • Veriler çok daha yoğunsa (günde daha fazla işlem), işlemsel karmaşıklık, sürgülü penceredeki işlem sayısı (orijinal sorguda olduğu gibi) ile karesel olarak artmaz; Bence bu Paul'ün birden fazla taramadan kaçınmak istediği konusundaki endişesinin bir kısmını ele alıyor
  • Yeni tempdb tembel yazma işlevselliği nedeniyle SQL 2012+'nin son güncellemelerinde esasen tempdb I / O ile sonuçlanmadı.
  • Çok büyük veri kümeleri için, bellek baskısı kaygılanırsa çalışmayı her ürün için ayrı gruplara bölmek çok önemlidir.

Birkaç potansiyel uyarılar:

  • Teknik olarak sadece bir kez Production.TransactionHistory tarama yapsa da, gerçekten bir "tek tarama" yaklaşımı değildir; Ancak, kesin yapısını tanımladığımızdan beri üzerinde daha fazla manuel kontrole sahip olduğumuz çalışma masasından çok farklı görmüyorum
  • Ortamınıza bağlı olarak, tempdb kullanımı pozitif (örneğin, ayrı bir SSD sürücü setinde) veya negatif (sunucuda yüksek eşzamanlılık, zaten çok sayıda tempdb çekişmesi) olarak görülebilir.

25

Bu uzun bir cevap, bu yüzden buraya bir özet eklemeye karar verdim.

  • İlk başta, tam olarak aynı sonucu, söz konusu sırayla aynı sırada üreten bir çözüm sunuyorum. Ana tabloyu 3 kez tarar: ProductIDsher bir Ürünün tarih aralığını içeren bir liste almak , her bir gün için maliyetleri toplar (çünkü aynı tarihte birkaç işlem olduğundan), orijinal satırlarla sonuca katılır.
  • Daha sonra, görevi basitleştiren ve ana tablonun son bir taramasını engelleyen iki yaklaşımı karşılaştırdım. Elde ettiği sonuç günlük bir özettir, yani bir Üründeki birçok işlem aynı tarihe sahipse, tek sıra halinde yuvarlanır. Önceki adımdaki yaklaşımım masayı iki kez tarar. Geoff Patterson'ın yaklaşımı masayı bir kez tarar, çünkü tarih aralığı ve Ürün listesi hakkında dış bilgi kullanır.
  • Sonunda yine günlük bir özeti döndüren tek bir geçiş çözümü sunuyorum, ancak tarih aralığı veya listesi hakkında dış bilgi gerektirmiyor ProductIDs.

AdventureWorks2014 veritabanı ve SQL Server Express 2014 kullanacağım .

Orijinal veritabanında yapılan değişiklikler:

  • Değiştirilen tip [Production].[TransactionHistory].[TransactionDate]gelen datetimeetmek date. Zaman bileşeni zaten sıfırdı.
  • Takvim tablosu eklendi [dbo].[Calendar]
  • Dizin eklendi [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);

Fıkra hakkında MSDN makalesinde Itzik Ben-Gan'ın pencere işlevleri hakkında mükemmel bir blog yazısıOVER bağlantısı var . Bu yazıda nasıl çalıştığını, seçenekleri ve seçenekleri arasındaki farkı açıklar ve bir tarih aralığında bir yuvarlama toplamı hesaplamakta bu çok sorundan bahseder. Geçerli SQL Server sürümünün tam olarak uygulanmadığını ve geçici aralık veri türlerini uygulamadığını söyler . Aradaki farkı açıkladı ve bana bir fikir verdi.OVERROWSRANGERANGEROWSRANGE

Boşluklar ve kopyalar olmadan tarihler

TransactionHistoryTabloda boşluklar olmadan ve kopyalar olmadan tarihler varsa , aşağıdaki sorgu doğru sonuçlar verir:

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;

Aslında, 45 satırlık bir pencere tam olarak 45 günü kapsayacaktır.

Kopyaları olmayan boşluklu tarihler

Ne yazık ki, verilerimizin tarihler arasında boşluklar var. Bu sorunu çözmek için Calendar, boşluksuz bir tarihler kümesi oluşturmak için bir tablo kullanabiliriz , daha sonra LEFT JOINbu set için orijinal verileri kullanabilir ve aynı sorguyu kullanabiliriz ROWS BETWEEN 45 PRECEDING AND CURRENT ROW. Bu, yalnızca tarihler tekrarlanmazsa (aynı içinde ProductID) doğru sonuçlar verir .

Çiftleri olan boşluklu tarihler

Ne yazık ki, verilerimiz tarihler arasında hem boşluklar vardır hem de tarihler aynı içinde tekrarlanabilir ProductID. Bu sorunu çözmek için, kopyaları olmayan bir tarihler kümesi oluşturarak GROUPorijinal veriler yapabiliriz ProductID, TransactionDate. Ardından Calendarboşluksuz bir tarihler kümesi oluşturmak için tabloyu kullanın. Daha sonra ROWS BETWEEN 45 PRECEDING AND CURRENT ROWyuvarlamayı hesaplamak için sorguyu kullanabiliriz SUM. Bu doğru sonuçlar üretecektir. Aşağıdaki sorguda yorumları görün.

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
;

Bu sorgunun, alt sorgu kullanan soruya yaklaşımıyla aynı sonuçları verdiğini onayladım.

İcra planları

istatistikler

İlk sorgu alt sorgu kullanır, ikincisi - bu yaklaşım. Bu yaklaşımda süre ve okuma sayısının çok daha az olduğunu görebilirsiniz. Bu yaklaşımda tahmini maliyetin çoğunluğu nihaidir ORDER BY, aşağıya bakınız.

alt sorgu

Alt sorgu yaklaşımı iç içe döngüler ve O(n*n)karmaşıklıkla basit bir plana sahiptir .

bitmiş

Bu yaklaşım için plan TransactionHistorybirkaç kez tarar , ancak hiçbir döngü yoktur. Gördüğünüz gibi tahmini maliyetin% 70'inden fazlası Sortfinal içindir ORDER BY.

io

En iyi sonuç - subquery, alt - OVER.


Ekstra taramalardan kaçınılması

Yukarıdaki plandaki son Dizin Taraması, Birleştir ve Sırala INNER JOINseçeneği, nihai sonucu alt sorgudaki yavaş bir yaklaşımla tamamen aynı yapmak için orijinal tablodaki finalden kaynaklanır. Dönen satırların sayısı TransactionHistorytablodakilerle aynıdır . TransactionHistoryAynı ürün için aynı gün birkaç işlem gerçekleştiğinde satırlar var . Sonuçta yalnızca günlük özeti göstermek uygunsa, bu son JOINkaldırılabilir ve sorgu biraz daha basit ve biraz daha hızlı hale gelir. Son Dizin Tara, Birleştir ve Birleştir önceki plandaki Sıralama, tarafından eklenen satırları kaldıran Filtre ile değiştirilir 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
;

İki tarama

Yine de, TransactionHistoryiki kez taranır. Her ürünün tarih aralığını almak için bir ekstra tarama daha gereklidir. Küresel tarih aralıkları hakkında dış bilgileri kullandığımız başka bir yaklaşımla karşılaştırmayı ve bu fazladan taramayı önlemek için hepsine sahip olan TransactionHistoryekstra bir tablo ile nasıl karşılaştığını görmek ilgimi çekti . Karşılaştırmayı geçerli kılmak için bu sorgudan günlük işlem sayısı hesaplamasını kaldırdım. Her iki sorguya da eklenebilir, ancak karşılaştırma için basit olmasını isterim. Başka tarihler de kullanmak zorunda kaldım, çünkü veritabanının 2014 sürümünü kullandım.ProductProductIDs

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

Tek tarama

Her iki sorgu da aynı sonucu aynı sırayla döndürür.

karşılaştırma

İşte zaman ve IO istatistikleri.

stats2

io2

İki tarama değişkeni biraz daha hızlıdır ve daha az okumaya sahiptir, çünkü bir tarama değişkeninin Worktable'ı çok kullanması gerekir. Ayrıca, tek tarama varyantı planlarda görebileceğiniz gibi gerekenden daha fazla satır oluşturur. Herhangi bir işlem yapmamış olsa bile ProductID, Producttablodaki her biri için tarih oluşturur ProductID. ProductTabloda 504 satır var , ancak sadece 441 üründe işlem gerçekleştiriliyor TransactionHistory. Ayrıca, her ürün için gerekenden daha fazla olan aynı tarih aralığını oluşturur. Daha TransactionHistoryuzun bir genel geçmişe sahip olsaydı, her bir ürün nispeten kısa bir geçmişe sahipse , fazladan gereksiz sıra sayısı daha da artardı.

Öte yandan, sadece biraz daha dar bir endeks oluşturarak iki taramalı varyantı biraz daha optimize etmek mümkündür (ProductID, TransactionDate). Bu indeks, her bir ürün için Başlangıç ​​/ Bitiş tarihlerini hesaplamak için kullanılacaktır ( CTE_Products) ve endeksini kapsayandan daha az sayfa içerecek ve sonuç olarak daha az okumaya sebep olacaktır.

Öyleyse, ekstra basit bir tarama yapmayı ya da üstü açık bir Worktable'ı seçebiliriz.

BTW, yalnızca günlük özetlerle sonuç almak uygunsa, içermeyen bir dizin oluşturmak daha iyidir ReferenceOrderID. Daha az sayfa kullanırdı => daha az G / Ç.

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

CROSS APPLY kullanarak tek geçişli çözüm

Çok uzun bir cevap haline geldi, ancak işte yine sadece günlük özeti döndüren bir başka değişken daha var, ancak verilerin yalnızca bir taramasını yapıyor ve tarih aralığı veya ProductID'lerin listesi hakkında dış bilgi gerektirmiyor. Ara sıra yapmaz. Genel performans, önceki değişkenlere benzer, ancak biraz daha kötü gibi görünüyor.

Ana fikir, tarihlerdeki boşlukları dolduracak satırlar üretmek için bir sayılar tablosu kullanmaktır. Mevcut her tarih LEADiçin boşluğun boyutunu gün cinsinden hesaplamak için kullanın ve ardından CROSS APPLYsonuç kümesine gereken sayıda satır eklemek için kullanın . İlk başta kalıcı bir sayılar tablosu ile denedim. Plan, bu tabloda çok sayıda okuma gösterdi, ancak gerçek süre, anında kullanarak sayılar oluşturduğumda olduğu gibi hemen hemen aynıydı 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
;

Bu plan "daha uzun" çünkü sorgu iki pencere işlevi kullanıyor ( LEADve SUM).

çapraz başvuru

ca istatistikleri

ca io


23

Daha hızlı çalışan ve daha az bellek gerektiren alternatif bir SQLCLR çözümü:

Dağıtım Komut Dosyası

Bu EXTERNAL_ACCESS, (yavaş) içerik bağlantısı yerine hedef sunucuya ve veritabanına geridöngü bağlantı kullandığından izin kümesini gerektirir . İşlev şu şekilde çağırılır:

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;

Soru ile aynı sırada, aynı sonuçları, aynı sırada üretir.

Yürütme planı:

SQLCLR TVF yürütme planı

SQLCLR Kaynak Sorgu yürütme planı

Explorer performans istatistiklerini planlayın

Profiler mantıksal okur: 481

Bu uygulamanın temel avantajı, bağlam bağlantısını kullanmaktan daha hızlı olması ve daha az bellek kullanmasıdır. Herhangi bir anda sadece iki şeyi hafızasında tutar:

  1. Herhangi bir yinelenen satır (aynı ürün ve işlem tarihi). Bu gereklidir, çünkü ürün ya da tarih değişinceye kadar, son koşu toplamının ne olacağını bilmiyoruz. Örnek verilerde, 64 satırlık bir ürün ve tarih birleşimi vardır.
  2. Mevcut ürün için yalnızca 45 günlük kayan maliyet ve işlem tarihleri. Bu, 45 günlük sürgülü pencereden çıkan satırlar için basit koşu toplamını ayarlamak için gereklidir.

Bu minimum önbellekleme, bu yöntemin iyi ölçeklenmesini sağlamalıdır; setin tamamını CLR hafızada tutmaya çalışmaktan kesinlikle daha iyi.

Kaynak kodu


17

SQL Server 2014'ün 64 bit Kurumsal, Geliştirici veya Değerlendirme sürümündeyseniz, Bellek İçi OLTP kullanabilirsiniz . Çözüm tek bir tarama olmayacak ve hiçbir pencere işlevini hiç kullanmayacak ancak bu soruya bir miktar değer katabilir ve kullanılan algoritma muhtemelen diğer çözümlere ilham kaynağı olarak kullanılabilir.

Öncelikle AdventureWorks veritabanında Bellek İçi OLTP'yi etkinleştirmeniz gerekir.

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;

Prosedürün parametresi bir Bellek İçi tablo değişkenidir ve tür olarak tanımlanması gerekir.

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 bu tabloda benzersiz değildir, her kombinasyon için benzersizdir ProductIDve TransactionDate.

Prosedürde size ne yaptığını söyleyen bazı yorumlar var, ancak genel olarak bir döngüdeki toplam çalışmayı hesaplıyor ve her bir yineleme için toplam çalışma gününü 45 gün önce (veya daha fazla) olduğu gibi aramaya çalışıyor.

Şu anki koşu toplamı eksi koşu toplamı, 45 gün önce olduğu gibi, aradığımız toplam 45 gündür.

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;

Böyle bir prosedürü çağır.

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

Bunu bilgisayarımda denemek Müşteri İstatistikleri, yaklaşık 750 milisaniyelik bir Toplam yürütme süresi rapor ediyor. Karşılaştırmalar için alt sorgu versiyonu 3.5 saniye sürmektedir.

Ekstra başıboş:

Bu algoritma normal T-SQL tarafından da kullanılabilir. Çalışan toplamı, rangesatırları kullanarak hesaplayın ve sonucu geçici bir tabloda saklayın. Ardından, bu tabloyu 45 gün önce olduğu gibi koşu toplamına kendiliğinden birleşim ile sorgulayabilir ve toplamı hesaplayabilirsiniz. Bununla birlikte, rangekarşılaştırmanın uygulanması rows, siparişin kopyalarını yan tümce olarak farklı şekilde ele alması gerekmesi nedeniyle oldukça yavaştır, bu yüzden bu yaklaşımla tüm bu iyi performansı alamadım. Bunun için geçici bir çözüm, last_value()çalışan bir toplamı rowssimüle etmek için kullanarak hesaplanan bir koşu toplamı gibi başka bir pencere işlevi kullanmak olabilir range. Başka bir yol kullanmaktır max() over(). Her ikisinin de bazı sorunları vardı. Çeşitlerden kaçınmak için kullanılacak uygun dizini bulmak vemax() over()sürümü. Bunları optimize etmekten vazgeçtim ama şu ana kadar kodla ilgileniyorsanız, lütfen bana bildirin.


13

Bu çok eğlenceliydi :) Çözümüm @ GeoffPatterson'dan biraz daha yavaş ama bunun bir kısmı Geoff'in varsayımlarından birini (yani ürün / tarih çifti başına bir satır) ortadan kaldırmak için orjinal tabloya geri döndüğüm gerçeği. . Bu, son bir sorgunun basitleştirilmiş bir versiyonuydu ve orijinal tablodan ek bilgi gerektirebilirdi.

Not: Geoff'in takvim tablosunu ödünç alıyorum ve aslında çok benzer bir çözüm buldum:

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

İşte sorgunun kendisi:

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;

Temelde, onunla başa çıkmanın en kolay yolunu kullanmak olduğuna karar verdim. ROWS yan tümcesi için seçenek. Ama bu sadece bir başına satır var gerektiriyordu ProductID, TransactionDatekombinasyon değil sadece, ama başına bir satır olması gerekiyordu ProductIDve possible date. Bunu CTE'deki Ürün, takvim ve TransactionHistory tablolarını birleştirerek yaptım. Sonra haddeleme bilgilerini üretmek için başka bir CTE oluşturmak zorunda kaldım. Bunu yapmak zorundaydım, çünkü orijinal masaya doğrudan geri dönersem sonuçlarımı fırlatan sıradan elemeler yaptım. Ondan sonra ikinci CTE'mi orijinal masama geri eklemek basit bir meseleydi. Ben eklemek did TBEkurtulmak için (elimine edilecek) sütununu boş CTEs oluşturulan satırlar. Ayrıca CROSS APPLYilk CTE'de takvim masam için sınırlar oluşturmak için kullandım.

Sonra önerilen dizini ekledim:

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

Ve son icra planını aldım:

görüntü tanımını buraya girin görüntü tanımını buraya girin görüntü tanımını buraya girin

EDIT: Sonunda, takvim tablosuna performansı makul bir marjla arttıran bir endeks ekledim.

CREATE INDEX ix_calendar ON calendar(d)

2
RunningTotal.TBE IS NOT NULLDurum (ve dolayısıyla, TBEsütun) gereksizdir. Düşürürseniz gereksiz satırlar almayacaksınız, çünkü iç birleşim koşulu tarih sütununu içerir - bu nedenle sonuç kümesinin başlangıçta kaynakta bulunmayan tarihleri ​​olamaz.
Andriy M

2
Evet. Tamamen katılıyorum. Ve yine de hala yaklaşık 2 saniye kazanmama neden oldu. Optimizer’ın bazı ek bilgileri bilmesini sağladığını düşünüyorum.
Kenneth Fisher

4

Dizin veya referans tablosu kullanmayan birkaç alternatif çözümüm var. Belki de ek tablolara erişiminizin olmadığı ve dizin oluşturamadığınız durumlarda faydalı olabilirler. TransactionDateVerilerin yalnızca bir kez geçirilmesi ve sadece bir pencere işlevi ile gruplandırırken doğru sonuçlar elde etmek mümkün görünmektedir . Ancak, gruplayamadığınızda bunu yalnızca bir pencere işleviyle yapmanın bir yolunu bulamadım TransactionDate.

Bir referans çerçevesi sağlamak için, makinemde soruya gönderilen orijinal çözümün, kapak indeksi olmadan 2808 ms ve kapak endeksi ile 1950 ms CPU süresi vardır. AdventureWorks2014 veritabanı ve SQL Server Express 2014 ile test ediyorum.

Ne zaman gruplayacağımıza dair bir çözümle başlayalım TransactionDate. Son X gün boyunca geçerli toplam da şu şekilde ifade edilebilir:

Bir satır için çalıştırma toplamı = önceki tüm satırların çalıştırma toplamı - tarihin tarih penceresinin dışında olduğu tüm önceki satırların çalıştırma toplamı.

SQL'de bunu ifade etmenin bir yolu, verilerinizin iki kopyasını almak ve ikinci kopya için, maliyeti -1 ile çarpmak ve tarih sütununa X + 1 gün eklemek. Tüm verilerin üzerinde çalışan bir toplamı hesaplamak yukarıdaki formülü uygulayacaktır. Bunu bazı örnek veriler için göstereceğim. Aşağıda bir single için bazı örnek tarih ProductID. Hesaplamaları kolaylaştırmak için tarihleri ​​sayı olarak temsil ediyorum. Veri başlangıç:

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

Verilerin ikinci bir kopyasını ekleyin. İkinci kopyanın tarihe 46 günü eklenmiş ve maliyet -1 ile çarpılmıştır:

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

DateArtan ve CopiedRowazalan sırayla koşan toplamı alın :

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

İstenilen sonucu elde etmek için kopyalanan satırları filtreleyin:

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

Aşağıdaki SQL, yukarıdaki algoritmayı uygulamanın bir yoludur:

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

Makinemde bu, indeks olmadan 702 ms CPU zamanı ve kaplama endeksi ile 734 ms CPU zamanı aldı. Sorgu planı burada bulunabilir: https://www.brentozar.com/pastetheplan/?id=SJdCsGVSl

Bu çözümün bir dezavantajı, yeni TransactionDatesütuna göre sipariş verirken kaçınılmaz bir sıralama gibi görünmesidir . Bu türün indeksler ekleyerek çözülebileceğini sanmıyorum çünkü sipariş vermeden önce verilerin iki kopyasını birleştirmemiz gerekiyor. ORDER BY dizinine farklı bir sütun ekleyerek, sorgunun sonunda bir sıralamadan kurtulabildim. Eğer sipariş FilterFlagedersem, SQL Server'ın bu sütunu sıralamadan en iyi duruma getireceğini ve açık bir sıralama yapacağını öğrendim.

Aynısı için yinelenen TransactionDatedeğerleri olan bir sonuç kümesi döndürmemiz gerektiğindeki çözümler ProductIdçok daha karmaşıktı. Sorunu, aynı sütuna göre bölünmeye ve sipariş vermeye eşzamanlı olarak gerekecek şekilde özetlerdim. Paul'ün sağladığı sözdizimi, bu sorunu çözmektedir, bu nedenle şaşırtıcı bir şekilde, SQL Server'da bulunan geçerli pencere işlevleriyle (örneğin, sözdizimini genişletmeye gerek kalmayacaksa, ifade etmek zor olmasaydı) ifade etmek çok zor değildir.

Yukarıdaki sorguyu gruplamadan kullanırsam, aynı ProductIdve birden fazla satır olduğunda haddeleme toplamı için farklı değerler alırım TransactionDate. Bunu çözmenin bir yolu, yukarıdakiyle aynı koşu toplamı hesaplamasını yapmak, ancak aynı zamanda bölümdeki son satırı işaretlemektir. Bu, ek bir sıralama yapmadan LEAD( ProductIDasla NULL olmadığı varsayılarak) yapılabilir . Son çalışan toplam değer için, MAXbölümün son satırındaki değeri bölümdeki tüm satırlara uygulamak için bir pencere işlevi olarak kullanırım .

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

Makinemde bu, kapak endeksi olmadan 2464ms CPU zaman aldı. Daha önce olduğu gibi kaçınılmaz bir sıralama gibi görünüyor. Sorgu planı burada bulunabilir: https://www.brentozar.com/pastetheplan/?id=HyWxhGVBl

Yukarıdaki sorguda iyileştirme için yer olduğunu düşünüyorum. İstenen sonucu elde etmek için pencere işlevlerini kullanmanın kesinlikle başka yolları da var.

Sitemizi kullandığınızda şunları okuyup anladığınızı kabul etmiş olursunuz: Çerez Politikası ve Gizlilik Politikası.
Licensed under cc by-sa 3.0 with attribution required.