윈도우 함수를 사용한 날짜 범위 롤링 합계


56

날짜 범위에 대한 롤링 합계를 계산해야합니다. 예를 들어 AdventureWorks 샘플 데이터베이스 를 사용하면 다음과 같은 가상 구문이 필요한 것을 정확하게 수행합니다.

SELECT
    TH.ProductID,
    TH.TransactionDate,
    TH.ActualCost,
    RollingSum45 = SUM(TH.ActualCost) OVER (
        PARTITION BY TH.ProductID
        ORDER BY TH.TransactionDate
        RANGE BETWEEN 
            INTERVAL 45 DAY PRECEDING
            AND CURRENT ROW)
FROM Production.TransactionHistory AS TH
ORDER BY
    TH.ProductID,
    TH.TransactionDate,
    TH.ReferenceOrderID;

안타깝게도 RANGE창 프레임 범위는 현재 SQL Server에서 간격을 허용하지 않습니다.

하위 쿼리와 일반 (비 창) 집계를 사용하여 솔루션을 작성할 수 있다는 것을 알고 있습니다.

SELECT 
    TH.ProductID,
    TH.TransactionDate,
    TH.ActualCost,
    RollingSum45 =
    (
        SELECT SUM(TH2.ActualCost)
        FROM Production.TransactionHistory AS TH2
        WHERE
            TH2.ProductID = TH.ProductID
            AND TH2.TransactionDate <= TH.TransactionDate
            AND TH2.TransactionDate >= DATEADD(DAY, -45, TH.TransactionDate)
    )
FROM Production.TransactionHistory AS TH
ORDER BY
    TH.ProductID,
    TH.TransactionDate,
    TH.ReferenceOrderID;

다음과 같은 색인이 주어집니다.

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

실행 계획은 다음과 같습니다.

실행 계획

끔찍하게 비효율적이지는 않지만 SQL Server 2012, 2014 또는 2016 (지금까지)에서 지원되는 창 집계 및 분석 함수 만 사용하여이 쿼리를 표현할 수있을 것 같습니다.

명확성을 기하기 위해 데이터를 한 번만 통과 하는 솔루션을 찾고 있습니다.

T-SQL에서이 있다는 것을 의미 할 가능성이 절은 작업 및 윈도우 스풀 및 창 집계 기능을합니다 실행 계획을 할 것입니다. 이 조항 을 사용하는 모든 언어 요소 는 공정한 게임입니다. 올바른 결과를 보장 하는 경우 SQLCLR 솔루션을 사용할 수 있습니다.OVEROVER

T-SQL 솔루션의 경우 실행 계획에서 해시, 정렬 및 창 스풀 / 집계 수가 적을수록 좋습니다. 인덱스를 추가해도되지만 별도의 구조는 허용되지 않습니다 (예 : 사전 계산 된 테이블이 트리거와 동기화 된 상태로 유지되지 않음). 참조 테이블이 허용됩니다 (숫자, 날짜 등의 테이블).

이상적으로 솔루션은 위의 하위 쿼리 버전과 동일한 순서로 정확히 동일한 결과를 생성하지만 틀림없이 올바른 것도 허용됩니다. 성능은 항상 고려해야 할 사항이므로 솔루션은 적어도 합리적으로 효율적이어야합니다.

전용 대화방 : 이 질문 및 답변과 관련된 토론을위한 공개 대화방을 만들었습니다. 20 점 이상의 평판을 가진 사용자는 직접 참여할 수 있습니다. 담당자가 20 명 미만이고 참여하고 싶다면 아래의 의견에 저를 핑하십시오.

답변:


42

좋은 질문입니다, 폴! 저는 T-SQL과 CLR에 각각 다른 두 가지 접근 방식을 사용했습니다.

T-SQL 빠른 요약

T-SQL 접근 방식은 다음 단계로 요약 할 수 있습니다.

  • 제품 / 날짜의 교차 제품을 가져옵니다
  • 관찰 된 판매 데이터에서 병합
  • 해당 데이터를 제품 / 날짜 수준으로 집계
  • 이 집계 데이터를 기반으로 지난 45 일 동안의 롤링 합계 계산
  • 해당 결과를 하나 이상의 판매가있는 제품 / 날짜 쌍으로 만 필터링

을 사용 SET STATISTICS IO ON하여이 접근 방식 Table 'TransactionHistory'. Scan count 1, logical reads 484은 테이블을 통해 "단일 패스"를 확인하는을 보고 합니다. 참고로 원래 루프 검색 쿼리 보고서를 참조하십시오 Table 'TransactionHistory'. Scan count 113444, logical reads 438366.

에서보고 한대로 SET STATISTICS TIME ONCPU 시간은 514ms입니다. 이것은 2231ms원래 쿼리와 유리하게 비교 됩니다.

CLR 빠른 요약

CLR 요약은 다음 단계로 요약 할 수 있습니다.

  • 제품 및 날짜별로 데이터를 메모리로 읽습니다.
  • 각 거래를 처리하는 동안 총 비용을 합산하십시오. 트랜잭션이 이전 트랜잭션과 다른 제품 일 때마다 누계를 0으로 재설정하십시오.
  • 현재 거래와 동일한 (제품, 날짜) 첫 거래에 대한 포인터를 유지하십시오. 해당 제품 (날짜, 날짜)과의 마지막 거래가 발생할 때마다 해당 거래의 롤링 합계를 계산하고 동일한 제품 (날짜, 날짜)을 가진 모든 거래에 적용
  • 모든 결과를 사용자에게 반환하십시오!

