Tổng số phạm vi ngày sử dụng các chức năng cửa sổ


56

Tôi cần tính một tổng số trong một phạm vi ngày. Để minh họa, sử dụng cơ sở dữ liệu mẫu AdventureWorks , cú pháp giả định sau đây sẽ thực hiện chính xác những gì tôi cần:

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;

Đáng buồn thay, RANGE phạm vi khung cửa sổ hiện không cho phép một khoảng trong SQL Server.

Tôi biết tôi có thể viết một giải pháp bằng cách sử dụng truy vấn con và tổng hợp thông thường (không phải cửa sổ):

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;

Cho chỉ số sau:

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

Kế hoạch thực hiện là:

Kế hoạch thực hiện

Mặc dù không hiệu quả khủng khiếp, nhưng có vẻ như có thể thể hiện truy vấn này bằng cách chỉ sử dụng các hàm tổng hợp và phân tích cửa sổ được hỗ trợ trong SQL Server 2012, 2014 hoặc 2016 (cho đến nay).

Để rõ ràng, tôi đang tìm kiếm một giải pháp thực hiện một lần duy nhất dữ liệu.

Trong T-SQL này có khả năng có nghĩa là các OVERkhoản sẽ làm công việc, và kế hoạch thực hiện sẽ bao gồm Window Ống cuốn và Window Uẩn. Tất cả các yếu tố ngôn ngữ sử dụng OVERmệnh đề là trò chơi công bằng. Một giải pháp SQLCLR được chấp nhận, miễn là nó được đảm bảo để tạo ra kết quả chính xác.

Đối với các giải pháp T-SQL, càng ít Băm, Sắp xếp và Ống cuốn / Tập hợp cửa sổ trong kế hoạch thực hiện thì càng tốt. Vui lòng thêm các chỉ mục, nhưng các cấu trúc riêng biệt không được phép (vì vậy không có bảng được tính toán trước nào được giữ đồng bộ với các kích hoạt, chẳng hạn). Bảng tham chiếu được cho phép (bảng số, ngày, v.v.)

Lý tưởng nhất là các giải pháp sẽ tạo ra kết quả chính xác theo cùng thứ tự như phiên bản truy vấn con ở trên, nhưng bất cứ điều gì được cho là chính xác cũng được chấp nhận. Hiệu suất luôn luôn được xem xét, vì vậy các giải pháp ít nhất phải hiệu quả hợp lý.

Phòng trò chuyện chuyên dụng: Tôi đã tạo một phòng trò chuyện công cộng để thảo luận liên quan đến câu hỏi này và câu trả lời của nó. Bất kỳ người dùng nào có ít nhất 20 điểm danh tiếng đều có thể tham gia trực tiếp. Vui lòng ping tôi trong một bình luận bên dưới nếu bạn có ít hơn 20 đại diện và muốn tham gia.

Câu trả lời:


42

Câu hỏi tuyệt vời, Paul! Tôi đã sử dụng một vài cách tiếp cận khác nhau, một trong T-SQL và một trong CLR.

Tóm tắt nhanh T-SQL

Cách tiếp cận T-SQL có thể được tóm tắt như các bước sau:

  • Lấy sản phẩm chéo của sản phẩm / ngày
  • Hợp nhất dữ liệu bán hàng được quan sát
  • Tổng hợp dữ liệu đó đến cấp sản phẩm / ngày
  • Tính tổng số tiền trong 45 ngày qua dựa trên dữ liệu tổng hợp này (có chứa bất kỳ ngày "mất tích" nào được điền vào)
  • Lọc các kết quả đó thành chỉ các cặp sản phẩm / ngày có một hoặc nhiều doanh số

Sử dụng SET STATISTICS IO ON, cách tiếp cận này báo cáo Table 'TransactionHistory'. Scan count 1, logical reads 484, xác nhận "vượt qua một lần" trên bảng. Để tham khảo, các báo cáo truy vấn tìm kiếm vòng lặp ban đầu Table 'TransactionHistory'. Scan count 113444, logical reads 438366.

Theo báo cáo SET STATISTICS TIME ON, thời gian CPU là 514ms. Điều này so sánh thuận lợi 2231mscho các truy vấn ban đầu.

Tóm tắt nhanh CLR

Tóm tắt CLR có thể được tóm tắt như các bước sau:

  • Đọc dữ liệu vào bộ nhớ, sắp xếp theo sản phẩm và ngày
  • Trong khi xử lý mỗi giao dịch, hãy thêm vào tổng chi phí. Bất cứ khi nào một giao dịch là một sản phẩm khác với giao dịch trước đó, hãy đặt lại tổng số đang chạy về 0.
  • Duy trì một con trỏ đến giao dịch đầu tiên có cùng (sản phẩm, ngày) với giao dịch hiện tại. Bất cứ khi nào giao dịch cuối cùng với (sản phẩm, ngày) đó gặp phải, hãy tính tổng số tiền cho giao dịch đó và áp dụng nó cho tất cả các giao dịch có cùng (sản phẩm, ngày)
  • Trả lại tất cả các kết quả cho người dùng!

Sử dụng SET STATISTICS IO ON, phương pháp này báo cáo rằng không có I / O logic nào xảy ra! Wow, một giải pháp hoàn hảo! (Trên thực tế, có vẻ như SET STATISTICS IOkhông báo cáo I / O phát sinh trong CLR. Nhưng từ mã, có thể dễ dàng thấy rằng chính xác một lần quét của bảng được thực hiện và truy xuất dữ liệu theo chỉ mục mà Paul đề xuất.

Theo báo cáo SET STATISTICS TIME ON, thời gian CPU là187ms . Vì vậy, đây là một cải tiến so với phương pháp T-SQL. Thật không may, thời gian trôi qua tổng thể của cả hai phương pháp rất giống nhau ở khoảng nửa giây mỗi lần. Tuy nhiên, cách tiếp cận dựa trên CLR phải xuất 113K hàng cho bàn điều khiển (so với chỉ 52K cho cách tiếp cận T-SQL nhóm theo sản phẩm / ngày), vì vậy đó là lý do tại sao tôi tập trung vào thời gian CPU.

Một ưu điểm lớn khác của phương pháp này là nó mang lại kết quả chính xác như cách tiếp cận vòng lặp / tìm kiếm ban đầu, bao gồm một hàng cho mọi giao dịch ngay cả trong trường hợp sản phẩm được bán nhiều lần trong cùng một ngày. (Trên AdventureWorks, tôi đặc biệt so sánh các kết quả theo từng hàng và xác nhận rằng chúng liên kết với truy vấn ban đầu của Paul.)

Một nhược điểm của phương pháp này, ít nhất là ở dạng hiện tại, là nó đọc tất cả dữ liệu trong bộ nhớ. Tuy nhiên, thuật toán đã được thiết kế chỉ cần đúng khung cửa sổ hiện tại trong bộ nhớ tại bất kỳ thời điểm nào và có thể được cập nhật để hoạt động cho các tập dữ liệu vượt quá bộ nhớ. Paul đã minh họa điểm này trong câu trả lời của mình bằng cách tạo ra một triển khai thuật toán này chỉ lưu trữ cửa sổ trượt trong bộ nhớ. Điều này dẫn đến chi phí cấp quyền cao hơn cho lắp ráp CLR, nhưng chắc chắn sẽ có giá trị trong việc nhân rộng giải pháp này lên các tập dữ liệu lớn tùy ý.


T-SQL - một lần quét, được nhóm theo ngày

Thiết lập ban đầu

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

Truy vấn

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)

Kế hoạch thực hiện

Từ kế hoạch thực hiện, chúng tôi thấy rằng chỉ số ban đầu do Paul đề xuất là đủ để cho phép chúng tôi thực hiện một lần quét theo thứ tự Production.TransactionHistory, sử dụng phép nối hợp nhất để kết hợp lịch sử giao dịch với mỗi kết hợp sản phẩm / ngày có thể.

nhập mô tả hình ảnh ở đây

Giả định

Có một vài giả định quan trọng được đưa vào phương pháp này. Tôi cho rằng sẽ tùy thuộc vào Paul để quyết định xem họ có chấp nhận được không :)

  • Tôi đang sử dụng Production.Productbảng. Bảng này có sẵn miễn phí AdventureWorks2012và mối quan hệ được thực thi bởi một khóa ngoại từ Production.TransactionHistory, vì vậy tôi hiểu đây là trò chơi công bằng.
  • Cách tiếp cận này dựa trên thực tế là các giao dịch không có thành phần thời gian trên AdventureWorks2012; nếu họ đã làm, việc tạo ra toàn bộ kết hợp sản phẩm / ngày sẽ không còn có thể thực hiện được nếu không vượt qua lịch sử giao dịch.
  • Tôi đang sản xuất một hàng chỉ chứa một hàng cho mỗi cặp sản phẩm / ngày. Tôi nghĩ rằng điều này là "chính xác" và trong nhiều trường hợp, một kết quả mong muốn hơn để trở lại. Đối với mỗi sản phẩm / ngày, tôi đã thêm một NumOrderscột để cho biết có bao nhiêu doanh số đã xảy ra. Xem ảnh chụp màn hình sau đây để so sánh kết quả của truy vấn ban đầu so với truy vấn được đề xuất trong trường hợp sản phẩm được bán nhiều lần trong cùng một ngày (ví dụ: 319/ 2007-09-05 00:00:00.000)

nhập mô tả hình ảnh ở đây


CLR - một lần quét, tập kết quả chưa được nhóm

Cơ quan chức năng chính