SET STATISTICS IO ON이 접근 방식을 사용하면 논리적 I / O가 발생하지 않았다고보고합니다! 와우, 완벽한 솔루션! (실제로 SET STATISTICS IOCLR 내에서 발생한 I / O를보고하지 않는 것 같습니다 . 그러나 코드를 통해 정확히 한 번의 테이블 스캔이 이루어지고 Paul이 제안한 색인에 따라 데이터를 검색하는 것을 쉽게 알 수 있습니다.

에 의해보고 된 바와 같이 SET STATISTICS TIME ON, CPU 시간은 이제 187ms입니다. 따라서 이것은 T-SQL 접근 방식에 비해 상당히 개선 된 것입니다. 불행하게도, 두 접근법의 전체 경과 시간은 각각 약 0.5 초로 매우 유사합니다. 그러나 CLR 기반 접근 방식은 113K 행을 콘솔에 출력해야합니다 (제품 / 날짜별로 그룹화하는 T-SQL 접근 방식의 경우 52K에 불과). 그래서 대신 CPU 시간에 중점을 두었습니다.

이 방법의 또 다른 큰 장점은 제품이 같은 날에 여러 번 판매 된 경우에도 모든 트랜잭션에 대한 행을 포함하여 원래 루프 / 검색 방법과 정확히 동일한 결과를 산출한다는 것입니다. (AdventureWorks에서는 구체적으로 행 단위로 결과를 비교하여 Paul의 원래 쿼리와 연결되어 있음을 확인했습니다.)

이 방법의 단점은 적어도 현재 형식에서는 메모리의 모든 데이터를 읽는다는 것입니다. 그러나 설계된 알고리즘은 주어진 시간에 메모리의 현재 창 프레임 만 엄격하게 필요하며 메모리를 초과하는 데이터 세트에 대해 작동하도록 업데이트 될 수 있습니다. Paul은 슬라이딩 윈도우 만 메모리에 저장하는이 알고리즘을 구현하여이 점을 설명했습니다. 이로 인해 CLR 어셈블리에 더 많은 권한을 부여 할 수는 있지만이 솔루션을 임의의 대규모 데이터 세트로 확장하는 데 가치가 있습니다.


T-SQL-날짜별로 그룹화 된 하나의 스캔

초기 설정

USE AdventureWorks2012
GO
-- Create Paul's index
CREATE UNIQUE INDEX i
ON Production.TransactionHistory (ProductID, TransactionDate, ReferenceOrderID)
INCLUDE (ActualCost);
GO
-- Build calendar table for 2000 ~ 2020
CREATE TABLE dbo.calendar (d DATETIME NOT NULL CONSTRAINT PK_calendar PRIMARY KEY)
GO
DECLARE @d DATETIME = '1/1/2000'
WHILE (@d < '1/1/2021')
BEGIN
    INSERT INTO dbo.calendar (d) VALUES (@d)
    SELECT @d =  DATEADD(DAY, 1, @d)
END
GO

쿼리

DECLARE @minAnalysisDate DATE = '2007-09-01', -- Customizable start date depending on business needs
        @maxAnalysisDate DATE = '2008-09-03'  -- Customizable end date depending on business needs
SELECT ProductID, TransactionDate, ActualCost, RollingSum45, NumOrders
FROM (
    SELECT ProductID, TransactionDate, NumOrders, ActualCost,
        SUM(ActualCost) OVER (
                PARTITION BY ProductId ORDER BY TransactionDate 
                ROWS BETWEEN 45 PRECEDING AND CURRENT ROW
            ) AS RollingSum45
    FROM (
        -- The full cross-product of products and dates, combined with actual cost information for that product/date
        SELECT p.ProductID, c.d AS TransactionDate,
            COUNT(TH.ProductId) AS NumOrders, SUM(TH.ActualCost) AS ActualCost
        FROM Production.Product p
        JOIN dbo.calendar c
            ON c.d BETWEEN @minAnalysisDate AND @maxAnalysisDate
        LEFT OUTER JOIN Production.TransactionHistory TH
            ON TH.ProductId = p.productId
            AND TH.TransactionDate = c.d
        GROUP BY P.ProductID, c.d
    ) aggsByDay
) rollingSums
WHERE NumOrders > 0
ORDER BY ProductID, TransactionDate
-- MAXDOP 1 to avoid parallel scan inflating the scan count
OPTION (MAXDOP 1)

실행 계획

실행 계획에서 Paul이 제안한 원래 인덱스 Production.TransactionHistory는 병합 조인을 사용하여 트랜잭션 기록을 각 가능한 제품 / 날짜 조합과 결합하여 단일 주문 스캔을 수행하기에 충분하다는 것을 알 수 있습니다.

여기에 이미지 설명을 입력하십시오

가정

이 접근법에는 몇 가지 중요한 가정이 있습니다. 나는 그들이 수용 가능한지를 결정하는 것이 바울에게 달려 있다고 생각합니다. :)

  • 나는 Production.Product테이블을 사용하고 있습니다. 이 테이블은 무료로 사용할 수 있으며 AdventureWorks2012의 외래 키로 관계가 강화 Production.TransactionHistory되므로이 게임을 공정한 게임으로 해석했습니다.
  • 이 방법은 트랜잭션에 시간 구성 요소가 없다는 사실에 의존합니다 AdventureWorks2012. 만약 그렇다면, 거래 내역을 먼저 넘기지 않으면 전체 제품 / 날짜 조합을 생성 할 수 없게됩니다.
  • 제품 / 날짜 쌍당 하나의 행만 포함하는 행 집합을 생성 중입니다. 나는 이것이 "논쟁 적으로 맞다"고 생각하며 많은 경우에 더 바람직한 결과를 돌려 준다고 생각합니다. 각 제품 / 날짜에 NumOrders대해 판매 횟수를 나타내는 열을 추가 했습니다. 제품이 같은 날짜에 여러 번 판매 된 경우 원래 쿼리와 제안 된 쿼리의 결과를 비교하려면 다음 스크린 샷을 참조하십시오 (예 : 319/ 2007-09-05 00:00:00.000).

여기에 이미지 설명을 입력하십시오


CLR-하나의 스캔, 전체 그룹화되지 않은 결과 세트

주요 기능 본체

여기서 볼 톤은 없습니다. 함수의 본문은 입력 (해당 SQL 함수와 일치해야 함)을 선언하고 SQL 연결을 설정 한 후 SQLReader를 엽니 다.

// SQL CLR function for rolling SUMs on AdventureWorks2012.Production.TransactionHistory
[SqlFunction(DataAccess = DataAccessKind.Read,
    FillRowMethodName = "RollingSum_Fill",
    TableDefinition = "ProductId INT, TransactionDate DATETIME, ReferenceOrderID INT," +
                      "ActualCost FLOAT, PrevCumulativeSum FLOAT, RollingSum FLOAT")]
public static IEnumerable RollingSumTvf(SqlInt32 rollingPeriodDays) {
    using (var connection = new SqlConnection("context connection=true;")) {
        connection.Open();
        List<TrxnRollingSum> trxns;
        using (var cmd = connection.CreateCommand()) {
            //Read the transaction history (note: the order is important!)
            cmd.CommandText = @"SELECT ProductId, TransactionDate, ReferenceOrderID,
                                    CAST(ActualCost AS FLOAT) AS ActualCost 
                                FROM Production.TransactionHistory 
                                ORDER BY ProductId, TransactionDate";
            using (var reader = cmd.ExecuteReader()) {
                trxns = ComputeRollingSums(reader, rollingPeriodDays.Value);
            }
        }

        return trxns;
    }
}

핵심 논리

주요 논리를 분리하여 집중하기가 더 쉽습니다.

// Given a SqlReader with transaction history data, computes / returns the rolling sums
private static List<TrxnRollingSum> ComputeRollingSums(SqlDataReader reader,
                                                        int rollingPeriodDays) {
    var startIndexOfRollingPeriod = 0;
    var rollingSumIndex = 0;
    var trxns = new List<TrxnRollingSum>();

    // Prior to the loop, initialize "next" to be the first transaction
    var nextTrxn = GetNextTrxn(reader, null);
    while (nextTrxn != null)
    {
        var currTrxn = nextTrxn;
        nextTrxn = GetNextTrxn(reader, currTrxn);
        trxns.Add(currTrxn);

        // If the next transaction is not the same product/date as the current
        // transaction, we can finalize the rolling sum for the current transaction
        // and all previous transactions for the same product/date
        var finalizeRollingSum = nextTrxn == null || (nextTrxn != null &&
                                (currTrxn.ProductId != nextTrxn.ProductId ||
                                currTrxn.TransactionDate != nextTrxn.TransactionDate));
        if (finalizeRollingSum)
        {
            // Advance the pointer to the first transaction (for the same product)
            // that occurs within the rolling period
            while (startIndexOfRollingPeriod < trxns.Count
                && trxns[startIndexOfRollingPeriod].TransactionDate <
                    currTrxn.TransactionDate.AddDays(-1 * rollingPeriodDays))
            {
                startIndexOfRollingPeriod++;
            }

            // Compute the rolling sum as the cumulative sum (for this product),
            // minus the cumulative sum for prior to the beginning of the rolling window
            var sumPriorToWindow = trxns[startIndexOfRollingPeriod].PrevSum;
            var rollingSum = currTrxn.ActualCost + currTrxn.PrevSum - sumPriorToWindow;
            // Fill in the rolling sum for all transactions sharing this product/date
            while (rollingSumIndex < trxns.Count)
            {
                trxns[rollingSumIndex++].RollingSum = rollingSum;
            }
        }

        // If this is the last transaction for this product, reset the rolling period
        if (nextTrxn != null && currTrxn.ProductId != nextTrxn.ProductId)
        {
            startIndexOfRollingPeriod = trxns.Count;
        }
    }

    return trxns;
}

헬퍼

다음 논리는 인라인으로 작성 될 수 있지만 고유 한 메소드로 분리 될 때 읽기가 더 쉽습니다.

private static TrxnRollingSum GetNextTrxn(SqlDataReader r, TrxnRollingSum currTrxn) {
    TrxnRollingSum nextTrxn = null;
    if (r.Read()) {
        nextTrxn = new TrxnRollingSum {
            ProductId = r.GetInt32(0),
            TransactionDate = r.GetDateTime(1),
            ReferenceOrderId = r.GetInt32(2),
            ActualCost = r.GetDouble(3),
            PrevSum = 0 };
        if (currTrxn != null) {
            nextTrxn.PrevSum = (nextTrxn.ProductId == currTrxn.ProductId)
                    ? currTrxn.PrevSum + currTrxn.ActualCost : 0;
        }
    }
    return nextTrxn;
}

// Represents the output to be returned
// Note that the ReferenceOrderId/PrevSum fields are for debugging only
private class TrxnRollingSum {
    public int ProductId { get; set; }
    public DateTime TransactionDate { get; set; }
    public int ReferenceOrderId { get; set; }
    public double ActualCost { get; set; }
    public double PrevSum { get; set; }
    public double RollingSum { get; set; }
}