Không có một tấn để xem ở đây; phần chính của hàm khai báo các đầu vào (phải khớp với hàm SQL tương ứng), thiết lập kết nối SQL và mở 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;
    }
}

Logic cốt lõi

Tôi đã tách ra logic chính để dễ tập trung hơn vào:

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

Người giúp việc

Logic sau đây có thể được viết nội tuyến, nhưng sẽ dễ đọc hơn một chút khi chúng được chia thành các phương thức riêng.

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

Liên kết tất cả lại với nhau trong SQL

Mọi thứ cho đến thời điểm này đều có trong C #, vì vậy hãy xem SQL thực tế có liên quan. (Ngoài ra, bạn có thể sử dụng tập lệnh triển khai này để tạo tập hợp trực tiếp từ các bit của tập hợp của tôi thay vì tự biên dịch.)

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

Hãy cẩn thận

Cách tiếp cận CLR cung cấp sự linh hoạt hơn rất nhiều để tối ưu hóa thuật toán và có thể nó còn có thể được điều chỉnh bởi một chuyên gia về C #. Tuy nhiên, cũng có những nhược điểm đối với chiến lược CLR. Một số điều cần ghi nhớ:

  • Cách tiếp cận CLR này giữ một bản sao của tập dữ liệu trong bộ nhớ. Có thể sử dụng cách tiếp cận phát trực tuyến, nhưng tôi gặp phải những khó khăn ban đầu và thấy rằng có một vấn đề Kết nối nổi bật phàn nàn rằng những thay đổi trong SQL 2008+ khiến việc sử dụng kiểu tiếp cận này trở nên khó khăn hơn. Điều đó vẫn có thể (như Paul chứng minh), nhưng đòi hỏi mức độ cấp phép cao hơn bằng cách đặt cơ sở dữ liệu TRUSTWORTHYvà cấp EXTERNAL_ACCESScho hội đồng CLR. Vì vậy, có một số rắc rối và tiềm ẩn bảo mật tiềm ẩn, nhưng phần thưởng là một cách tiếp cận phát trực tuyến có thể mở rộng tốt hơn cho các tập dữ liệu lớn hơn nhiều so với trên AdventureWorks.
  • CLR có thể khó tiếp cận hơn đối với một số DBA, làm cho chức năng như vậy trở thành một hộp đen không trong suốt, không dễ sửa đổi, không dễ triển khai và có lẽ không dễ gỡ lỗi. Đây là một bất lợi khá lớn khi so sánh với cách tiếp cận T-SQL.


Phần thưởng: T-SQL # 2 - cách tiếp cận thực tế tôi thực sự sử dụng

Sau khi cố gắng suy nghĩ về vấn đề một cách sáng tạo trong một thời gian, tôi nghĩ tôi cũng sẽ đăng một cách khá đơn giản, thực tế mà tôi có thể chọn để giải quyết vấn đề này nếu nó xuất hiện trong công việc hàng ngày của tôi. Nó sử dụng chức năng cửa sổ SQL 2012+, nhưng không phải là cách thức đột phá mà câu hỏi đã hy vọng:

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

Điều này thực sự mang lại một kế hoạch truy vấn tổng thể khá đơn giản, ngay cả khi nhìn vào cả hai kế hoạch truy vấn có liên quan với nhau:

nhập mô tả hình ảnh ở đây nhập mô tả hình ảnh ở đây

Một vài lý do tôi thích cách tiếp cận này:

  • Nó mang lại tập kết quả đầy đủ được yêu cầu trong báo cáo vấn đề (trái ngược với hầu hết các giải pháp T-SQL khác, trả về một phiên bản được nhóm lại của các kết quả).
  • Thật dễ dàng để giải thích, hiểu và gỡ lỗi; Tôi sẽ không quay lại một năm sau đó và tự hỏi làm thế nào tôi có thể tạo ra một thay đổi nhỏ mà không làm hỏng tính chính xác hoặc hiệu suất
  • Nó chạy trong khoảng 900mstrên tập dữ liệu được cung cấp, thay vì 2700mstìm kiếm vòng lặp ban đầu
  • Nếu dữ liệu dày đặc hơn (nhiều giao dịch hơn mỗi ngày), thì độ phức tạp tính toán không tăng theo phương trình bậc hai với số lượng giao dịch trong cửa sổ trượt (như đối với truy vấn ban đầu); Tôi nghĩ rằng điều này giải quyết một phần mối quan tâm của Paul về việc muốn tránh nhiều lần quét
  • Về cơ bản, kết quả là không có I / O tempdb trong các bản cập nhật gần đây của SQL 2012+ do chức năng ghi lười biếng tempdb mới
  • Đối với các tập dữ liệu rất lớn, việc chia công việc thành các lô riêng biệt cho từng sản phẩm là rất nhỏ nếu áp lực bộ nhớ trở thành mối lo ngại

Một vài cảnh báo tiềm năng:

  • Mặc dù về mặt kỹ thuật, nó thực hiện quét Production.TransactionHistory chỉ một lần, nhưng đây không thực sự là cách tiếp cận "một lần quét" vì bảng #temp có kích thước tương tự và cũng sẽ cần thực hiện thêm I / O logic trên bảng đó. Tuy nhiên, tôi không thấy điều này quá khác so với một bảng làm việc mà chúng ta có nhiều quyền kiểm soát thủ công hơn vì chúng ta đã xác định cấu trúc chính xác của nó
  • Tùy thuộc vào môi trường của bạn, việc sử dụng tempdb có thể được xem là tích cực (ví dụ: trên một bộ ổ SSD riêng) hoặc âm tính (đồng thời cao trên máy chủ, đã có rất nhiều tranh chấp tempdb)

25

Đây là một câu trả lời dài, vì vậy tôi quyết định thêm một bản tóm tắt ở đây.

  • Đầu tiên tôi trình bày một giải pháp tạo ra kết quả chính xác theo cùng một thứ tự như trong câu hỏi. Nó quét bảng chính 3 lần: để có danh sáchProductIDs với phạm vi ngày cho mỗi Sản phẩm, để tổng hợp chi phí cho mỗi ngày (vì có một số giao dịch có cùng ngày), để kết quả với các hàng ban đầu.
  • Tiếp theo tôi so sánh hai cách tiếp cận đơn giản hóa nhiệm vụ và tránh một lần quét cuối cùng của bảng chính. Kết quả của họ là một bản tóm tắt hàng ngày, tức là nếu một số giao dịch trên Sản phẩm có cùng ngày họ được cuộn thành một hàng. Cách tiếp cận của tôi từ bước trước quét bảng hai lần. Cách tiếp cận của Geoff Patterson quét bảng một lần, bởi vì anh ta sử dụng kiến ​​thức bên ngoài về phạm vi ngày và danh sách Sản phẩm.
  • Cuối cùng tôi trình bày một giải pháp vượt qua duy nhất một lần nữa trả về một bản tóm tắt hàng ngày, nhưng nó không đòi hỏi kiến ​​thức bên ngoài về phạm vi ngày hoặc danh sách ProductIDs.

Tôi sẽ sử dụng cơ sở dữ liệu AdventureWorks2014 và SQL Server Express 2014.

Thay đổi cơ sở dữ liệu gốc:

  • Thay đổi loại [Production].[TransactionHistory].[TransactionDate]từ datetimeđến date. Thành phần thời gian bằng không.
  • Đã thêm bảng lịch [dbo].[Calendar]
  • Đã thêm chỉ mục vào [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);

Bài viết về OVERmệnh đề MSDN có một liên kết đến một bài đăng blog tuyệt vời về các chức năng cửa sổ của Itzik Ben-Gan. Trong bài đăng đó, ông giải thích cách thức OVERhoạt động, sự khác biệt giữa ROWSRANGEcác tùy chọn và đề cập đến chính vấn đề này trong việc tính toán tổng số trong một phạm vi ngày. Ông đề cập rằng phiên bản hiện tại của SQL Server không triển khai RANGEđầy đủ và không triển khai các kiểu dữ liệu khoảng thời gian. Giải thích của anh ấy về sự khác biệt giữa ROWSRANGEcho tôi một ý tưởng.

Ngày không có khoảng cách và trùng lặp

Nếu TransactionHistorybảng chứa ngày không có khoảng trống và không trùng lặp, thì truy vấn sau đây sẽ cho kết quả chính xác:

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;

Thật vậy, một cửa sổ 45 hàng sẽ bao gồm chính xác 45 ngày.

Ngày có khoảng trống không trùng lặp

Thật không may, dữ liệu của chúng tôi có khoảng cách về ngày. Để giải quyết vấn đề này, chúng ta có thể sử dụng một Calendarbảng để tạo ra một tập hợp các ngày không có khoảng trống, sau đó LEFT JOINdữ liệu gốc cho tập hợp này và sử dụng cùng một truy vấn với ROWS BETWEEN 45 PRECEDING AND CURRENT ROW. Điều này sẽ tạo ra kết quả chính xác chỉ khi ngày không lặp lại (trong cùng một ProductID).

Ngày có khoảng trống với trùng lặp

Thật không may, dữ liệu của chúng tôi có cả khoảng cách về ngày và ngày có thể lặp lại trong cùng một ProductID. Để giải quyết vấn đề này, chúng ta có thể tạo GROUPdữ liệu gốc bằng cách ProductID, TransactionDatetạo một tập hợp ngày mà không trùng lặp. Sau đó sử dụng Calendarbảng để tạo một tập hợp các ngày không có khoảng trống. Sau đó chúng ta có thể sử dụng truy vấn với ROWS BETWEEN 45 PRECEDING AND CURRENT ROWđể tính toán cán SUM. Điều này sẽ tạo ra kết quả chính xác. Xem bình luận trong truy vấn dưới đây.

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
;