// The function that generates the result data for each row
// (Such a function is mandatory for SQL CLR table-valued functions)
public static void RollingSum_Fill(object trxnWithRollingSumObj,
                                    out int productId,
                                    out DateTime transactionDate, 
                                    out int referenceOrderId, out double actualCost,
                                    out double prevCumulativeSum,
                                    out double rollingSum) {
    var trxn = (TrxnRollingSum)trxnWithRollingSumObj;
    productId = trxn.ProductId;
    transactionDate = trxn.TransactionDate;
    referenceOrderId = trxn.ReferenceOrderId;
    actualCost = trxn.ActualCost;
    prevCumulativeSum = trxn.PrevSum;
    rollingSum = trxn.RollingSum;
}

SQL로 모두 묶기

지금까지의 모든 것은 C #에 있었으므로 실제 SQL과 관련된 것을 보자. 또는 이 배포 스크립트 를 사용하여 직접 컴파일하지 않고 어셈블리의 비트에서 직접 어셈블리를 만들 수 있습니다 .

USE AdventureWorks2012; /* GPATTERSON2\SQL2014DEVELOPER */
GO

-- Enable CLR
EXEC sp_configure 'clr enabled', 1;
GO
RECONFIGURE;
GO

-- Create the assembly based on the dll generated by compiling the CLR project
-- I've also included the "assembly bits" version that can be run without compiling
CREATE ASSEMBLY ClrPlayground
-- See http://pastebin.com/dfbv1w3z for a "from assembly bits" version
FROM 'C:\FullPathGoesHere\ClrPlayground\bin\Debug\ClrPlayground.dll'
WITH PERMISSION_SET = safe;
GO

--Create a function from the assembly
CREATE FUNCTION dbo.RollingSumTvf (@rollingPeriodDays INT)
RETURNS TABLE ( ProductId INT, TransactionDate DATETIME, ReferenceOrderID INT,
                ActualCost FLOAT, PrevCumulativeSum FLOAT, RollingSum FLOAT)
-- The function yields rows in order, so let SQL Server know to avoid an extra sort
ORDER (ProductID, TransactionDate, ReferenceOrderID)
AS EXTERNAL NAME ClrPlayground.UserDefinedFunctions.RollingSumTvf;
GO

-- Now we can actually use the TVF!
SELECT * 
FROM dbo.RollingSumTvf(45) 
ORDER BY ProductId, TransactionDate, ReferenceOrderId
GO

경고

CLR 접근 방식은 알고리즘을 최적화하는 데 훨씬 더 많은 유연성을 제공하며 아마도 C # 전문가가 더 조정할 수 있습니다. 그러나 CLR 전략에는 단점도 있습니다. 명심해야 할 몇 가지 사항 :

  • 이 CLR 방식은 데이터 세트의 사본을 메모리에 보관합니다. 스트리밍 접근 방식을 사용할 수는 있지만 초기 문제가 발생 하여 SQL 2008+의 변경 사항으로 인해 이러한 유형의 접근 방식을 사용하기가 더 어렵다고 불평 하는 뛰어난 Connect 문제 가 있음을 발견했습니다 . Paul이 보여주는 것처럼 여전히 가능하지만 데이터베이스를 설정 하고 CLR 어셈블리에 TRUSTWORTHY부여 EXTERNAL_ACCESS하여 더 높은 수준의 권한이 필요합니다 . 따라서 번거롭고 잠재적 인 보안 영향이 있지만 그 대가는 AdventureWorks보다 훨씬 큰 데이터 세트로 확장 할 수있는 스트리밍 방식입니다.
  • CLR은 일부 DBA에서 액세스하기 어려울 수 있으므로 그러한 기능은 투명하지 않고 쉽게 수정되지 않고 쉽게 배포되지 않으며 쉽게 디버깅되지 않는 블랙 박스보다 더 많이 사용됩니다. 이것은 T-SQL 접근 방식과 비교할 때 매우 큰 단점입니다.


보너스 : T-SQL # 2-실제로 사용하는 실용적인 접근법

한동안 문제를 창의적으로 생각한 후에, 나는이 문제가 나의 일상적인 일에서 떠 올랐을 때 해결할 수있는 상당히 간단하고 실용적인 방법을 게시하겠다고 생각했다. SQL 2012 + 창 기능을 사용하지만 질문이 기대했던 혁신적인 방식은 아닙니다.

-- Compute all running costs into a #temp table; Note that this query could simply read
-- from Production.TransactionHistory, but a CROSS APPLY by product allows the window 
-- function to be computed independently per product, supporting a parallel query plan
SELECT t.*
INTO #runningCosts
FROM Production.Product p
CROSS APPLY (
    SELECT t.ProductId, t.TransactionDate, t.ReferenceOrderId, t.ActualCost,
        -- Running sum of the cost for this product, including all ties on TransactionDate
        SUM(t.ActualCost) OVER (
            ORDER BY t.TransactionDate 
            RANGE UNBOUNDED PRECEDING) AS RunningCost
    FROM Production.TransactionHistory t
    WHERE t.ProductId = p.ProductId
) t
GO

-- Key the table in our output order
ALTER TABLE #runningCosts
ADD PRIMARY KEY (ProductId, TransactionDate, ReferenceOrderId)
GO

SELECT r.ProductId, r.TransactionDate, r.ReferenceOrderId, r.ActualCost,
    -- Cumulative running cost - running cost prior to the sliding window
    r.RunningCost - ISNULL(w.RunningCost,0) AS RollingSum45
FROM #runningCosts r
OUTER APPLY (
    -- For each transaction, find the running cost just before the sliding window begins
    SELECT TOP 1 b.RunningCost
    FROM #runningCosts b
    WHERE b.ProductId = r.ProductId
        AND b.TransactionDate < DATEADD(DAY, -45, r.TransactionDate)
    ORDER BY b.TransactionDate DESC
) w
ORDER BY r.ProductId, r.TransactionDate, r.ReferenceOrderId
GO

이렇게하면 두 개의 관련 쿼리 계획을 모두 함께 볼 때도 상당히 간단한 전체 쿼리 계획이 생성됩니다.

여기에 이미지 설명을 입력하십시오 여기에 이미지 설명을 입력하십시오

내가이 접근법을 좋아하는 몇 가지 이유 :

  • 그룹화 된 결과 버전을 반환하는 대부분의 다른 T-SQL 솔루션과 달리 문제 설명에서 요청 된 전체 결과 집합을 생성합니다.
  • 설명, 이해 및 디버깅이 쉽습니다. 1 년 후 다시 오지 않고 정확성이나 성능을 떨어 뜨리지 않고 어떻게 작은 변화를 만들 수 있을지 궁금합니다.
  • 원래의 루프 탐색이 900ms아닌 제공된 데이터 세트 에서 실행됩니다.2700ms
  • 데이터가 훨씬 더 조밀 한 경우 (하루에 더 많은 트랜잭션) 계산 창의 복잡성은 슬라이딩 창에있는 트랜잭션 수에 따라 2 차적으로 커지지 않습니다 (원래 쿼리와 동일). 나는 이것이 여러 번의 스캔을 피하고 싶었던 것에 대한 바울의 관심사 중 일부를 다루고 있다고 생각한다
  • 새로운 tempdb 지연 쓰기 기능 으로 인해 최근 SQL 2012+ 업데이트에서 tempdb I / O가 발생하지 않음
  • 매우 큰 데이터 세트의 경우 메모리 압력이 문제가되는 경우 작업을 각 제품에 대해 개별 배치로 분할하는 것이 쉽지 않습니다.

몇 가지 잠재적 인 경고 :

  • 기술적으로는 Production.TransactionHistory를 한 번만 스캔하지만 비슷한 크기의 #temp 테이블이 있고 해당 테이블에서 추가 논리 I / O를 수행해야하기 때문에 실제로는 "한 번의 스캔"접근 방식이 아닙니다. 그러나 나는 이것이 정확한 구조를 정의했기 때문에 더 수동으로 제어 할 수있는 작업 테이블과 너무 다른 것으로 보지 않습니다.
  • 환경에 따라 tempdb 사용은 긍정적 (예 : 별도의 SSD 드라이브 세트에 있음) 또는 부정적 (서버의 높은 동시성, 많은 tempdb 경합)으로 볼 수 있습니다.

25

이것은 긴 답변이므로 여기에 요약을 추가하기로 결정했습니다.

  • 처음에는 질문에서와 동일한 순서로 정확히 동일한 결과를 생성하는 솔루션을 제시합니다. 기본 테이블을 3 번 스캔합니다. ProductIDs각 제품의 날짜 범위 목록을 가져 오고, 매일 같은 비용을 가진 여러 트랜잭션이 있기 때문에 매일 비용을 요약하고, 원래 행과 결과를 결합합니다.
  • 다음으로 작업을 단순화하고 메인 테이블의 마지막 스캔을 피하는 두 가지 접근 방식을 비교합니다. 결과는 일일 요약입니다. 즉, 제품의 여러 트랜잭션이 동일한 날짜를 가진 경우 단일 행으로 롤업됩니다. 이전 단계의 접근 방식으로 테이블을 두 번 스캔합니다. Geoff Patterson의 접근법은 날짜 범위 및 제품 목록에 대한 외부 지식을 사용하므로 테이블을 한 번 스캔합니다.
  • 마지막으로 일일 요약을 다시 반환하는 단일 패스 솔루션을 제시하지만 날짜 범위 또는에 대한 목록에 대한 외부 지식이 필요하지 않습니다 ProductIDs.

AdventureWorks2014 데이터베이스와 SQL Server Express 2014를 사용하겠습니다 .

원본 데이터베이스의 변경 사항 :

  • 유형을 [Production].[TransactionHistory].[TransactionDate]에서 (으) datetime로 변경 했습니다 date. 어쨌든 시간 구성 요소는 0이었습니다.
  • 캘린더 테이블 추가 [dbo].[Calendar]
  • 에 색인 추가 [Production].[TransactionHistory]

.

CREATE TABLE [dbo].[Calendar]
(
    [dt] [date] NOT NULL,
    CONSTRAINT [PK_Calendar] PRIMARY KEY CLUSTERED 
(
    [dt] ASC
))

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

-- Init calendar table
INSERT INTO dbo.Calendar (dt)
SELECT TOP (50000)
    DATEADD(day, ROW_NUMBER() OVER (ORDER BY s1.[object_id])-1, '2000-01-01') AS dt
FROM sys.all_objects AS s1 CROSS JOIN sys.all_objects AS s2
OPTION (MAXDOP 1);

MSDN 기사 OVER조항 에 대해서는 Itzik Ben-Gan의 창 기능대한 훌륭한 블로그 게시물 링크가 있습니다. 해당 게시물에서 그는 방법을 설명합니다 OVER, 차이 작동 ROWSRANGE옵션 및 날짜 범위 롤링 합을 계산이 매우 문제를 언급하고있다. 그는 현재 버전의 SQL Server가 RANGE전체적으로 구현되지 않고 시간 간격 데이터 유형을 구현하지 않는다고 언급합니다 . 차이의 그의 설명 ROWS하고 RANGE나에게 아이디어를 주었다.

간격과 중복이없는 날짜

TransactionHistory테이블에 간격이없고 날짜가없는 날짜가 포함 된 경우 다음 쿼리는 올바른 결과를 생성합니다.

SELECT
    TH.ProductID,
    TH.TransactionDate,
    TH.ActualCost,
    RollingSum45 = SUM(TH.ActualCost) OVER (
        PARTITION BY TH.ProductID
        ORDER BY TH.TransactionDate
        ROWS BETWEEN 
            45 PRECEDING
            AND CURRENT ROW)
FROM Production.TransactionHistory AS TH
ORDER BY
    TH.ProductID,
    TH.TransactionDate,
    TH.ReferenceOrderID;

실제로 45 행의 창은 정확히 45 일을 포함합니다.

중복없는 공백이있는 날짜

불행히도, 우리의 데이터는 날짜에 차이가 있습니다. 이 문제를 해결하기 위해 Calendar테이블을 사용하여 간격없이 날짜 집합을 생성 한 다음 LEFT JOIN이 집합에 대한 원래 데이터를 생성하고 와 동일한 쿼리를 사용할 수 있습니다 ROWS BETWEEN 45 PRECEDING AND CURRENT ROW. 날짜가 반복되지 않는 경우에만 동일한 결과를 얻을 수 ProductID있습니다.

중복되는 공백이있는 날짜

안타깝게도 Google 데이터에는 날짜에 차이가 있으며 동일한 날짜 내에서 날짜가 반복 될 수 있습니다 ProductID. 이 문제를 해결하기 위해 중복없이 날짜 집합을 생성하여 GROUP원본 데이터 ProductID, TransactionDate를 만들 수 있습니다 . 그런 다음 Calendar표를 사용하여 공백없이 날짜 집합을 생성하십시오. 그런 다음 with ROWS BETWEEN 45 PRECEDING AND CURRENT ROW를 사용하여 rolling을 계산할 수 있습니다 SUM. 올바른 결과를 얻을 수 있습니다. 아래 쿼리에서 주석을 참조하십시오.

WITH

-- calculate Start/End dates for each product
CTE_Products
AS
(
    SELECT TH.ProductID
        ,MIN(TH.TransactionDate) AS MinDate
        ,MAX(TH.TransactionDate) AS MaxDate
    FROM [Production].[TransactionHistory] AS TH
    GROUP BY TH.ProductID
)

-- generate set of dates without gaps for each product
,CTE_ProductsWithDates
AS
(
    SELECT CTE_Products.ProductID, C.dt
    FROM
        CTE_Products
        INNER JOIN dbo.Calendar AS C ON
            C.dt >= CTE_Products.MinDate AND
            C.dt <= CTE_Products.MaxDate
)

-- generate set of dates without duplicates for each product
-- calculate daily cost as well
,CTE_DailyCosts
AS
(
    SELECT TH.ProductID, TH.TransactionDate, SUM(ActualCost) AS DailyActualCost
    FROM [Production].[TransactionHistory] AS TH
    GROUP BY TH.ProductID, TH.TransactionDate
)

-- calculate rolling sum over 45 days
,CTE_Sum
AS
(
    SELECT
        CTE_ProductsWithDates.ProductID
        ,CTE_ProductsWithDates.dt
        ,CTE_DailyCosts.DailyActualCost
        ,SUM(CTE_DailyCosts.DailyActualCost) OVER (
            PARTITION BY CTE_ProductsWithDates.ProductID
            ORDER BY CTE_ProductsWithDates.dt
            ROWS BETWEEN 45 PRECEDING AND CURRENT ROW) AS RollingSum45
    FROM
        CTE_ProductsWithDates
        LEFT JOIN CTE_DailyCosts ON 
            CTE_DailyCosts.ProductID = CTE_ProductsWithDates.ProductID AND
            CTE_DailyCosts.TransactionDate = CTE_ProductsWithDates.dt
)

-- remove rows that were added by Calendar, which fill the gaps in dates
-- add back duplicate dates that were removed by GROUP BY
SELECT
    TH.ProductID
    ,TH.TransactionDate
    ,TH.ActualCost
    ,CTE_Sum.RollingSum45
FROM
    [Production].[TransactionHistory] AS TH
    INNER JOIN CTE_Sum ON
        CTE_Sum.ProductID = TH.ProductID AND
        CTE_Sum.dt = TH.TransactionDate
ORDER BY
    TH.ProductID
    ,TH.TransactionDate
    ,TH.ReferenceOrderID
;

이 쿼리는 하위 쿼리를 사용하는 질문의 접근 방식과 동일한 결과를 생성 함을 확인했습니다.

실행 계획

통계

첫 번째 쿼리는 하위 쿼리를 사용하고 두 번째는이 방법을 사용합니다. 이 방법에서는 지속 시간과 읽기 수가 훨씬 적습니다. 이 접근법에서 예상 비용의 대부분은 최종적입니다 ( ORDER BY아래 참조).

하위 쿼리

하위 쿼리 접근 방식에는 중첩 루프와 O(n*n)복잡성 이 포함 된 간단한 계획이 있습니다.

위에

이 접근 방식에 대한 계획은 TransactionHistory여러 번 스캔 하지만 루프는 없습니다. 보시다시피 예상 비용의 70 % 이상 Sort이 최종 비용입니다 ORDER BY.

io

상단 결과- subquery하단 OVER.


추가 스캔 피하기