Tôi xác nhận rằng truy vấn này tạo ra kết quả tương tự như cách tiếp cận từ câu hỏi sử dụng truy vấn con.

Kế hoạch thực hiện

số liệu thống kê

Truy vấn đầu tiên sử dụng truy vấn con, thứ hai - phương pháp này. Bạn có thể thấy rằng thời lượng và số lần đọc ít hơn nhiều trong phương pháp này. Phần lớn chi phí ước tính trong phương pháp này là cuối cùng ORDER BY, xem bên dưới.

truy vấn con

Phương pháp truy vấn con có một kế hoạch đơn giản với các vòng lặp lồng nhau và O(n*n)độ phức tạp.

kết thúc

Kế hoạch cho phương pháp này quét TransactionHistorynhiều lần, nhưng không có vòng lặp. Như bạn có thể thấy hơn 70% chi phí ước tính là Sortcuối cùng ORDER BY.

io

Kết quả hàng đầu - subquery, dưới cùng - OVER.


Tránh quét thêm

Quét chỉ mục cuối cùng, Hợp nhất Tham gia và Sắp xếp trong kế hoạch ở trên là do trận chung kết INNER JOINvới bảng ban đầu để tạo ra kết quả cuối cùng giống hệt như cách tiếp cận chậm với truy vấn phụ. Số lượng hàng trả về giống như trong TransactionHistorybảng. Có hàng trong TransactionHistorykhi một số giao dịch xảy ra trong cùng một ngày cho cùng một sản phẩm. Nếu kết quả chỉ hiển thị tóm tắt hàng ngày trong kết quả, thì kết quả cuối cùng này JOINcó thể được loại bỏ và truy vấn trở nên đơn giản hơn và nhanh hơn một chút. Quét chỉ mục cuối cùng, Hợp nhất tham gia và sắp xếp từ gói trước được thay thế bằng Bộ lọc, loại bỏ các hàng được thêm bởi 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
;

quét hai lần

Tuy nhiên, TransactionHistoryđược quét hai lần. Cần thêm một lần quét để có được phạm vi ngày cho mỗi sản phẩm. Tôi đã quan tâm để xem làm thế nào nó so sánh với một cách tiếp cận khác, nơi chúng tôi sử dụng kiến ​​thức bên ngoài về phạm vi ngày toàn cầu TransactionHistory, cộng với bảng bổ sung Productcó tất cả ProductIDsđể tránh việc quét thêm đó. Tôi đã loại bỏ tính toán số lượng giao dịch mỗi ngày khỏi truy vấn này để so sánh hợp lệ. Nó có thể được thêm vào trong cả hai truy vấn, nhưng tôi muốn giữ nó đơn giản để so sánh. Tôi cũng phải sử dụng các ngày khác, vì tôi sử dụng phiên bản 2014 của cơ sở dữ liệu.

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

quét một lần

Cả hai truy vấn trả về cùng một kết quả theo cùng một thứ tự.

So sánh

Dưới đây là số liệu thống kê thời gian và IO.

số liệu thống kê2

io2

Biến thể hai lần quét nhanh hơn một chút và có ít lần đọc hơn, vì biến thể một lần quét phải sử dụng Worktable rất nhiều. Ngoài ra, biến thể quét một lần tạo ra nhiều hàng hơn mức cần thiết như bạn có thể thấy trong các kế hoạch. Nó tạo ngày cho mỗi ProductIDcái trong Productbảng, ngay cả khi ProductIDkhông có bất kỳ giao dịch nào. Có 504 hàng trong Productbảng, nhưng chỉ có 441 sản phẩm có giao dịch TransactionHistory. Ngoài ra, nó tạo ra cùng một phạm vi ngày cho mỗi sản phẩm, nhiều hơn mức cần thiết. Nếu TransactionHistorycó lịch sử tổng thể dài hơn, với mỗi sản phẩm riêng lẻ có lịch sử tương đối ngắn, số lượng hàng không cần thiết thêm sẽ còn cao hơn.

Mặt khác, có thể tối ưu hóa biến thể hai lần quét hơn một chút bằng cách tạo một chỉ mục khác, hẹp hơn trên chỉ (ProductID, TransactionDate). Chỉ mục này sẽ được sử dụng để tính ngày bắt đầu / ngày kết thúc cho mỗi sản phẩm ( CTE_Products) và nó sẽ có ít trang hơn so với chỉ số che phủ và kết quả là gây ra ít đọc hơn.

Vì vậy, chúng ta có thể chọn, hoặc có thêm một lần quét đơn giản rõ ràng hoặc có một Worktable ẩn.