위의 계획에서 마지막 인덱스 스캔, 병합 조인 및 정렬 INNER JOIN은 원래 테이블의 최종 결과로 인해 최종 결과가 하위 쿼리의 느린 접근 방식과 정확히 동일하게됩니다. 리턴 된 행 수는 TransactionHistory표 와 동일 합니다. TransactionHistory같은 날 같은 제품에 대해 여러 트랜잭션이 발생했을 때 행이 있습니다 . 결과에 매일 요약 만 표시해도 괜찮 으면이 최종 결과를 JOIN제거 할 수 있으며 쿼리가 조금 더 단순 해지고 빨라집니다. 이전 계획의 마지막 인덱스 스캔, 병합 조인 및 정렬이 필터로 바뀌어로 추가 된 행이 제거됩니다 Calendar.

WITH
-- two scans
-- calculate Start/End dates for each product
CTE_Products
AS
(
    SELECT TH.ProductID
        ,MIN(TH.TransactionDate) AS MinDate
        ,MAX(TH.TransactionDate) AS MaxDate
    FROM [Production].[TransactionHistory] AS TH
    GROUP BY TH.ProductID
)

-- generate set of dates without gaps for each product
,CTE_ProductsWithDates
AS
(
    SELECT CTE_Products.ProductID, C.dt
    FROM
        CTE_Products
        INNER JOIN dbo.Calendar AS C ON
            C.dt >= CTE_Products.MinDate AND
            C.dt <= CTE_Products.MaxDate
)

-- generate set of dates without duplicates for each product
-- calculate daily cost as well
,CTE_DailyCosts
AS
(
    SELECT TH.ProductID, TH.TransactionDate, SUM(ActualCost) AS DailyActualCost
    FROM [Production].[TransactionHistory] AS TH
    GROUP BY TH.ProductID, TH.TransactionDate
)

-- calculate rolling sum over 45 days
,CTE_Sum
AS
(
    SELECT
        CTE_ProductsWithDates.ProductID
        ,CTE_ProductsWithDates.dt
        ,CTE_DailyCosts.DailyActualCost
        ,SUM(CTE_DailyCosts.DailyActualCost) OVER (
            PARTITION BY CTE_ProductsWithDates.ProductID
            ORDER BY CTE_ProductsWithDates.dt
            ROWS BETWEEN 45 PRECEDING AND CURRENT ROW) AS RollingSum45
    FROM
        CTE_ProductsWithDates
        LEFT JOIN CTE_DailyCosts ON 
            CTE_DailyCosts.ProductID = CTE_ProductsWithDates.ProductID AND
            CTE_DailyCosts.TransactionDate = CTE_ProductsWithDates.dt
)

-- remove rows that were added by Calendar, which fill the gaps in dates
SELECT
    CTE_Sum.ProductID
    ,CTE_Sum.dt AS TransactionDate
    ,CTE_Sum.DailyActualCost
    ,CTE_Sum.RollingSum45
FROM CTE_Sum
WHERE CTE_Sum.DailyActualCost IS NOT NULL
ORDER BY
    CTE_Sum.ProductID
    ,CTE_Sum.dt
;

두 스캔

여전히 TransactionHistory두 번 스캔됩니다. 각 제품의 날짜 범위를 얻으려면 한 번의 추가 스캔이 필요합니다. 나는 그것이 다른 날짜와 비교하는 방법에 관심이있었습니다. 우리는의 날짜 범위에 대한 외부 지식 TransactionHistory과 추가 스캔을 피할 수있는 추가 테이블을 사용 Product했습니다 ProductIDs. 비교를 유효하게하기 위해이 쿼리에서 매일 트랜잭션 수 계산을 제거했습니다. 두 쿼리 모두에 추가 할 수 있지만 비교를 위해 간단하게 유지하고 싶습니다. 2014 버전의 데이터베이스를 사용하기 때문에 다른 날짜도 사용해야했습니다.

DECLARE @minAnalysisDate DATE = '2013-07-31', 
-- Customizable start date depending on business needs
        @maxAnalysisDate DATE = '2014-08-03'  
-- Customizable end date depending on business needs
SELECT 
    -- one scan
    ProductID, TransactionDate, ActualCost, RollingSum45
--, NumOrders
FROM (
    SELECT ProductID, TransactionDate, 
    --NumOrders, 
    ActualCost,
        SUM(ActualCost) OVER (
                PARTITION BY ProductId ORDER BY TransactionDate 
                ROWS BETWEEN 45 PRECEDING AND CURRENT ROW
            ) AS RollingSum45
    FROM (
        -- The full cross-product of products and dates, 
        -- combined with actual cost information for that product/date
        SELECT p.ProductID, c.dt AS TransactionDate,
            --COUNT(TH.ProductId) AS NumOrders, 
            SUM(TH.ActualCost) AS ActualCost
        FROM Production.Product p
        JOIN dbo.calendar c
            ON c.dt BETWEEN @minAnalysisDate AND @maxAnalysisDate
        LEFT OUTER JOIN Production.TransactionHistory TH
            ON TH.ProductId = p.productId
            AND TH.TransactionDate = c.dt
        GROUP BY P.ProductID, c.dt
    ) aggsByDay
) rollingSums
--WHERE NumOrders > 0
WHERE ActualCost IS NOT NULL
ORDER BY ProductID, TransactionDate
-- MAXDOP 1 to avoid parallel scan inflating the scan count
OPTION (MAXDOP 1);

한 스캔

두 쿼리 모두 동일한 결과를 동일한 순서로 반환합니다.

비교

시간 및 IO 통계는 다음과 같습니다.

통계 2

io2

한 스캔 변종은 작업 테이블을 많이 사용해야하므로 2- 스캔 변형은 조금 더 빠르고 읽기 수가 적습니다. 또한 단일 스캔 변형은 계획에서 볼 수 있듯이 필요한 것보다 많은 행을 생성합니다. 거래가없는 경우에도 테이블 ProductID에있는 각각의 날짜를 생성 합니다. 테이블 에는 504 개의 행이 있지만에 441 개의 제품 만 트랜잭션을 가지고 있습니다 . 또한 각 제품에 대해 동일한 날짜 범위를 생성하는데, 이는 필요 이상입니다. 경우 각 개별 제품이 상대적으로 짧은 역사를 가지고있는 이상 전체 역사를 가지고, 여분의 불필요한 행의 수는 더 높은 것입니다.ProductProductIDProductTransactionHistoryTransactionHistory

반면에,보다 좁은 다른 인덱스를 생성하여 2 스캔 변형을 조금 더 최적화 할 수 있습니다 (ProductID, TransactionDate). 이 색인은 각 제품의 시작 / 종료 날짜를 계산하는 데 사용되며 ( CTE_Products) 색인을 포함하는 것보다 적은 페이지를 가지며 결과적으로 읽기가 줄어 듭니다.

따라서 명시 적 단순 스캔을 추가하거나 암시 적 작업 테이블을 선택할 수 있습니다.

BTW, 일일 요약만으로 결과를 얻는 것이 좋다면을 포함하지 않는 인덱스를 만드는 것이 좋습니다 ReferenceOrderID. 적은 페이지 => 적은 IO를 사용합니다.

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

CROSS APPLY를 사용한 단일 패스 솔루션

정말 긴 대답이되지만 여기에는 일일 요약 만 다시 반환하는 또 하나의 변형이 있지만 데이터를 한 번만 스캔하므로 날짜 범위 또는 ProductID 목록에 대한 외부 지식이 필요하지 않습니다. 중간 정렬도 수행하지 않습니다. 전반적인 성능은 이전 변형과 비슷하지만 약간 나빠 보입니다.

주요 아이디어는 숫자 테이블을 사용하여 날짜의 간격을 채울 행을 생성하는 것입니다. 기존의 각 날짜에 대해 LEAD일 단위 간격의 크기를 계산 한 다음 CROSS APPLY결과 집합에 필요한 수의 행을 추가하는 데 사용 합니다. 처음에는 영구적 인 숫자 테이블로 시도했습니다. 계획은이 표에서 많은 수의 읽기를 보여 주었지만 실제 지속 시간은을 사용하여 즉시 숫자를 생성했을 때와 거의 동일 CTE합니다.

WITH 
e1(n) AS
(
    SELECT 1 UNION ALL SELECT 1 UNION ALL SELECT 1 UNION ALL 
    SELECT 1 UNION ALL SELECT 1 UNION ALL SELECT 1 UNION ALL 
    SELECT 1 UNION ALL SELECT 1 UNION ALL SELECT 1 UNION ALL SELECT 1
) -- 10
,e2(n) AS (SELECT 1 FROM e1 CROSS JOIN e1 AS b) -- 10*10
,e3(n) AS (SELECT 1 FROM e1 CROSS JOIN e2) -- 10*100
,CTE_Numbers
AS
(
    SELECT ROW_NUMBER() OVER (ORDER BY n) AS Number
    FROM e3
)
,CTE_DailyCosts
AS
(
    SELECT
        TH.ProductID
        ,TH.TransactionDate
        ,SUM(ActualCost) AS DailyActualCost
        ,ISNULL(DATEDIFF(day,
            TH.TransactionDate,
            LEAD(TH.TransactionDate) 
            OVER(PARTITION BY TH.ProductID ORDER BY TH.TransactionDate)), 1) AS DiffDays
    FROM [Production].[TransactionHistory] AS TH
    GROUP BY TH.ProductID, TH.TransactionDate
)
,CTE_NoGaps
AS
(
    SELECT
        CTE_DailyCosts.ProductID
        ,CTE_DailyCosts.TransactionDate
        ,CASE WHEN CA.Number = 1 
        THEN CTE_DailyCosts.DailyActualCost
        ELSE NULL END AS DailyCost
    FROM
        CTE_DailyCosts
        CROSS APPLY
        (
            SELECT TOP(CTE_DailyCosts.DiffDays) CTE_Numbers.Number
            FROM CTE_Numbers
            ORDER BY CTE_Numbers.Number
        ) AS CA
)
,CTE_Sum
AS
(
    SELECT
        ProductID
        ,TransactionDate
        ,DailyCost
        ,SUM(DailyCost) OVER (
            PARTITION BY ProductID
            ORDER BY TransactionDate
            ROWS BETWEEN 45 PRECEDING AND CURRENT ROW) AS RollingSum45
    FROM CTE_NoGaps
)
SELECT
    ProductID
    ,TransactionDate
    ,DailyCost
    ,RollingSum45
FROM CTE_Sum
WHERE DailyCost IS NOT NULL
ORDER BY 
    ProductID
    ,TransactionDate
;

쿼리는 두 개의 창 함수 ( LEADSUM)를 사용하기 때문에이 계획은 더 길다 .

교차 적용

ca 통계

ca io


23

더 빠르게 실행되고 더 적은 메모리를 필요로하는 대체 SQLCLR 솔루션 :

배포 스크립트

EXTERNAL_ACCESS(느린) 컨텍스트 연결 대신 대상 서버와 데이터베이스에 대한 루프백 연결을 사용하기 때문에 권한 세트 가 필요합니다 . 다음은 함수를 호출하는 방법입니다.

SELECT 
    RS.ProductID,
    RS.TransactionDate,
    RS.ActualCost,
    RS.RollingSum45
FROM dbo.RollingSum
(
    N'.\SQL2014',           -- Instance name
    N'AdventureWorks2012'   -- Database name
) AS RS 
ORDER BY
    RS.ProductID,
    RS.TransactionDate,
    RS.ReferenceOrderID;

질문과 동일한 순서로 정확하게 동일한 결과를 생성합니다.

실행 계획 :

SQLCLR TVF 실행 계획

SQLCLR 소스 쿼리 실행 계획

탐색기 성능 통계 계획

프로파일 러 논리적 읽기 : 481

이 구현의 주요 장점은 컨텍스트 연결을 사용하는 것보다 빠르며 더 적은 메모리를 사용한다는 것입니다. 한 번에 두 가지만 메모리에 유지합니다.

  1. 모든 중복 행 (동일한 제품 및 거래 날짜) 이는 제품 또는 날짜가 변경 될 때까지 최종 누계액이 무엇인지 알 수 없기 때문에 필요합니다. 샘플 데이터에는 64 개의 행이있는 제품과 날짜의 조합이 있습니다.
  2. 현재 제품에 대해 45 일 동안 변동하는 비용 및 거래 날짜 만 적용됩니다. 45 일 슬라이딩 창을 떠나는 행의 단순 누적 합계를 조정하는 데 필요합니다.

이 최소 캐싱은이 방법의 확장 성을 보장해야합니다. CLR 메모리에 전체 입력 세트를 유지하는 것보다 확실히 좋습니다.

소스 코드


17

64 비트 Enterprise, Developer 또는 Evaluation Edition의 SQL Server 2014를 사용하는 경우 In-Memory OLTP를 사용할 수 있습니다 . 솔루션은 단일 스캔이 아니며 창 기능을 거의 사용하지 않지만이 질문에 가치를 더할 수 있으며 사용 된 알고리즘을 다른 솔루션에 대한 영감으로 사용할 수 있습니다.

먼저 AdventureWorks 데이터베이스에서 In-Memory OLTP를 활성화해야합니다.

alter database AdventureWorks2014 
  add filegroup InMem contains memory_optimized_data;

alter database AdventureWorks2014 
  add file (name='AW2014_InMem', 
            filename='D:\SQL Server\MSSQL12.MSSQLSERVER\MSSQL\DATA\AW2014') 
    to filegroup InMem;

alter database AdventureWorks2014 
  set memory_optimized_elevate_to_snapshot = on;

프로 시저에 대한 매개 변수는 메모리 내 테이블 변수이며 유형으로 정의되어야합니다.

create type dbo.TransHistory as table
(
  ID int not null,
  ProductID int not null,
  TransactionDate datetime not null,
  ReferenceOrderID int not null,
  ActualCost money not null,
  RunningTotal money not null,
  RollingSum45 money not null,

  -- Index used in while loop
  index IX_T1 nonclustered hash (ID) with (bucket_count = 1000000),

  -- Used to lookup the running total as it was 45 days ago (or more)
  index IX_T2 nonclustered (ProductID, TransactionDate desc)
) with (memory_optimized = on);

이 표에서 ID는 고유하지 않으며 ProductID및의 각 조합에 대해 고유합니다 TransactionDate.

절차에 어떤 내용이 있는지 알려주는 주석이 있지만 전체적으로 루프에서 누적 합계를 계산하고 있으며 각 반복마다 45 일 전 (또는 그 이상) 동안 누적 합계를 조회합니다.

현재 누적 합계에서 45 일 전의 누적 합계를 뺀 값은 우리가 찾고있는 순환 45 일 합계입니다.

create procedure dbo.GetRolling45
  @TransHistory dbo.TransHistory readonly
with native_compilation, schemabinding, execute as owner as
begin atomic with(transaction isolation level = snapshot, language = N'us_english')

  -- Table to hold the result
  declare @TransRes dbo.TransHistory;

  -- Loop variable
  declare @ID int = 0;

  -- Current ProductID
  declare @ProductID int = -1;

  -- Previous ProductID used to restart the running total
  declare @PrevProductID int;

  -- Current transaction date used to get the running total 45 days ago (or more)
  declare @TransactionDate datetime;

  -- Sum of actual cost for the group ProductID and TransactionDate
  declare @ActualCost money;

  -- Running total so far
  declare @RunningTotal money = 0;

  -- Running total as it was 45 days ago (or more)
  declare @RunningTotal45 money = 0;

  -- While loop for each unique occurence of the combination of ProductID, TransactionDate
  while @ProductID <> 0
  begin
    set @ID += 1;
    set @PrevProductID = @ProductID;

    -- Get the current values
    select @ProductID = min(ProductID),
           @TransactionDate = min(TransactionDate),
           @ActualCost = sum(ActualCost)
    from @TransHistory 
    where ID = @ID;

    if @ProductID <> 0
    begin
      set @RunningTotal45 = 0;

      if @ProductID <> @PrevProductID
      begin
        -- New product, reset running total
        set @RunningTotal = @ActualCost;
      end
      else
      begin
        -- Same product as last row, aggregate running total
        set @RunningTotal += @ActualCost;

        -- Get the running total as it was 45 days ago (or more)
        select top(1) @RunningTotal45 = TR.RunningTotal
        from @TransRes as TR
        where TR.ProductID = @ProductID and
              TR.TransactionDate < dateadd(day, -45, @TransactionDate)
        order by TR.TransactionDate desc;

      end;

      -- Add all rows that match ID to the result table
      -- RollingSum45 is calculated by using the current running total and the running total as it was 45 days ago (or more)
      insert into @TransRes(ID, ProductID, TransactionDate, ReferenceOrderID, ActualCost, RunningTotal, RollingSum45)
      select @ID, 
             @ProductID, 
             @TransactionDate, 
             TH.ReferenceOrderID, 
             TH.ActualCost, 
             @RunningTotal, 
             @RunningTotal - @RunningTotal45
      from @TransHistory as TH
      where ID = @ID;

    end
  end;

  -- Return the result table to caller
  select TR.ProductID, TR.TransactionDate, TR.ReferenceOrderID, TR.ActualCost, TR.RollingSum45
  from @TransRes as TR
  order by TR.ProductID, TR.TransactionDate, TR.ReferenceOrderID;