BTW, nếu có kết quả chỉ với các bản tóm tắt hàng ngày thì tốt hơn là tạo một chỉ mục không bao gồm ReferenceOrderID. Nó sẽ sử dụng ít trang hơn => ít IO hơn.

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

Giải pháp vượt qua đơn sử dụng CROSS ỨNG DỤNG

Nó trở thành một câu trả lời thực sự dài, nhưng đây là một biến thể nữa chỉ trả về tóm tắt hàng ngày một lần nữa, nhưng nó chỉ quét một lần dữ liệu và nó không yêu cầu kiến ​​thức bên ngoài về phạm vi ngày hoặc danh sách ProductID. Nó cũng không thực hiện Sắp xếp trung gian. Hiệu suất tổng thể tương tự như các biến thể trước đó, mặc dù có vẻ tệ hơn một chút.

Ý tưởng chính là sử dụng một bảng số để tạo các hàng sẽ lấp đầy các khoảng trống theo ngày. Đối với mỗi ngày hiện tại, hãy sử dụng LEADđể tính kích thước của khoảng cách tính theo ngày và sau đó sử dụng CROSS APPLYđể thêm số lượng hàng cần thiết vào tập kết quả. Lúc đầu, tôi đã thử nó với một bảng số vĩnh viễn. Kế hoạch cho thấy số lượng đọc lớn trong bảng này, mặc dù thời lượng thực tế khá giống nhau, như khi tôi tạo số khi đang sử dụng 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
;

Gói này "dài hơn", vì truy vấn sử dụng hai hàm cửa sổ ( LEADSUM).

áp dụng chéo

chỉ số ca

ca io


23

Một giải pháp SQLCLR thay thế thực thi nhanh hơn và cần ít bộ nhớ hơn:

Kịch bản triển khai

Điều đó yêu cầu EXTERNAL_ACCESSthiết lập quyền vì nó sử dụng kết nối loopback đến máy chủ và cơ sở dữ liệu đích thay vì kết nối ngữ cảnh (chậm). Đây là cách gọi hàm:

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;

Tạo ra kết quả chính xác như nhau, theo cùng một thứ tự, như câu hỏi.

Kế hoạch thực hiện:

Kế hoạch thực hiện SQLCLR TVF

Kế hoạch thực hiện truy vấn nguồn SQLCLR

Thống kê hiệu suất của Explorer

Profiler logic đọc: 481

Ưu điểm chính của việc thực hiện này là nhanh hơn so với sử dụng kết nối ngữ cảnh và nó sử dụng ít bộ nhớ hơn. Nó chỉ giữ hai thứ trong bộ nhớ bất cứ lúc nào:

  1. Bất kỳ hàng trùng lặp (cùng sản phẩm và ngày giao dịch). Điều này là bắt buộc vì cho đến khi sản phẩm hoặc ngày thay đổi, chúng tôi không biết tổng tiền chạy cuối cùng sẽ là bao nhiêu. Trong dữ liệu mẫu, có một kết hợp sản phẩm và ngày có 64 hàng.
  2. Một phạm vi 45 ngày chi phí và ngày giao dịch chỉ dành cho sản phẩm hiện tại. Điều này là cần thiết để điều chỉnh tổng chạy đơn giản cho các hàng rời khỏi cửa sổ trượt 45 ngày.

Bộ nhớ đệm tối thiểu này phải đảm bảo phương pháp này có tỷ lệ tốt; chắc chắn tốt hơn là cố gắng giữ toàn bộ bộ đầu vào trong bộ nhớ CLR.

Mã nguồn


17

Nếu bạn đang sử dụng phiên bản 64-bit Enterprise, Nhà phát triển hoặc Đánh giá của SQL Server 2014, bạn có thể sử dụng OLTP trong bộ nhớ . Giải pháp sẽ không phải là một lần quét và hầu như không sử dụng bất kỳ chức năng cửa sổ nào, nhưng nó có thể thêm một số giá trị cho câu hỏi này và thuật toán được sử dụng có thể được sử dụng làm nguồn cảm hứng cho các giải pháp khác.

Trước tiên, bạn cần bật OLTP trong bộ nhớ trên cơ sở dữ liệu 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;

Tham số của thủ tục là một biến trong bảng Bộ nhớ và phải được xác định là một kiểu.

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 không phải là duy nhất trong bảng này, nó là duy nhất cho mỗi kết hợp ProductIDTransactionDate.

Có một số ý kiến ​​trong quy trình cho bạn biết nó làm gì nhưng nhìn chung nó đang tính tổng chạy trong một vòng lặp và với mỗi lần lặp, nó sẽ tìm kiếm tổng số chạy như cách đây 45 ngày (hoặc hơn).

Tổng số hoạt động hiện tại trừ tổng số hoạt động như 45 ngày trước là tổng số 45 ngày chúng tôi đang tìm kiếm.

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;

Gọi thủ tục như thế này.

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