end;

이와 같은 절차를 호출하십시오.

-- Parameter to stored procedure GetRollingSum
declare @T dbo.TransHistory;

-- Load data to in-mem table
-- ID is unique for each combination of ProductID, TransactionDate
insert into @T(ID, ProductID, TransactionDate, ReferenceOrderID, ActualCost, RunningTotal, RollingSum45)
select dense_rank() over(order by TH.ProductID, TH.TransactionDate),
       TH.ProductID, 
       TH.TransactionDate, 
       TH.ReferenceOrderID,
       TH.ActualCost,
       0, 
       0
from Production.TransactionHistory as TH;

-- Get the rolling 45 days sum
exec dbo.GetRolling45 @T;

내 컴퓨터에서이를 테스트하면 클라이언트 통계에 약 750 밀리 초의 총 실행 시간이보고됩니다. 비교를 위해 하위 쿼리 버전은 3.5 초가 걸립니다.

추가 램 블링 :

이 알고리즘은 일반 T-SQL에서도 사용할 수 있습니다. range행이 아닌 누적 합계를 계산하고 결과를 임시 테이블에 저장하십시오. 그런 다음 45 일 전의 누적 합계에 자체 조인을 사용하여 해당 테이블을 쿼리하고 롤링 합계를 계산할 수 있습니다. 그러나 order by 절의 중복을 다르게 처리해야하기 때문에 range비교 방식의 구현 rows이 상당히 느리 므로이 방법으로 모든 성능을 얻지 못했습니다. 이에 대한 해결 방법 last_value()은 계산 된 누적 합계 와 같은 다른 윈도우 함수를 사용 하여 누적 합계 rows를 시뮬레이션하는 것 range입니다. 다른 방법은을 사용하는 것 max() over()입니다. 둘 다 몇 가지 문제가있었습니다. 정렬을 피하고 스풀을 피하기 위해 사용할 적절한 색인 찾기max() over()번역. 나는 그 것들을 최적화하는 것을 포기했지만 코드에 관심이 있다면 지금까지 알려주십시오.


13

잘 재미있었습니다 :) 내 솔루션은 @GeoffPatterson보다 약간 느리지 만 그중 일부는 Geoff의 가정 중 하나 (예 : 제품 / 날짜 쌍당 하나의 행)를 제거하기 위해 원래 테이블에 다시 연결한다는 사실입니다. . 나는 이것이 최종 쿼리의 단순화 된 버전이라고 가정하고 원래 테이블에서 추가 정보가 필요할 수 있습니다.

참고 : Geoff의 달력 테이블을 빌려 왔으며 실제로는 매우 유사한 솔루션으로 끝났습니다.

-- Build calendar table for 2000 ~ 2020
CREATE TABLE dbo.calendar (d DATETIME NOT NULL CONSTRAINT PK_calendar PRIMARY KEY)
GO
DECLARE @d DATETIME = '1/1/2000'
WHILE (@d < '1/1/2021')
BEGIN
    INSERT INTO dbo.calendar (d) VALUES (@d)
    SELECT @d =  DATEADD(DAY, 1, @d)
END

쿼리 자체는 다음과 같습니다.

WITH myCTE AS (SELECT PP.ProductID, calendar.d AS TransactionDate, 
                    SUM(ActualCost) AS CostPerDate
                FROM Production.Product PP
                CROSS JOIN calendar
                LEFT OUTER JOIN Production.TransactionHistory PTH
                    ON PP.ProductID = PTH.ProductID
                    AND calendar.d = PTH.TransactionDate
                CROSS APPLY (SELECT MAX(TransactionDate) AS EndDate,
                                MIN(TransactionDate) AS StartDate
                            FROM Production.TransactionHistory) AS Boundaries
                WHERE calendar.d BETWEEN Boundaries.StartDate AND Boundaries.EndDate
                GROUP BY PP.ProductID, calendar.d),
    RunningTotal AS (
        SELECT ProductId, TransactionDate, CostPerDate AS TBE,
                SUM(myCTE.CostPerDate) OVER (
                    PARTITION BY myCTE.ProductID
                    ORDER BY myCTE.TransactionDate
                    ROWS BETWEEN 
                        45 PRECEDING
                        AND CURRENT ROW) AS RollingSum45
        FROM myCTE)
SELECT 
    TH.ProductID,
    TH.TransactionDate,
    TH.ActualCost,
    RollingSum45
FROM Production.TransactionHistory AS TH
JOIN RunningTotal
    ON TH.ProductID = RunningTotal.ProductID
    AND TH.TransactionDate = RunningTotal.TransactionDate
WHERE RunningTotal.TBE IS NOT NULL
ORDER BY
    TH.ProductID,
    TH.TransactionDate,
    TH.ReferenceOrderID;

기본적으로 나는 그것을 다루는 가장 쉬운 방법은 ROWS 절에 대한 옵션입니다. 그러나 내가 하나 개 당 행이 것을 요구 ProductID, TransactionDate조합뿐 아니라 그,하지만 난 당 하나 개의 행이했다 ProductIDpossible date. CTE에서 Product, calendar 및 TransactionHistory 테이블을 결합하여 수행했습니다. 그런 다음 롤링 정보를 생성하기 위해 다른 CTE를 만들어야했습니다. 원래 테이블에 직접 결합하면 행 제거가 발생하여 결과가 사라지기 때문에이 작업을 수행해야했습니다. 그 후 두 번째 CTE를 원래 테이블에 다시 연결하는 것은 간단한 일이었습니다. 나는 추가 않았다 TBE의 없애 (가 제거 될 수있는) 열을 열팽창 계수에서 만든 행. 또한 CROSS APPLY초기 CTE에서 a 를 사용하여 달력 테이블의 경계를 생성했습니다.

그런 다음 권장 색인을 추가했습니다.

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

그리고 최종 실행 계획을 얻었습니다.

여기에 이미지 설명을 입력하십시오 여기에 이미지 설명을 입력하십시오 여기에 이미지 설명을 입력하십시오

편집 : 결국 나는 달력 테이블에 인덱스를 추가하여 성능을 합리적인 수준으로 올렸습니다.

CREATE INDEX ix_calendar ON calendar(d)

2
RunningTotal.TBE IS NOT NULL조건 (그리고, 결과적으로, TBE열)는 불필요하다. 내부 조인 조건에 날짜 열이 포함되어 있으므로 중복 행을 삭제하면 중복 행이 표시되지 않으므로 결과 집합에 원래 소스에 없었던 날짜가있을 수 없습니다.
Andriy M

2
네. 나는 완전히 동의합니다. 그럼에도 불구하고 여전히 약 0.2 초가 증가했습니다. 최적화 프로그램에 추가 정보를 알려주는 것 같습니다.
Kenneth Fisher

4

인덱스 또는 참조 테이블을 사용하지 않는 몇 가지 대체 솔루션이 있습니다. 추가 테이블에 액세스 할 수없고 인덱스를 작성할 수없는 상황에서 유용 할 수 있습니다. TransactionDate한 번의 데이터 전달과 하나의 창 기능만으로 그룹화 할 때 올바른 결과를 얻을 수있는 것으로 보입니다 . 그러나 그룹화 할 수없는 경우 하나의 창 기능으로 수행 할 수있는 방법을 알 수 없었습니다 TransactionDate.

참조 프레임을 제공하기 위해 내 컴퓨터에서 질문에 게시 된 원래 솔루션의 CPU 시간은 커버링 인덱스가없는 2808ms이고 커버링 인덱스가있는 1950ms입니다. AdventureWorks2014 데이터베이스 및 SQL Server Express 2014로 테스트하고 있습니다.

그룹화 할 수있는시기에 대한 솔루션부터 시작하겠습니다 TransactionDate. 지난 X 일 동안의 누적 합계는 다음과 같이 표현 될 수 있습니다.

행의 누계 = 모든 이전 행의 누계-날짜가 날짜 창 밖에있는 모든 이전 행의 누계입니다.

SQL에서이를 표현하는 한 가지 방법은 두 개의 데이터 사본을 작성하고 두 번째 사본의 경우 비용에 -1을 곱하고 날짜 열에 X + 1 일을 추가하는 것입니다. 모든 데이터에 대해 누계를 계산하면 위의 수식이 구현됩니다. 몇 가지 예제 데이터를 보여 드리겠습니다. 아래는 하나의 샘플 날짜입니다 ProductID. 계산을 쉽게하기 위해 날짜를 숫자로 나타냅니다. 데이터 시작 :

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

데이터의 두 번째 사본을 추가하십시오. 두 번째 사본에는 날짜에 46 일이 추가되고 비용에 -1이 곱해집니다.

╔══════╦══════╦═══════════╗
 Date  Cost  CopiedRow 
╠══════╬══════╬═══════════╣
    1     3          0 
    2     6          0 
   20     1          0 
   45    -4          0 
   47    -3          1 
   47     2          0 
   48    -6          1 
   64     2          0 
   66    -1          1 
   91     4          1 
   93    -2          1 
  110    -2          1 
╚══════╩══════╩═══════════╝

Date오름차순 및 CopiedRow내림차순 으로 정렬 된 누적 합계를 가져옵니다 .

╔══════╦══════╦═══════════╦════════════╗
 Date  Cost  CopiedRow  RunningSum 
╠══════╬══════╬═══════════╬════════════╣
    1     3          0           3 
    2     6          0           9 
   20     1          0          10 
   45    -4          0           6 
   47    -3          1           3 
   47     2          0           5 
   48    -6          1          -1 
   64     2          0           1 
   66    -1          1           0 
   91     4          1           4 
   93    -2          1           0 
  110    -2          1           0 
╚══════╩══════╩═══════════╩════════════╝

복사 한 행을 필터링하여 원하는 결과를 얻습니다.

╔══════╦══════╦═══════════╦════════════╗
 Date  Cost  CopiedRow  RunningSum 
╠══════╬══════╬═══════════╬════════════╣
    1     3          0           3 
    2     6          0           9 
   20     1          0          10 
   45    -4          0           6 
   47     2          0           5 
   64     2          0           1 
╚══════╩══════╩═══════════╩════════════╝

다음 SQL은 위의 알고리즘을 구현하는 한 가지 방법입니다.

WITH THGrouped AS 
(
    SELECT
    ProductID,
    TransactionDate,
    SUM(ActualCost) ActualCost
    FROM Production.TransactionHistory
    GROUP BY ProductID,
    TransactionDate
)
SELECT
ProductID,
TransactionDate,
ActualCost,
RollingSum45
FROM
(
    SELECT
    TH.ProductID,
    TH.ActualCost,
    t.TransactionDate,
    SUM(t.ActualCost) OVER (PARTITION BY TH.ProductID ORDER BY t.TransactionDate, t.OrderFlag) AS RollingSum45,
    t.OrderFlag,
    t.FilterFlag -- define this column to avoid another sort at the end
    FROM THGrouped AS TH
    CROSS APPLY (
        VALUES
        (TH.ActualCost, TH.TransactionDate, 1, 0),
        (-1 * TH.ActualCost, DATEADD(DAY, 46, TH.TransactionDate), 0, 1)
    ) t (ActualCost, TransactionDate, OrderFlag, FilterFlag)
) tt
WHERE tt.FilterFlag = 0
ORDER BY
tt.ProductID,
tt.TransactionDate,
tt.OrderFlag
OPTION (MAXDOP 1);

내 컴퓨터에서 이것은 커버링 인덱스가있는 702ms의 CPU 시간과 인덱스가없는 734ms의 CPU 시간을 소비했습니다. 쿼리 계획은 https://www.brentozar.com/pastetheplan/?id=SJdCsGVSl 에서 찾을 수 있습니다.

이 솔루션의 한 가지 단점은 새 TransactionDate열로 주문할 때 피할 수없는 정렬이있는 것 입니다. 순서를 수행하기 전에 두 개의 데이터 사본을 결합해야하기 때문에 색인을 추가 하여이 정렬을 해결할 수 있다고 생각하지 않습니다. ORDER BY에 다른 열을 추가하여 쿼리 끝에 정렬을 제거 할 수있었습니다. 내가 주문한 경우 FilterFlagSQL Server가 정렬에서 해당 열을 최적화하고 명시 적 정렬을 수행한다는 것을 알았습니다.

TransactionDate동일한 값에 대해 중복 된 값 으로 결과 집합을 반환해야하는 경우에 대한 솔루션 ProductId은 훨씬 더 복잡했습니다. 동일한 열을 기준으로 분할하고 순서를 지정 해야하는 것으로 문제를 요약합니다. Paul이 제공 한 구문은이 문제를 해결하므로 SQL Server에서 사용할 수있는 현재 창 함수를 사용하여 표현하기가 그리 어렵지는 않습니다 (표현하기 어려운 경우 구문을 확장 할 필요가 없음).

위의 쿼리를 그룹화하지 않고 사용하면 ProductIdand과 같은 여러 행이있을 때 롤링 합계에 대해 다른 값을 얻습니다 TransactionDate. 이를 해결하는 한 가지 방법은 위와 동일한 누적 합계 계산을 수행하고 파티션의 마지막 행에 플래그를 지정하는 것입니다. 이것은 추가 정렬없이 수행 할 수 있습니다 LEAD( ProductIDNULL이 아니라고 가정 ). 최종 누적 합계 값 MAX의 경우 파티션의 마지막 행에있는 값을 파티션의 모든 행에 적용하는 창 함수로 사용 합니다.

SELECT
ProductID,
TransactionDate,
ReferenceOrderID,
ActualCost,
MAX(CASE WHEN LasttRowFlag = 1 THEN RollingSum ELSE NULL END) OVER (PARTITION BY ProductID, TransactionDate) RollingSum45
FROM
(
    SELECT
    TH.ProductID,
    TH.ActualCost,
    TH.ReferenceOrderID,
    t.TransactionDate,
    SUM(t.ActualCost) OVER (PARTITION BY TH.ProductID ORDER BY t.TransactionDate, t.OrderFlag, TH.ReferenceOrderID) RollingSum,
    CASE WHEN LEAD(TH.ProductID) OVER (PARTITION BY TH.ProductID, t.TransactionDate ORDER BY t.OrderFlag, TH.ReferenceOrderID) IS NULL THEN 1 ELSE 0 END LasttRowFlag,
    t.OrderFlag,
    t.FilterFlag -- define this column to avoid another sort at the end
    FROM Production.TransactionHistory AS TH
    CROSS APPLY (
        VALUES
        (TH.ActualCost, TH.TransactionDate, 1, 0),
        (-1 * TH.ActualCost, DATEADD(DAY, 46, TH.TransactionDate), 0, 1)
    ) t (ActualCost, TransactionDate, OrderFlag, FilterFlag)
) tt
WHERE tt.FilterFlag = 0
ORDER BY
tt.ProductID,
tt.TransactionDate,
tt.OrderFlag,
tt.ReferenceOrderID
OPTION (MAXDOP 1);  

내 컴퓨터에서 커버링 인덱스없이 2464ms의 CPU 시간이 걸렸습니다. 이전과 마찬가지로 피할 수없는 정렬이있는 것으로 보입니다. 쿼리 계획은 https://www.brentozar.com/pastetheplan/?id=HyWxhGVBl 에서 찾을 수 있습니다.

위의 쿼리에는 개선의 여지가 있다고 생각합니다. 윈도우 함수를 사용하여 원하는 결과를 얻는 다른 방법이 있습니다.

당사 사이트를 사용함과 동시에 당사의 쿠키 정책개인정보 보호정책을 읽고 이해하였음을 인정하는 것으로 간주합니다.
Licensed under cc by-sa 3.0 with attribution required.