Kiểm tra điều này trên máy tính của tôi Thống kê khách hàng báo cáo Tổng thời gian thực hiện khoảng 750 mili giây. Để so sánh, phiên bản truy vấn phụ mất 3,5 giây.

Lan man thêm:

Thuật toán này cũng có thể được sử dụng bởi T-SQL thông thường. Tính tổng chạy, rangekhông sử dụng hàng và lưu kết quả vào bảng tạm thời. Sau đó, bạn có thể truy vấn bảng đó với một tham gia tự đến tổng số đang chạy như cách đây 45 ngày và tính tổng số cán. Tuy nhiên, việc thực hiện rangeso với rowskhá chậm do thực tế là cần xử lý các bản sao của thứ tự theo mệnh đề khác nhau nên tôi không đạt được hiệu quả tốt với phương pháp này. Một cách giải quyết khác có thể là sử dụng một chức năng cửa sổ khác như last_value()trên tổng số chạy được tính toán bằng cách sử dụng rowsđể mô phỏng rangetổng số đang chạy. Một cách khác là sử dụngmax() over() . Cả hai đã có một số vấn đề. Tìm chỉ mục thích hợp để sử dụng để tránh sắp xếp và tránh các cuộn chỉ vớimax() over()phiên bản. Tôi đã từ bỏ tối ưu hóa những điều đó nhưng nếu bạn quan tâm đến mã tôi có cho đến nay xin vui lòng cho tôi biết.


13

Chà, thật vui . Tôi đã đi với giả định đây là phiên bản đơn giản của truy vấn cuối cùng và có thể yêu cầu thêm thông tin ngoài bảng gốc.

Lưu ý: Tôi đang mượn bảng lịch của Geoff và trên thực tế đã kết thúc với một giải pháp rất giống nhau:

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

Đây là truy vấn chính nó:

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;

Về cơ bản tôi đã quyết định rằng cách dễ nhất để đối phó với nó là sử dụng tùy chọn cho mệnh đề ROWS. Nhưng điều đó đòi hỏi rằng tôi chỉ có một hàng mỗi ProductID, TransactionDatekết hợp và không chỉ vậy, mà tôi phải có một hàng cho mỗi ProductIDpossible date. Tôi đã thực hiện việc kết hợp các bảng Sản phẩm, lịch và Giao dịch trong CTE. Sau đó, tôi đã phải tạo một CTE khác để tạo ra thông tin cuộn. Tôi đã phải làm điều này bởi vì nếu tôi tham gia nó trở lại bảng ban đầu trực tiếp, tôi đã loại bỏ hàng đã làm mất kết quả của tôi. Sau đó, việc tham gia CTE thứ hai của tôi trở lại bảng ban đầu là một vấn đề đơn giản. Tôi đã thêm TBEcột (sẽ được loại bỏ) để loại bỏ các hàng trống được tạo trong CTEs. Ngoài ra, tôi đã sử dụng một CROSS APPLYCTE ban đầu để tạo ranh giới cho bảng lịch của mình.

Sau đó tôi đã thêm chỉ số được đề xuất:

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

Và đã có kế hoạch thực hiện cuối cùng:

nhập mô tả hình ảnh ở đây nhập mô tả hình ảnh ở đây nhập mô tả hình ảnh ở đây

EDIT: Cuối cùng, tôi đã thêm một chỉ mục trên bảng lịch giúp tăng hiệu suất bằng một mức hợp lý.

CREATE INDEX ix_calendar ON calendar(d)

2
Điều RunningTotal.TBE IS NOT NULLkiện (và, do đó, TBEcột) là không cần thiết. Bạn sẽ không nhận được các hàng thừa nếu bạn bỏ nó, bởi vì điều kiện nối bên trong của bạn bao gồm cột ngày - do đó, tập kết quả không thể có ngày ban đầu không có trong nguồn.
Andriy M

2
Vâng. Tôi hoàn toàn đồng ý. Nhưng nó vẫn khiến tôi tăng thêm khoảng 2 giây. Tôi nghĩ rằng nó cho trình tối ưu hóa biết một số thông tin bổ sung.
Kenneth Fisher

4

Tôi có một vài giải pháp thay thế không sử dụng chỉ mục hoặc bảng tham chiếu. Có lẽ chúng có thể hữu ích trong các tình huống mà bạn không có quyền truy cập vào bất kỳ bảng bổ sung nào và không thể tạo chỉ mục. Dường như có thể có được kết quả chính xác khi nhóm TransactionDatechỉ bằng một lần truyền dữ liệu và chỉ một chức năng cửa sổ duy nhất. Tuy nhiên, tôi không thể tìm ra cách để làm điều đó chỉ với một chức năng cửa sổ khi bạn không thể nhóm theo TransactionDate.

Để cung cấp khung tham chiếu, trên máy của tôi, giải pháp ban đầu được đăng trong câu hỏi có thời gian CPU là 2808 ms mà không có chỉ số bao phủ và 1950 ms với chỉ số bao phủ. Tôi đang thử nghiệm với cơ sở dữ liệu AdventureWorks2014 và SQL Server Express 2014.

Hãy bắt đầu với một giải pháp khi chúng ta có thể nhóm theo TransactionDate. Tổng số tiền chạy trong X ngày qua cũng có thể được thể hiện theo cách sau:

Tổng chạy cho một hàng = tổng chạy của tất cả các hàng trước - tổng chạy của tất cả các hàng trước đó có ngày nằm ngoài cửa sổ ngày.

Trong SQL, một cách để thể hiện điều này là tạo hai bản sao dữ liệu của bạn và cho bản sao thứ hai, nhân chi phí với -1 và thêm X + 1 ngày vào cột ngày. Tính toán một tổng số chạy trên tất cả các dữ liệu sẽ thực hiện công thức trên. Tôi sẽ hiển thị điều này cho một số dữ liệu ví dụ. Dưới đây là một số ngày mẫu cho một ProductID. Tôi biểu diễn ngày dưới dạng số để làm cho phép tính dễ dàng hơn. Dữ liệu bắt đầu:

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

Thêm vào một bản sao thứ hai của dữ liệu. Bản sao thứ hai có 46 ngày được thêm vào ngày và chi phí nhân với -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 
╚══════╩══════╩═══════════╝

Lấy tổng số chạy theo thứ tự Datetăng dần và CopiedRowgiảm dầ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 
╚══════╩══════╩═══════════╩════════════╝

Lọc ra các hàng đã sao chép để có kết quả mong muốn:

╔══════╦══════╦═══════════╦════════════╗
 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 sau đây là một cách để thực hiện thuật toán trên:

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

Trên máy của tôi, việc này mất 702 ms thời gian CPU với chỉ số bao phủ và 734 ms thời gian CPU mà không có chỉ mục. Gói truy vấn có thể được tìm thấy ở đây: https://www.brentozar.com/pastetheplan/?id=SJdCsGVSl

Một nhược điểm của giải pháp này là dường như có một loại không thể tránh khỏi khi đặt hàng theo TransactionDatecột mới . Tôi không nghĩ rằng loại này có thể được giải quyết bằng cách thêm chỉ mục vì chúng tôi cần kết hợp hai bản sao của dữ liệu trước khi thực hiện đặt hàng. Tôi đã có thể loại bỏ một loại ở cuối truy vấn bằng cách thêm vào một cột khác để ĐẶT HÀNG. Nếu tôi đặt hàng bởi FilterFlagtôi thấy rằng SQL Server sẽ tối ưu hóa cột đó từ sắp xếp và sẽ thực hiện sắp xếp rõ ràng.

Các giải pháp khi chúng ta cần trả về một tập kết quả với TransactionDatecác giá trị trùng lặp cho cùng một ProductIdthứ phức tạp hơn nhiều. Tôi sẽ tóm tắt vấn đề là đồng thời cần phân vùng và sắp xếp theo cùng một cột. Cú pháp mà Paul cung cấp đã giải quyết vấn đề đó nên không ngạc nhiên khi rất khó diễn đạt với các hàm cửa sổ hiện tại có sẵn trong SQL Server (nếu không khó diễn đạt thì sẽ không cần phải mở rộng cú pháp).

Nếu tôi sử dụng truy vấn trên mà không nhóm thì tôi nhận được các giá trị khác nhau cho tổng số cuộn khi có nhiều hàng có cùng ProductIdTransactionDate. Một cách để giải quyết vấn đề này là thực hiện phép tính tổng chạy tương tự như trên nhưng cũng đánh dấu hàng cuối cùng trong phân vùng. Điều này có thể được thực hiện với LEAD(giả sử ProductIDkhông bao giờ là NULL) mà không cần sắp xếp bổ sung. Đối với giá trị tổng chạy cuối cùng, tôi sử dụng MAXlàm hàm cửa sổ để áp dụng giá trị ở hàng cuối cùng của phân vùng cho tất cả các hàng trong phân vùng.

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

Trên máy của tôi, việc này mất 2464ms thời gian CPU mà không có chỉ số bao phủ. Như trước đây dường như là một loại không thể tránh khỏi. Gói truy vấn có thể được tìm thấy ở đây: https://www.brentozar.com/pastetheplan/?id=HyWxhGVBl

Tôi nghĩ rằng có chỗ để cải thiện trong truy vấn trên. Chắc chắn có nhiều cách khác để sử dụng các chức năng của windows để có được kết quả mong muốn.

Khi sử dụng trang web của chúng tôi, bạn xác nhận rằng bạn đã đọc và hiểu Chính sách cookieChính sách bảo mật của chúng tôi.
Licensed under cc by-sa 3.0 with attribution required.