그룹당 n 개의 행 검색


88

결과 집합의 각 그룹에서 여러 행을 선택해야하는 경우가 종종 있습니다.

예를 들어, 고객 당 'n'가장 최근 또는 가장 최근의 주문 값을 나열하려고합니다.

더 복잡한 경우, 나열 할 행 수는 그룹 (그룹 / 부모 레코드의 속성으로 정의)에 따라 달라질 수 있습니다. 이 부분은 선택 사항이며 추가 크레딧을 제공하며 사람들이 응답하지 않도록 설득하지 않습니다.

SQL Server 2005 이상에서 이러한 유형의 문제를 해결하기위한 주요 옵션은 무엇입니까? 각 방법의 주요 장점과 단점은 무엇입니까?

AdventureWorks 예제 (명확성을 위해 옵션)

  1. TransactionHistoryM에서 R까지의 문자로 시작하는 각 제품에 대해 표 에서 가장 최근 5 개의 최근 거래 날짜와 ID를 나열하십시오 .
  2. n제품마다 히스토리 라인이 있지만 제품 속성의 n5 배 DaysToManufacture입니다.
  3. 제품 당 정확히 하나의 히스토리 라인이 필요한 특수한 경우 (에 의한 가장 최근의 단일 항목 TransactionDate인 tie-break on) TransactionID.

답변:


70

기본 시나리오부터 시작하겠습니다.

테이블에서 몇 개의 행을 얻으려면 두 가지 주요 옵션이 있습니다. 순위 함수; 또는 TOP.

먼저, 전체 Production.TransactionHistory의 특정 세트를 고려해 봅시다 ProductID.

SELECT h.TransactionID, h.ProductID, h.TransactionDate
FROM Production.TransactionHistory h
WHERE h.ProductID = 800;

그러면 418 개의 행이 반환되고 계획에 따라 테이블의 모든 행에서 필터를 제공 할 술어와 함께 제한되지 않은 클러스터형 인덱스 스캔을 찾는 것이 표시됩니다. 797은이 글을 읽었습니다.

'잔여'술어를 사용한 비싼 스캔

그래서 공정하게합시다. 더 유용한 인덱스를 만드십시오. 우리의 조건은에 등식 일치를 요구 ProductID하고 그 뒤에 가장 최근에 대한 검색이 이어집니다 TransactionDate. TransactionID반품도 필요 하므로 다음과 같이 갑시다 CREATE INDEX ix_FindingMostRecent ON Production.TransactionHistory (ProductID, TransactionDate) INCLUDE (TransactionID);.

이 작업을 수행 한 후 계획이 크게 변경되고 판독 값이 3으로 줄어 듭니다. 따라서 이미 250 배 이상 개선하고 있습니다.

개선 된 계획

이제 경기장의 레벨을 조정 했으므로 최상위 옵션 순위 기능 및을 살펴 보겠습니다 TOP.

WITH Numbered AS
(
SELECT h.TransactionID, h.ProductID, h.TransactionDate, ROW_NUMBER() OVER (ORDER BY TransactionDate DESC) AS RowNum
FROM Production.TransactionHistory h
WHERE h.ProductID = 800
)
SELECT TransactionID, ProductID, TransactionDate
FROM Numbered
WHERE RowNum <= 5;

SELECT TOP (5) h.TransactionID, h.ProductID, h.TransactionDate
FROM Production.TransactionHistory h
WHERE h.ProductID = 800
ORDER BY TransactionDate DESC;

두 가지 계획-기본 TOP \ RowNum

두 번째 ( TOP) 쿼리는 쿼리와 계획 모두에서 첫 번째 쿼리보다 훨씬 간단합니다. 그러나 매우 중요하게, 둘 다 TOP실제로 인덱스에서 제거되는 행 수를 제한하는 데 사용 합니다. 비용은 추정치이며 무시할 가치가 있지만 두 계획에서 많은 유사점을 볼 수 있습니다. ROW_NUMBER()버전은 숫자를 할당하고 필터링하기 위해 약간의 추가 작업을 수행하며 두 쿼리 모두 2 번 읽기를 수행합니다. 그들의 일. Query Optimizer는 ROW_NUMBER()필드에서 필터링하는 아이디어를 확실히 인식하여 필요하지 않은 행을 무시하기 위해 Top 연산자를 사용할 수 있음을 인식합니다. 이 두 쿼리는 충분히 TOP훌륭합니다. 코드를 변경하는 것이 가치가 있지만, 초보자에게는 더 간단하고 명확합니다.

따라서 이것은 단일 제품에서 작동합니다. 그러나 여러 제품에서이 작업을 수행해야하는 경우 어떻게되는지 고려해야합니다.

반복 프로그래머는 관심있는 제품을 반복하고이 쿼리를 여러 번 호출한다는 아이디어를 고려할 것입니다. 실제로 커서를 사용하지 않고를 사용하여이 형식으로 쿼리를 작성하면 실제로 벗어날 수 있습니다 APPLY. OUTER APPLY거래가없는 경우 NULL을 사용하여 제품을 반환 할 수 있다고 생각하면서을 사용 하고 있습니다.

SELECT p.Name, p.ProductID, t.TransactionID, t.TransactionDate
FROM 
Production.Product p
OUTER APPLY (
    SELECT TOP (5) h.TransactionID, h.ProductID, h.TransactionDate
    FROM Production.TransactionHistory h
    WHERE h.ProductID = p.ProductID
    ORDER BY TransactionDate DESC
) t
WHERE p.Name >= 'M' AND p.Name < 'S';

이를위한 계획은 반복적 인 프로그래머의 방법 인 Nested Loop이며 각 제품에 대해 Top 작업과 Seek (이전에 읽은 2 개의 읽기)를 수행합니다. 이렇게하면 Product에 대해 4 개의 읽기를, TransactionHistory에 대해 360을 읽습니다.

APPLY 계획

를 사용하여이 ROW_NUMBER()방법을 절 PARTITION BY에서 사용 하여 OVER각 제품의 번호 매기기를 다시 시작합니다. 그런 다음 이전처럼 필터링 할 수 있습니다. 계획은 상당히 다릅니다. TransactionHistory에서 논리적 읽기는 약 15 % 낮으며 전체 인덱스 스캔이 진행되어 행을 가져옵니다.

WITH Numbered AS
(
SELECT p.Name, p.ProductID, h.TransactionID, h.TransactionDate, ROW_NUMBER() OVER (PARTITION BY h.ProductID ORDER BY h.TransactionDate DESC) AS RowNum
FROM Production.Product p
LEFT JOIN Production.TransactionHistory h ON h.ProductID = p.ProductID
WHERE p.Name >= 'M' AND p.Name < 'S'
)
SELECT Name, ProductID, TransactionID, TransactionDate
FROM Numbered n
WHERE RowNum <= 5;

ROW_NUMBER 계획

그러나이 계획에는 값 비싼 정렬 연산자가 있습니다. 병합 조인은 TransactionHistory에서 행 순서를 유지하지 않는 것 같습니다. 행 번호를 찾을 수 있도록 데이터를 사용해야합니다. 읽기는 적지 만이 차단 정렬은 고통 스러울 수 있습니다. 를 사용 APPLY하면 Nested Loop는 몇 번의 읽기 후에 첫 번째 행을 매우 빠르게 반환하지만 Sort를 사용 ROW_NUMBER()하면 대부분의 작업이 완료된 후에 만 ​​행을 반환합니다.

흥미롭게도 ROW_NUMBER()쿼리가 INNER JOIN대신을 사용 LEFT JOIN하면 다른 계획이 나타납니다.

INNER JOIN의 ROW_NUMBER ()

이 계획은와 마찬가지로 Nested Loop를 사용합니다 APPLY. 그러나 Top 연산자가 없으므로 각 제품에 대한 모든 트랜잭션을 가져오고 이전보다 훨씬 많은 읽기를 사용합니다. TransactionHistory에 대한 492 읽기입니다. 여기에서 Merge Join 옵션을 선택하지 않는 좋은 이유가 없으므로 계획이 '충분히 좋은'것으로 간주 된 것 같습니다. 여전히-그것은 차단되지 않습니다. 그것은 좋지 않습니다 APPLY.

PARTITION BY내가 사용 열 ROW_NUMBER()였다 h.ProductID나는 QO에게 제품 테이블에 합류하기 전에 ROWNUM 값을 생성하는 옵션을주고 싶어했기 때문에, 두 경우 모두에서. 를 사용 p.ProductID하면 INNER JOIN변형 과 동일한 모양 계획이 나타납니다 .

WITH Numbered AS
(
SELECT p.Name, p.ProductID, h.TransactionID, h.TransactionDate, ROW_NUMBER() OVER (PARTITION BY p.ProductID ORDER BY h.TransactionDate DESC) AS RowNum
FROM Production.Product p
LEFT JOIN Production.TransactionHistory h ON h.ProductID = p.ProductID
WHERE p.Name >= 'M' AND p.Name < 'S'
)
SELECT Name, ProductID, TransactionID, TransactionDate
FROM Numbered n
WHERE RowNum <= 5;

그러나 Join 연산자는 'Inner Join'대신 'Left Outer Join'이라고 말합니다. TransactionHistory 테이블에 대한 읽기 수는 여전히 500 개 미만입니다.

h.ProductID 대신 p.ProductID의 PARTITION BY

어쨌든-손에 든 질문으로 돌아가십시오 ...

1 번 질문답변 했습니다. 선택하고 선택할 수있는 두 가지 옵션이 있습니다. 개인적으로 나는 APPLY옵션을 좋아한다 .

변수 번호 ( 질문 2 ) 를 사용하도록 이것을 확장하려면 5그에 따라 변경해야합니다. 아, 그리고 다른 색인을 추가 Production.Product.Name하여 DaysToManufacture열 을 포함 하는 색인이있었습니다 .

WITH Numbered AS
(
SELECT p.Name, p.ProductID, p.DaysToManufacture, h.TransactionID, h.TransactionDate, ROW_NUMBER() OVER (PARTITION BY h.ProductID ORDER BY h.TransactionDate DESC) AS RowNum
FROM Production.Product p
LEFT JOIN Production.TransactionHistory h ON h.ProductID = p.ProductID
WHERE p.Name >= 'M' AND p.Name < 'S'
)
SELECT Name, ProductID, TransactionID, TransactionDate
FROM Numbered n
WHERE RowNum <= 5 * DaysToManufacture;

SELECT p.Name, p.ProductID, t.TransactionID, t.TransactionDate
FROM 
Production.Product p
OUTER APPLY (
    SELECT TOP (5 * p.DaysToManufacture) h.TransactionID, h.ProductID, h.TransactionDate
    FROM Production.TransactionHistory h
    WHERE h.ProductID = p.ProductID
    ORDER BY TransactionDate DESC
) t
WHERE p.Name >= 'M' AND p.Name < 'S';

그리고 두 계획은 이전과 거의 동일합니다!

변수 행

다시 한 번, 예상 비용을 무시하십시오. 그러나 훨씬 더 간단하고 계획에 차단 운영자가 없으므로 TOP 시나리오가 여전히 좋습니다. 에서 0이 많기 때문에 TransactionHistory의 읽기는 적지 DaysToManufacture만 실제로는 해당 열을 선택하지 않을 것입니다. ;)

블록을 피하는 한 가지 방법 ROW_NUMBER()은 조인의 오른쪽 (계획에서) 비트를 처리하는 계획을 만드는 것 입니다. 우리는 CTE 외부에서 참여함으로써 이러한 일이 일어나도록 설득 할 수 있습니다.

WITH Numbered AS
(
SELECT h.TransactionID, h.ProductID, h.TransactionDate, ROW_NUMBER() OVER (PARTITION BY ProductID ORDER BY TransactionDate DESC) AS RowNum
FROM Production.TransactionHistory h
)
SELECT p.Name, p.ProductID, t.TransactionID, t.TransactionDate
FROM Production.Product p
LEFT JOIN Numbered t ON t.ProductID = p.ProductID
    AND t.RowNum <= 5 * p.DaysToManufacture
WHERE p.Name >= 'M' AND p.Name < 'S';

여기서 계획은 더 단순 해 보입니다. 차단하지는 않지만 숨겨진 위험이 있습니다.

CTE 외부 가입

Product 테이블에서 데이터를 가져 오는 Compute Scalar에 주목하십시오. 이것은 5 * p.DaysToManufacture가치를 해결하는 것입니다. 이 값은 TransactionHistory 테이블에서 데이터를 가져 오는 분기로 전달되지 않고 병합 조인에서 사용됩니다. 잔여로.

비열한 잔여 물!

따라서 병합 조인은 처음에는 많은 행이 필요하지만 모든 행을 소비 한 다음 모든 행을 소비 한 다음 잔차 검사를 수행합니다. 트랜잭션 수가 증가하면 위험합니다. 이 시나리오의 팬이 아닙니다. 병합 조인의 잔여 조건자는 빠르게 에스컬레이션 될 수 있습니다. 내가 APPLY/TOP시나리오를 선호하는 또 다른 이유 .

정확히 하나의 행인 특별한 경우, 질문 3 에 대해 동일한 쿼리를 사용할 수 있지만 1대신을 사용할 수 있습니다 5. 그러나 정규 집계를 사용하는 추가 옵션이 있습니다.

SELECT ProductID, MAX(TransactionDate)
FROM Production.TransactionHistory
GROUP BY ProductID;

이와 같은 쿼리는 유용한 시작이 될 것입니다. 우리는 연결 끊기 목적으로 (연결을 사용하여 분류 한 후) TransactionID를 꺼내도록 쉽게 수정할 수 있지만 전체 인덱스를 보거나 우리는 제품별로 다이빙을하며,이 시나리오에서 우리가 이전에했던 것에서 크게 개선되지는 않습니다.

그러나 여기서 우리는 특정 시나리오를보고 있음을 지적해야합니다. 실제 데이터와 이상적이지 않은 인덱싱 전략을 사용하면 마일리지가 크게 다를 수 있습니다. 우리가 APPLY여기에서 이것이 강력 하다는 것을 알았지 만 어떤 상황에서는 느려질 수 있습니다. 그래도 많은 사람들 (내 자신을 포함)이 매우 매력적으로 생각하는 Nested Loops를 사용하는 경향이 있기 때문에 거의 차단하지 않습니다.

나는 여기서 병렬 처리를 탐구하지 않았거나 3 번 질문으로 열심히 뛰어 들지 않았습니다.이 질문은 사람들이 연결과 분리의 합병증을 거의 원하지 않는 특별한 경우로 간주됩니다. 여기서 고려해야 할 주요 사항은이 두 가지 옵션이 모두 매우 강력하다는 것입니다.

나는 선호한다 APPLY. 분명히 Top 연산자를 잘 사용하며 블로킹을 거의 일으키지 않습니다.


44

SQL Server 2005 이상에서이 작업을 수행하는 일반적인 방법은 CTE 및 윈도우 기능을 사용하는 것입니다. 그룹당 상위 n 개의 ROW_NUMBER()경우 PARTITION절 과 함께 사용 하고 외부 쿼리에서 해당 항목을 필터링 할 수 있습니다. 예를 들어 고객 당 가장 최근 5 개의 주문을 다음과 같이 표시 할 수 있습니다.

DECLARE @top INT;
SET @top = 5;

;WITH grp AS 
(
   SELECT CustomerID, OrderID, OrderDate,
     rn = ROW_NUMBER() OVER
     (PARTITION BY CustomerID ORDER BY OrderDate DESC)
   FROM dbo.Orders
)
SELECT CustomerID, OrderID, OrderDate
  FROM grp
  WHERE rn <= @top
  ORDER BY CustomerID, OrderDate DESC;

당신은 또한 이것을 할 수 있습니다 CROSS APPLY:

DECLARE @top INT;
SET @top = 5;

SELECT c.CustomerID, o.OrderID, o.OrderDate
FROM dbo.Customers AS c
CROSS APPLY 
(
    SELECT TOP (@top) OrderID, OrderDate 
    FROM dbo.Orders AS o
    WHERE CustomerID = c.CustomerID
    ORDER BY OrderDate DESC
) AS o
ORDER BY c.CustomerID, o.OrderDate DESC;

추가 옵션 Paul을 지정하면 Customers 테이블에 고객 당 포함 할 행 수를 나타내는 열이 있다고 가정하십시오.

;WITH grp AS 
(
   SELECT CustomerID, OrderID, OrderDate,
     rn = ROW_NUMBER() OVER
     (PARTITION BY CustomerID ORDER BY OrderDate DESC)
   FROM dbo.Orders
)
SELECT c.CustomerID, grp.OrderID, grp.OrderDate
  FROM grp 
  INNER JOIN dbo.Customers AS c
  ON grp.CustomerID = c.CustomerID
  AND grp.rn <= c.Number_of_Recent_Orders_to_Show
  ORDER BY c.CustomerID, grp.OrderDate DESC;

다시 한 번, CROSS APPLY추가 된 옵션을 사용 하고 통합하여 customers 테이블의 일부 열이 고객의 행 수를 지시합니다.

SELECT c.CustomerID, o.OrderID, o.OrderDate
FROM dbo.Customers AS c
CROSS APPLY 
(
    SELECT TOP (c.Number_of_Recent_Orders_to_Show) OrderID, OrderDate 
    FROM dbo.Orders AS o
    WHERE CustomerID = c.CustomerID
    ORDER BY OrderDate DESC
) AS o
ORDER BY c.CustomerID, o.OrderDate DESC;

이는 데이터 배포 및 지원 인덱스의 가용성에 따라 다르게 수행되므로 성능을 최적화하고 최상의 계획을 얻는 것은 실제로 지역 요인에 따라 다릅니다.

개인적으로, 나는 논리를 더 잘 분리하고 더 직관적이기 때문에 CROSS APPLY/ 보다 CTE 및 창 솔루션을 선호합니다 TOP. 일반적으로 (이 경우와 일반적인 경험 모두에서) CTE 접근 방식은보다 효율적인 계획 (아래 예)을 생성하지만 이것이 보편적 인 진실로 간주되어서는 안됩니다. 특히 인덱스가 변경되었거나 데이터가 크게 왜곡되었습니다.


AdventureWorks 예제-변경 사항 없음

  1. TransactionHistoryM에서 R까지의 문자로 시작하는 각 제품에 대해 표 에서 가장 최근 5 개의 최근 거래 날짜와 ID를 나열하십시오 .
-- CTE / OVER()

;WITH History AS
(
  SELECT p.ProductID, p.Name, t.TransactionID, t.TransactionDate,
    rn = ROW_NUMBER() OVER 
    (PARTITION BY t.ProductID ORDER BY t.TransactionDate DESC)
  FROM Production.Product AS p
  INNER JOIN Production.TransactionHistory AS t
  ON p.ProductID = t.ProductID
  WHERE p.Name >= N'M' AND p.Name < N'S'
)
SELECT ProductID, Name, TransactionID, TransactionDate
FROM History 
WHERE rn <= 5;

-- CROSS APPLY

SELECT p.ProductID, p.Name, t.TransactionID, t.TransactionDate
FROM Production.Product AS p
CROSS APPLY
(
  SELECT TOP (5) TransactionID, TransactionDate
  FROM Production.TransactionHistory
  WHERE ProductID = p.ProductID
  ORDER BY TransactionDate DESC
) AS t
WHERE p.Name >= N'M' AND p.Name < N'S';

런타임 메트릭에서이 두 가지 비교 :

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

CTE / OVER()계획 :

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

CROSS APPLY 계획:

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

CTE 계획은 더 복잡해 보이지만 실제로는 훨씬 더 효율적입니다. 예상 비용 % 숫자에는 거의주의를 기울이지 않지만 훨씬 적은 판독 및 지속 시간과 같은 더 중요한 실제 관찰 에 중점을 둡니다 . 나는 또한 병렬 처리없이 이것을 실행했지만 차이는 없었습니다. 런타임 메트릭과 CTE 계획 ( CROSS APPLY계획은 동일하게 유지됨) :

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

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

  1. n제품마다 히스토리 라인이 있지만 제품 속성의 n5 배 DaysToManufacture입니다.

여기서 약간의 변경이 필요합니다. CTE의 경우 내부 쿼리에 열을 추가하고 외부 쿼리를 필터링 할 수 있습니다. 에 대해 CROSS APPLY, 우리는 상관 내부에서 계산을 수행 할 수 있습니다 TOP. 이것이 CROSS APPLY솔루션에 약간의 효율성을 제공한다고 생각 하지만이 경우에는 발생하지 않습니다. 검색어 :

-- CTE / OVER()

;WITH History AS
(
  SELECT p.ProductID, p.Name, p.DaysToManufacture, t.TransactionID, t.TransactionDate,
    rn = ROW_NUMBER() OVER 
    (PARTITION BY t.ProductID ORDER BY t.TransactionDate DESC)
  FROM Production.Product AS p
  INNER JOIN Production.TransactionHistory AS t
  ON p.ProductID = t.ProductID
  WHERE p.Name >= N'M' AND p.Name < N'S'
)
SELECT ProductID, Name, TransactionID, TransactionDate
FROM History 
WHERE rn <= (5 * DaysToManufacture);

-- CROSS APPLY

SELECT p.ProductID, p.Name, t.TransactionID, t.TransactionDate
FROM Production.Product AS p
CROSS APPLY
(
  SELECT TOP (5 * p.DaysToManufacture) TransactionID, TransactionDate
  FROM Production.TransactionHistory
  WHERE ProductID = p.ProductID
  ORDER BY TransactionDate DESC
) AS t
WHERE p.Name >= N'M' AND p.Name < N'S';

런타임 결과 :

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

병렬 CTE / OVER()계획 :

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

단일 스레드 CTE / OVER()계획 :

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

CROSS APPLY 계획:

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

  1. 제품 당 정확히 하나의 히스토리 라인이 필요한 특수한 경우 (에 의한 가장 최근의 단일 항목 TransactionDate인 tie-break on) TransactionID.

다시 한 번 사소한 변경이 있습니다. CTE 솔루션에서 우리 TransactionIDOVER()절에 추가 하고 외부 필터를로 변경합니다 rn = 1. 를 들어 CROSS APPLY, 우리는 변화 TOPTOP (1), 그리고 추가 TransactionID내부에 ORDER BY.

-- CTE / OVER()

;WITH History AS
(
  SELECT p.ProductID, p.Name, t.TransactionID, t.TransactionDate,
    rn = ROW_NUMBER() OVER 
    (PARTITION BY t.ProductID ORDER BY t.TransactionDate DESC, TransactionID DESC)
  FROM Production.Product AS p
  INNER JOIN Production.TransactionHistory AS t
  ON p.ProductID = t.ProductID
  WHERE p.Name >= N'M' AND p.Name < N'S'
)
SELECT ProductID, Name, TransactionID, TransactionDate
FROM History 
WHERE rn = 1;

-- CROSS APPLY

SELECT p.ProductID, p.Name, t.TransactionID, t.TransactionDate
FROM Production.Product AS p
CROSS APPLY
(
  SELECT TOP (1) TransactionID, TransactionDate
  FROM Production.TransactionHistory
  WHERE ProductID = p.ProductID
  ORDER BY TransactionDate DESC, TransactionID DESC
) AS t
WHERE p.Name >= N'M' AND p.Name < N'S';

런타임 결과 :

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

병렬 CTE / OVER()계획 :

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

단일 스레드 CTE / OVER () 계획 :

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

CROSS APPLY 계획:

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

윈도 잉 함수가 항상 최선의 대안 COUNT(*) OVER()은 아니지만 (그룹으로 이동) 그룹당 n 개의 행 문제를 해결하는 유일한 방법은 아니지만 스키마, 기존 인덱스 및 데이터 배포를 고려할 때- CTE는 모든 의미있는 계정으로 더 나아졌습니다.


AdventureWorks 예제-유연한 인덱스 추가

그러나 주석에 언급 되었지만 Paul 과 비슷 하지만 두 번째 및 세 번째 열이 정렬 된 지원 색인을 추가하는 경우 DESC:

CREATE UNIQUE NONCLUSTERED INDEX UQ3 ON Production.TransactionHistory 
  (ProductID, TransactionDate DESC, TransactionID DESC);

실제로 훨씬 더 유리한 계획을 얻을 수 있으며 메트릭은 CROSS APPLY세 가지 경우 모두 접근 방식 을 선호합니다 .

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

이것이 내 프로덕션 환경이라면 아마도이 경우의 기간에 만족할 것이고 더 이상 최적화하지 않아도됩니다.


이것은 모든 지원하지 않은 2000 SQL 서버, 훨씬 더 추악한이었다 APPLY또는 OVER()절.


24

윈도우 기능이없는 MySQL과 같은 DBMS에서 또는 CROSS APPLY이를 수행하는 방법은 표준 SQL (89)을 사용하는 것입니다. 느린 방법은 집계와의 삼각형 교차 결합입니다. 더 빠른 방법 (그러나 cross apply 또는 row_number 함수를 사용하는 것보다 여전히 효율적이지는 않지만)은 "poor man 's CROSS APPLY"라고 합니다. 이 쿼리를 다른 쿼리와 비교하는 것이 흥미로울 것입니다.

가정은 : Orders (CustomerID, OrderDate)UNIQUE제약 조건을 :

DECLARE @top INT;
SET @top = 5;

SELECT o.CustomerID, o.OrderID, o.OrderDate
  FROM dbo.Customers AS c
    JOIN dbo.Orders AS o
      ON  o.CustomerID = c.CustomerID
      AND o.OrderID IN
          ( SELECT TOP (@top) oi.OrderID
            FROM dbo.Orders AS oi
            WHERE oi.CustomerID = c.CustomerID
            ORDER BY oi.OrderDate DESC
          )
  ORDER BY CustomerID, OrderDate DESC ;

그룹당 사용자 정의 된 최상위 행의 추가 문제점 :

SELECT o.CustomerID, o.OrderID, o.OrderDate
  FROM dbo.Customers AS c
    JOIN dbo.Orders AS o
      ON  o.CustomerID = c.CustomerID
      AND o.OrderID IN
          ( SELECT TOP (c.Number_of_Recent_Orders_to_Show) oi.OrderID
            FROM dbo.Orders AS oi
            WHERE oi.CustomerID = c.CustomerID
            ORDER BY oi.OrderDate DESC
          )
  ORDER BY CustomerID, OrderDate DESC ;

참고 : MySQL에서는 대신을 AND o.OrderID IN (SELECT TOP(@top) oi.OrderID ...)사용 AND o.OrderDate >= (SELECT oi.OrderDate ... LIMIT 1 OFFSET (@top - 1))합니다. SQL-Server FETCH / OFFSET는 2012 버전에서 구문을 추가 했습니다. 여기의 쿼리는 IN (TOP...)이전 버전에서 작동 하도록 조정되었습니다 .


21

나는 옵션이 좋은 것이기 때문에 주로이 기술이 다른 기술과 어떻게 비교되는지 알기 위해 약간 다른 접근 방식을 취했습니다.

테스트

다양한 방법이 서로 어떻게 쌓여 있는지 살펴 보는 것으로 시작하지 않겠습니까? 세 가지 테스트를 수행했습니다.

  1. 첫 번째 세트는 DB 수정없이 실행되었습니다.
  2. 에 대한 TransactionDate기반 쿼리 를 지원하기 위해 인덱스가 작성된 후 두 번째 세트가 실행되었습니다 Production.TransactionHistory.
  3. 세 번째 세트는 약간 다른 가정을했습니다. 세 가지 테스트 모두 동일한 제품 목록에 대해 실행되었으므로 해당 목록을 캐시하면 어떻게됩니까? 내 방법은 메모리 내 캐시를 사용하는 반면 다른 방법은 동등한 임시 테이블을 사용했습니다. 두 번째 테스트 세트에 대해 작성된 지원 색인은이 테스트 세트에 여전히 존재합니다.

추가 테스트 세부 사항 :

  • 테스트는 AdventureWorks2012SQL Server 2012 SP2 (Developer Edition)에서 실행되었습니다.
  • 각 테스트마다 나는 그 질문에 대한 답변과 어떤 특정 쿼리에 대한 답을 표시했습니다.
  • 쿼리 옵션 | "실행 후 결과 폐기"옵션을 사용했습니다. 결과.
  • 처음 두 세트의 테스트에서 RowCounts내 방법에 대해 "끄기"로 나타납니다. 이것은 내 방법이 수행중인 작업의 수동 구현이기 CROSS APPLY때문입니다. 초기 쿼리를 실행하고 Production.Product161 행을 가져 와서 에 대해 쿼리에 사용합니다 Production.TransactionHistory. 따라서 RowCount내 항목 의 값은 항상 다른 항목보다 161 더 큽니다. 세 번째 테스트 (캐싱 사용)에서 행 수는 모든 방법에 대해 동일합니다.
  • 실행 계획에 의존하는 대신 SQL Server 프로파일 러를 사용하여 통계를 캡처했습니다. Aaron과 Mikael은 이미 쿼리 계획을 보여주는 훌륭한 작업을 수행했으며 해당 정보를 재현 할 필요가 없습니다. 그리고 내 방법의 의도는 쿼리를 실제로 중요하지 않은 간단한 형태로 줄이는 것입니다. 프로파일 러를 사용하는 또 다른 이유가 있지만 나중에 설명하겠습니다.
  • Name >= N'M' AND Name < N'S'구문을 사용하는 대신 을 사용하기로 선택 Name LIKE N'[M-R]%'했으며 SQL Server는이를 동일하게 취급합니다.

결과

지지 지수 없음

이것은 기본적으로 제공되는 AdventureWorks2012입니다. 모든 경우에 내 방법은 다른 방법보다 분명히 우수하지만 상위 1 또는 2 방법만큼 좋은 것은 아닙니다.

테스트 1 인덱스가없는 테스트 1 결과
Aaron의 CTE가 분명히 승자입니다.

테스트 2 인덱스가없는 테스트 2 결과
Aaron의 CTE (다시)와 Mikael의 두 번째 apply row_number()방법은 아주 가깝습니다.

테스트 3 인덱스가없는 테스트 3 결과
Aaron의 CTE (다시)가 승자입니다.

결론
에 대한 지원 색인이없는 TransactionDate경우 표준 방법보다 내 방법이 낫지 CROSS APPLY만 여전히 CTE 방법을 사용하는 것이 좋습니다 .

지원 인덱스 포함 (캐싱 없음)

이 테스트 세트의 경우 TransactionHistory.TransactionDate모든 쿼리가 해당 필드에서 정렬되므로 명백한 색인을 추가했습니다 . 다른 답변들도이 점에 동의하기 때문에 "명백하다"고 말합니다. 쿼리가 모두 가장 최근 날짜를 원하기 때문에 TransactionDate필드를 정렬해야 DESC하므로 CREATE INDEXMikael의 답변 맨 아래에 있는 진술을 잡고 명시 적으로 추가했습니다 FILLFACTOR.

CREATE INDEX [IX_TransactionHistoryX]
    ON Production.TransactionHistory (ProductID ASC, TransactionDate DESC)
    WITH (FILLFACTOR = 100);

이 지수가 제정되면 결과가 약간 변경됩니다.

테스트 1 테스트 1 결과 지원 인덱스
이번에는 적어도 논리적 읽기와 관련하여 앞서 나오는 방법입니다. CROSS APPLY방법, 시험 1 이전에 최악의 활약은, 기간에 승리하고도 논리적 읽고에 CTE 방법을 친다.

테스트 2 테스트 2 결과 지원 인덱스
이번에 apply row_number()는 Reads를 볼 때 가장 좋은 방법 은 Mikael의 첫 번째 방법이지만, 이전에는 최악의 성능 중 하나였습니다. 그리고 이제 내 방법은 Reads를 볼 때 매우 가까운 2 위를 차지합니다. 실제로 CTE 방법 이외의 나머지는 모두 읽기 측면에서 상당히 가깝습니다.

테스트 3 테스트 3 결과 지원 인덱스
여기서 CTE가 여전히 승자이지만, 이제 다른 방법들 사이의 차이는 인덱스를 만들기 전에 존재했던 과감한 차이와 비교할 때 거의 눈에 띄지 않습니다.

결론
적절한 인덱스를 사용하지 않는 것이 탄력성이 떨어지지 만 내 방법의 적용 가능성은 이제 더 분명합니다.

인덱스 및 캐싱 지원

이 테스트 세트에서 캐싱을 사용했습니다. 왜 그렇지 않습니까? 내 방법을 사용하면 다른 방법으로 액세스 할 수없는 메모리 내 캐싱을 사용할 수 있습니다. 공정하게 Product.Product하기 위해 세 가지 테스트 모두에서 다른 방법의 모든 참조 대신 사용되는 다음 임시 테이블을 만들었습니다 . 이 DaysToManufacture필드는 테스트 번호 2에서만 사용되지만 동일한 테이블을 사용하기 위해 SQL 스크립트에서 일관성을 유지하는 것이 더 쉬웠으며 거기에 두는 것이 아프지 않았습니다.

CREATE TABLE #Products
(
    ProductID INT NOT NULL PRIMARY KEY,
    Name NVARCHAR(50) NOT NULL,
    DaysToManufacture INT NOT NULL
);

INSERT INTO #Products (ProductID, Name, DaysToManufacture)
    SELECT  p.ProductID, p.Name, p.DaysToManufacture
    FROM    Production.Product p
    WHERE   p.Name >= N'M' AND p.Name < N'S'
    AND    EXISTS (
                    SELECT  *
                    FROM    Production.TransactionHistory th
                    WHERE   th.ProductID = p.ProductID
                );

ALTER TABLE #Products REBUILD WITH (FILLFACTOR = 100);

테스트 1 인덱스 1과 캐싱을 지원하는 테스트 1 결과
모든 방법이 캐싱에서 똑같이 이익을 얻는 것처럼 보이며 제 방법은 여전히 ​​앞서 나옵니다.

테스트 2 인덱스 2와 캐싱을 지원하는 테스트 2 결과
이제 우리의 방법이 간신히 나올 때 라인업의 차이를 볼 수 있습니다. 2 개의 읽기만 Mikael의 첫 번째 apply row_number()방법 보다 나은 반면, 캐싱이 없으면 내 방법은 4 개의 읽기로 이루어졌습니다.

테스트 3 인덱스 3과 캐싱을 지원하는 테스트 3 결과
하단 (라인 아래)으로 업데이트를 참조하십시오 . 여기서 우리는 또 다른 차이점을 보게됩니다. 내 분석법의 "매개 변수화 된"맛은 이제 Aaron의 CROSS APPLY 방법 (캐싱없이 동일)에 비해 2 개의 읽기로 인해 거의 선두에 서 있지 않습니다. 그러나 정말 이상한 점은 캐싱에 부정적인 영향을받는 방법 인 Aaron의 CTE 방법 (처음에는 테스트 번호 3에 가장 적합 함)을 처음으로 보는 것입니다. 그러나 캐싱이 없으면 Aaron의 CTE 방법이 캐싱과 함께 사용하는 방법보다 여전히 빠르기 때문에이 특정 상황에 대한 가장 좋은 방법은 Aaron의 CTE 방법으로 보입니다.

결론 하단 (라인 아래)으로 업데이트를 참조하십시오
. 보조 쿼리의 결과를 반복적으로 사용하는 상황은 이러한 결과를 캐싱하는 데 도움이 될 수 있습니다 (항상 그런 것은 아님). 그러나 캐싱이 이점 인 경우, 상기 캐싱에 메모리를 사용하면 임시 테이블을 사용하는 것보다 몇 가지 이점이 있습니다.

방법

일반적으로

나는 "헤더"질의 분리 (즉, 점점 ProductID들, 하나의 경우도 DaysToManufacture,에 기초하여 Name특정 문자로 시작하는)은 "상세"쿼리에서 (즉,이 점점 TransactionID들과 TransactionDate들). 개념은 매우 간단한 쿼리를 수행하고 옵티마이 저가 조인 할 때 혼동되지 않도록하는 것입니다. 분명히 이것은 최적화 프로그램이 최적화를 잘하지 못하기 때문에 항상 유리 하지는 않습니다 . 그러나 결과에서 볼 수 있듯이 쿼리 유형에 따라이 방법에는 장점이 있습니다.

이 방법의 다양한 맛의 차이점은 다음과 같습니다.

  • 상수 : 대체 가능한 값을 매개 변수 대신 인라인 상수로 제출하십시오. 이는 ProductID" DaysToManufacture제품 속성의 5 배"기능이므로 테스트 2에서 반환 할 행 수와 세 가지 테스트 모두에서 언급됩니다 . 이 하위 방법은 각각 ProductID고유 한 실행 계획을 얻게되며, 이는 데이터 배포에 다양한 변형이있는 경우 유용 할 수 있습니다 ProductID. 그러나 데이터 배포에 변동이 거의없는 경우 추가 계획을 생성하는 비용은 그만한 가치가 없을 것입니다.

  • 매개 변수화 : 최소한 ProductID으로 제출하여 @ProductID실행 계획 캐싱 및 재사용을 허용하십시오. 테스트 2에 대해 리턴 할 변수 행 수를 매개 변수로 처리하기위한 추가 테스트 옵션이 있습니다.

  • 알 수없는 최적화 : 참조하는 경우 ProductID@ProductID, 데이터 분포의 폭 넓은 변화가있을 경우 다음 다른에 부정적인 영향이 계획 캐시 할 수 ProductID는이 쿼리 힌트를 사용하여 어떤 도움이되는지 알고 좋은 것, 그래서 값을.

  • 캐시 제품 :Production.Product 매번 테이블을 쿼리하지 않고 정확히 동일한 목록을 가져 와서 쿼리를 한 번만 실행하십시오 (그리고 우리가 테이블에 있지 않은 ProductID동안 TransactionHistory테이블에 있지 않은 모든 것을 필터링하여 낭비하지 않도록하십시오) 리소스를 찾고 해당 목록을 캐시합니다. 목록에는 DaysToManufacture필드 가 포함되어야 합니다. 이 옵션을 사용하면 첫 번째 실행에 대한 논리적 읽기에서 초기 히트가 약간 더 높지만 그 후에 TransactionHistory는 쿼리 된 테이블 만됩니다 .

구체적으로

그러나 CURSOR를 사용하지 않고 각 결과 세트를 임시 테이블 또는 테이블 변수에 덤프하지 않고 모든 하위 쿼리를 개별 쿼리로 발행하는 방법은 무엇입니까? CURSOR / Temp Table 방법을 명확하게 수행하면 읽기 및 쓰기에 분명히 반영됩니다. 글쎄, SQLCLR을 사용하여 :). SQLCLR 저장 프로 시저를 만들면 결과 집합을 열고 기본적으로 각 하위 쿼리의 결과를 여러 결과 집합이 아닌 연속 결과 집합으로 스트리밍 할 수있었습니다. 제품 정보 외부 (예 ProductID: Name, 및DaysToManufacture) 하위 쿼리 결과는 어디에도 저장하지 않아도되며 (메모리 또는 디스크) SQLCLR 저장 프로 시저의 기본 결과 집합으로 전달되지 않습니다. 이를 통해 제품 정보를 얻기 위해 간단한 쿼리를 수행 한 다음에 대해 매우 간단한 쿼리를 수행하여 제품 정보를 살펴볼 수있었습니다 TransactionHistory.

그리고 이것이 통계를 캡처하기 위해 SQL Server 프로파일 러를 사용해야하는 이유입니다. "실제 실행 계획 포함"쿼리 옵션을 설정하거나을 실행하여 SQLCLR 저장 프로 시저가 실행 계획을 반환하지 않았습니다 SET STATISTICS XML ON;.

제품 정보 캐싱을 위해 readonly static일반 목록 (예 : _GlobalProducts아래 코드)을 사용했습니다. 이 컬렉션에 추가하는 것은 위반하지 않는 것 같다 readonly어셈블리가있을 때 따라서이 코드가 작동, 옵션 PERMISSON_SETSAFE그 반 직관적 경우에도 :)을.

생성 된 쿼리

이 SQLCLR 저장 프로 시저에서 생성 된 쿼리는 다음과 같습니다.

제품 정보

테스트 번호 1과 3 (캐싱 없음)

SELECT prod1.ProductID, prod1.Name, 1 AS [DaysToManufacture]
FROM   Production.Product prod1
WHERE  prod1.Name LIKE N'[M-R]%';

테스트 번호 2 (캐싱 없음)

;WITH cte AS
(
    SELECT prod1.ProductID
    FROM   Production.Product prod1 WITH (INDEX(AK_Product_Name))
    WHERE  prod1.Name LIKE N'[M-R]%'
)
SELECT prod2.ProductID, prod2.Name, prod2.DaysToManufacture
FROM   Production.Product prod2
INNER JOIN cte
        ON cte.ProductID = prod2.ProductID;

테스트 번호 1, 2 및 3 (캐싱)

;WITH cte AS
(
    SELECT prod1.ProductID
    FROM   Production.Product prod1 WITH (INDEX(AK_Product_Name))
    WHERE  prod1.Name LIKE N'[M-R]%'
    AND    EXISTS (
                SELECT *
                FROM Production.TransactionHistory th
                WHERE th.ProductID = prod1.ProductID
                  )
)
SELECT prod2.ProductID, prod2.Name, prod2.DaysToManufacture
FROM   Production.Product prod2
INNER JOIN cte
        ON cte.ProductID = prod2.ProductID;

거래 정보

테스트 번호 1과 2 (상수)

SELECT TOP (5) th.TransactionID, th.TransactionDate
FROM   Production.TransactionHistory th
WHERE  th.ProductID = 977
ORDER BY th.TransactionDate DESC;

테스트 번호 1 및 2 (파라미터 화)

SELECT TOP (5) th.TransactionID, th.TransactionDate
FROM   Production.TransactionHistory th
WHERE  th.ProductID = @ProductID
ORDER BY th.TransactionDate DESC
;

테스트 번호 1 및 2 (파라미터 + OPTIMIZE UNKNOWN)

SELECT TOP (5) th.TransactionID, th.TransactionDate
FROM   Production.TransactionHistory th
WHERE  th.ProductID = @ProductID
ORDER BY th.TransactionDate DESC
OPTION (OPTIMIZE FOR (@ProductID UNKNOWN));

테스트 번호 2 (모두 매개 변수화 됨)

SELECT TOP (@RowsToReturn) th.TransactionID, th.TransactionDate
FROM   Production.TransactionHistory th
WHERE  th.ProductID = @ProductID
ORDER BY th.TransactionDate DESC
;

테스트 번호 2 (모두 매개 변수화 됨 + 최적화 불가)

SELECT TOP (@RowsToReturn) th.TransactionID, th.TransactionDate
FROM   Production.TransactionHistory th
WHERE  th.ProductID = @ProductID
ORDER BY th.TransactionDate DESC
OPTION (OPTIMIZE FOR (@ProductID UNKNOWN));

테스트 번호 3 (상수)

SELECT TOP (1) th.TransactionID, th.TransactionDate
FROM   Production.TransactionHistory th
WHERE  th.ProductID = 977
ORDER BY th.TransactionDate DESC, th.TransactionID DESC;

테스트 번호 3 (파라미터 화)

SELECT TOP (1) th.TransactionID, th.TransactionDate
FROM   Production.TransactionHistory th
WHERE  th.ProductID = @ProductID
ORDER BY th.TransactionDate DESC, th.TransactionID DESC
;

테스트 번호 3 (파라미터 + OPTIMIZE UNKNOWN)

SELECT TOP (1) th.TransactionID, th.TransactionDate
FROM   Production.TransactionHistory th
WHERE  th.ProductID = @ProductID
ORDER BY th.TransactionDate DESC, th.TransactionID DESC
OPTION (OPTIMIZE FOR (@ProductID UNKNOWN));

코드

using System;
using System.Collections.Generic;
using System.Data;
using System.Data.SqlClient;
using System.Data.SqlTypes;
using Microsoft.SqlServer.Server;

public class ObligatoryClassName
{
    private class ProductInfo
    {
        public int ProductID;
        public string Name;
        public int DaysToManufacture;

        public ProductInfo(int ProductID, string Name, int DaysToManufacture)
        {
            this.ProductID = ProductID;
            this.Name = Name;
            this.DaysToManufacture = DaysToManufacture;

            return;
        }
    }

    private static readonly List<ProductInfo> _GlobalProducts = new List<ProductInfo>();

    private static void PopulateGlobalProducts(SqlBoolean PrintQuery)
    {
        if (_GlobalProducts.Count > 0)
        {
            if (PrintQuery.IsTrue)
            {
                SqlContext.Pipe.Send(String.Concat("I already haz ", _GlobalProducts.Count,
                            " entries :)"));
            }

            return;
        }

        SqlConnection _Connection = new SqlConnection("Context Connection = true;");
        SqlCommand _Command = new SqlCommand();
        _Command.CommandType = CommandType.Text;
        _Command.Connection = _Connection;
        _Command.CommandText = @"
   ;WITH cte AS
   (
     SELECT prod1.ProductID
     FROM   Production.Product prod1 WITH (INDEX(AK_Product_Name))
     WHERE  prod1.Name LIKE N'[M-R]%'
     AND    EXISTS (
                     SELECT *
                     FROM Production.TransactionHistory th
                     WHERE th.ProductID = prod1.ProductID
                   )
   )
   SELECT prod2.ProductID, prod2.Name, prod2.DaysToManufacture
   FROM   Production.Product prod2
   INNER JOIN cte
           ON cte.ProductID = prod2.ProductID;
";

        SqlDataReader _Reader = null;

        try
        {
            _Connection.Open();

            _Reader = _Command.ExecuteReader();

            while (_Reader.Read())
            {
                _GlobalProducts.Add(new ProductInfo(_Reader.GetInt32(0), _Reader.GetString(1),
                                                    _Reader.GetInt32(2)));
            }
        }
        catch
        {
            throw;
        }
        finally
        {
            if (_Reader != null && !_Reader.IsClosed)
            {
                _Reader.Close();
            }

            if (_Connection != null && _Connection.State != ConnectionState.Closed)
            {
                _Connection.Close();
            }

            if (PrintQuery.IsTrue)
            {
                SqlContext.Pipe.Send(_Command.CommandText);
            }
        }

        return;
    }


    [Microsoft.SqlServer.Server.SqlProcedure]
    public static void GetTopRowsPerGroup(SqlByte TestNumber,
        SqlByte ParameterizeProductID, SqlBoolean OptimizeForUnknown,
        SqlBoolean UseSequentialAccess, SqlBoolean CacheProducts, SqlBoolean PrintQueries)
    {
        SqlConnection _Connection = new SqlConnection("Context Connection = true;");
        SqlCommand _Command = new SqlCommand();
        _Command.CommandType = CommandType.Text;
        _Command.Connection = _Connection;

        List<ProductInfo> _Products = null;
        SqlDataReader _Reader = null;

        int _RowsToGet = 5; // default value is for Test Number 1
        string _OrderByTransactionID = "";
        string _OptimizeForUnknown = "";
        CommandBehavior _CmdBehavior = CommandBehavior.Default;

        if (OptimizeForUnknown.IsTrue)
        {
            _OptimizeForUnknown = "OPTION (OPTIMIZE FOR (@ProductID UNKNOWN))";
        }

        if (UseSequentialAccess.IsTrue)
        {
            _CmdBehavior = CommandBehavior.SequentialAccess;
        }

        if (CacheProducts.IsTrue)
        {
            PopulateGlobalProducts(PrintQueries);
        }
        else
        {
            _Products = new List<ProductInfo>();
        }


        if (TestNumber.Value == 2)
        {
            _Command.CommandText = @"
   ;WITH cte AS
   (
     SELECT prod1.ProductID
     FROM   Production.Product prod1 WITH (INDEX(AK_Product_Name))
     WHERE  prod1.Name LIKE N'[M-R]%'
   )
   SELECT prod2.ProductID, prod2.Name, prod2.DaysToManufacture
   FROM   Production.Product prod2
   INNER JOIN cte
           ON cte.ProductID = prod2.ProductID;
";
        }
        else
        {
            _Command.CommandText = @"
     SELECT prod1.ProductID, prod1.Name, 1 AS [DaysToManufacture]
     FROM   Production.Product prod1
     WHERE  prod1.Name LIKE N'[M-R]%';
";
            if (TestNumber.Value == 3)
            {
                _RowsToGet = 1;
                _OrderByTransactionID = ", th.TransactionID DESC";
            }
        }

        try
        {
            _Connection.Open();

            // Populate Product list for this run if not using the Product Cache
            if (!CacheProducts.IsTrue)
            {
                _Reader = _Command.ExecuteReader(_CmdBehavior);

                while (_Reader.Read())
                {
                    _Products.Add(new ProductInfo(_Reader.GetInt32(0), _Reader.GetString(1),
                                                  _Reader.GetInt32(2)));
                }

                _Reader.Close();

                if (PrintQueries.IsTrue)
                {
                    SqlContext.Pipe.Send(_Command.CommandText);
                }
            }
            else
            {
                _Products = _GlobalProducts;
            }

            SqlDataRecord _ResultRow = new SqlDataRecord(
                new SqlMetaData[]{
                    new SqlMetaData("ProductID", SqlDbType.Int),
                    new SqlMetaData("Name", SqlDbType.NVarChar, 50),
                    new SqlMetaData("TransactionID", SqlDbType.Int),
                    new SqlMetaData("TransactionDate", SqlDbType.DateTime)
                });

            SqlParameter _ProductID = new SqlParameter("@ProductID", SqlDbType.Int);
            _Command.Parameters.Add(_ProductID);
            SqlParameter _RowsToReturn = new SqlParameter("@RowsToReturn", SqlDbType.Int);
            _Command.Parameters.Add(_RowsToReturn);

            SqlContext.Pipe.SendResultsStart(_ResultRow);

            for (int _Row = 0; _Row < _Products.Count; _Row++)
            {
                // Tests 1 and 3 use previously set static values for _RowsToGet
                if (TestNumber.Value == 2)
                {
                    if (_Products[_Row].DaysToManufacture == 0)
                    {
                        continue; // no use in issuing SELECT TOP (0) query
                    }

                    _RowsToGet = (5 * _Products[_Row].DaysToManufacture);
                }

                _ResultRow.SetInt32(0, _Products[_Row].ProductID);
                _ResultRow.SetString(1, _Products[_Row].Name);

                switch (ParameterizeProductID.Value)
                {
                    case 0x01:
                        _Command.CommandText = String.Format(@"
   SELECT TOP ({0}) th.TransactionID, th.TransactionDate
   FROM   Production.TransactionHistory th
   WHERE  th.ProductID = @ProductID
   ORDER BY th.TransactionDate DESC{2}
   {1};
", _RowsToGet, _OptimizeForUnknown, _OrderByTransactionID);

                        _ProductID.Value = _Products[_Row].ProductID;
                        break;
                    case 0x02:
                        _Command.CommandText = String.Format(@"
   SELECT TOP (@RowsToReturn) th.TransactionID, th.TransactionDate
   FROM   Production.TransactionHistory th
   WHERE  th.ProductID = @ProductID
   ORDER BY th.TransactionDate DESC
   {0};
", _OptimizeForUnknown);

                        _ProductID.Value = _Products[_Row].ProductID;
                        _RowsToReturn.Value = _RowsToGet;
                        break;
                    default:
                        _Command.CommandText = String.Format(@"
   SELECT TOP ({0}) th.TransactionID, th.TransactionDate
   FROM   Production.TransactionHistory th
   WHERE  th.ProductID = {1}
   ORDER BY th.TransactionDate DESC{2};
", _RowsToGet, _Products[_Row].ProductID, _OrderByTransactionID);
                        break;
                }


                _Reader = _Command.ExecuteReader(_CmdBehavior);

                while (_Reader.Read())
                {
                    _ResultRow.SetInt32(2, _Reader.GetInt32(0));
                    _ResultRow.SetDateTime(3, _Reader.GetDateTime(1));

                    SqlContext.Pipe.SendResultsRow(_ResultRow);
                }
                _Reader.Close();
            }

        }
        catch
        {
            throw;
        }
        finally
        {
            if (SqlContext.Pipe.IsSendingResults)
            {
                SqlContext.Pipe.SendResultsEnd();
            }

            if (_Reader != null && !_Reader.IsClosed)
            {
                _Reader.Close();
            }

            if (_Connection != null && _Connection.State != ConnectionState.Closed)
            {
                _Connection.Close();
            }

            if (PrintQueries.IsTrue)
            {
                SqlContext.Pipe.Send(_Command.CommandText);
            }
        }


    }
}

시험 쿼리

여기에 시험을 게시 할 공간이 충분하지 않아 다른 위치를 찾을 수 있습니다.

결론

특정 시나리오의 경우 SQLCLR을 사용하여 T-SQL에서 수행 할 수없는 쿼리의 특정 측면을 조작 할 수 있습니다. 임시 테이블 대신 캐싱에 메모리를 사용하는 기능이 있지만 메모리가 시스템에 자동으로 다시 릴리스되지 않으므로 신중하고 신중하게 수행해야합니다. 이 방법은 또한 임시 쿼리에 도움이되는 것은 아니지만 실행중인 쿼리의 더 많은 측면을 조정하기 위해 매개 변수를 추가하여 여기에 표시된 것보다 더 유연하게 만들 수 있습니다.


최신 정보

추가 테스트
지원 인덱스가 포함 된 원래 테스트 TransactionHistory는 다음 정의 를 사용했습니다.

ProductID ASC, TransactionDate DESC

나는 TransactionId DESC마지막에 포함하는 것을 포기하기로 결심했다. 테스트 번호 3 (가장 최근에 잘 TransactionId풀리는 것을 지정하는 테스트 번호 3을 명시 할 수는 있지만 명시 적으로 언급되지 않았기 때문에 "가장 최근의"이라고 가정 함) 이 가정에 동의하기 위해서는 차이를 만들기에 충분한 유대가 없을 것입니다.

그러나 Aaron은이 세 가지 테스트 모두에서이 방법이 승자 TransactionId DESC라는 것을 포함하는지지 지수로 다시 CROSS APPLY테스트했습니다. 이것은 CTE 방법이 테스트 번호 3에 가장 적합하다는 것을 나타내는 내 테스트와 다릅니다 (캐싱을 사용하지 않을 때 Aaron의 테스트를 반영 함). 테스트해야 할 추가 변형이 있음이 분명했습니다.

현재 지원 색인을 제거하고으로 새로운 색인을 생성 TransactionId하고 계획 캐시를 지 웠습니다 (확실히).

DROP INDEX [IX_TransactionHistoryX] ON Production.TransactionHistory;

CREATE UNIQUE INDEX [UIX_TransactionHistoryX]
    ON Production.TransactionHistory (ProductID ASC, TransactionDate DESC, TransactionID DESC)
    WITH (FILLFACTOR = 100);

DBCC FREEPROCCACHE WITH NO_INFOMSGS;

테스트 번호 1을 다시 실행했으며 예상대로 결과가 동일했습니다. 그런 다음 테스트 번호 3을 다시 실행하고 결과가 실제로 변경되었습니다.

테스트 3 지원 인덱스가있는 결과 (TransactionId DESC 사용)
위의 결과는 표준 비 캐싱 테스트에 대한 것입니다. 이번에 CROSS APPLY는 Aaron의 테스트에서 알 수 있듯이 CTE를 이길뿐만 아니라 SQLCLR proc이 30 Reads (woo hoo)만큼 우위를 차지했습니다.

테스트 3 지원 인덱스 (TransactionId DESC 포함) 및 캐싱
위의 결과는 캐싱이 활성화 된 테스트에 대한 것입니다. 이번에는 CTE의 성능이 CROSS APPLY여전히 저하되지는 않지만 성능이 저하되지는 않습니다 . 그러나 이제는 SQLCLR proc이 23 Reads (woo hoo, 다시 한 번)로 앞서고 있습니다.

핵심, 관심사

  1. 사용할 수있는 다양한 옵션이 있습니다. 그들이 각각의 강점을 가지고 있기 때문에 여러 가지를 시도하는 것이 가장 좋습니다. 여기에서 수행 된 테스트는 모든 테스트에서 최고 및 최저 성능 사이의 읽기 및 지속 시간에서 약간의 차이를 나타냅니다 (지원 인덱스 포함). 읽기의 변화는 약 350이고 지속 시간은 55ms입니다. SQLCLR proc은 1 번의 테스트 (읽기 측면에서)를 제외하고 모두 승리했지만, 몇 번의 읽기만 저장하는 것은 일반적으로 SQLCLR 경로를 유지하는 유지 관리 비용이 들지 않습니다. 그러나 AdventureWorks2012에서 Product테이블에는 504 개의 행만 있고 TransactionHistory113,443 개의 행만 있습니다. 이러한 방법의 성능 차이는 행 수가 증가함에 따라 더욱 두드러집니다.

  2. 이 질문은 특정 행 집합을 가져 오는 것과 관련이 있었지만 성능에서 가장 큰 단일 요소는 인덱싱이고 특정 SQL은 아니라고 간과해서는 안됩니다. 어떤 방법이 가장 적합한지를 결정하기 전에 좋은 지수를 마련해야합니다.

  3. 여기에서 가장 중요한 교훈은 CROSS APPLY vs CTE vs SQLCLR에 관한 것이 아니라 테스트에 관한 것입니다. 가정하지 마십시오. 여러 사람으로부터 아이디어를 얻고 최대한 많은 시나리오를 테스트하십시오.


2
apply와 관련된 추가 논리적 읽기의 이유는 Mikael의 답변을 편집 한 내용을 참조하십시오.
Paul White

18

APPLY TOP또는 ROW_NUMBER()? 그 문제에 관해 무엇을 더 말할 수 있습니까?

차이점을 간단히 요약하고 실제로 짧게 유지하려면 옵션 2에 대한 계획 만 표시하고에 색인을 추가했습니다 Production.TransactionHistory.

create index IX_TransactionHistoryX on 
  Production.TransactionHistory(ProductID, TransactionDate)

row_number()쿼리 :.

with C as
(
  select T.TransactionID,
         T.TransactionDate,
         P.DaysToManufacture,
         row_number() over(partition by P.ProductID order by T.TransactionDate desc) as rn
  from Production.Product as P
    inner join Production.TransactionHistory as T
      on P.ProductID = T.ProductID
  where P.Name >= N'M' and
        P.Name < N'S'
)
select C.TransactionID,
       C.TransactionDate
from C
where C.rn <= 5 * C.DaysToManufacture;

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

apply top버전 :

select T.TransactionID, 
       T.TransactionDate
from Production.Product as P
  cross apply (
              select top(cast(5 * P.DaysToManufacture as bigint))
                T.TransactionID,
                T.TransactionDate
              from Production.TransactionHistory as T
              where P.ProductID = T.ProductID
              order by T.TransactionDate desc
              ) as T
where P.Name >= N'M' and
      P.Name < N'S';

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

이들의 주요 차이점 apply top은 중첩 루프 아래의 최상위 식에있는 row_number필터가 결합 후 버전이 필터링 되는 위치 에서 결합한다는 것입니다. 이는 Production.TransactionHistory실제로 필요한 것보다 더 많은 읽기가 있음을 의미 합니다.

조인하기 전에 행을 열거하는 책임이있는 연산자를 하위 브랜치로 푸시하는 방법 만있는 경우 row_number버전이 더 나을 수 있습니다.

따라서 apply row_number()버전을 입력하십시오 .

select T.TransactionID, 
       T.TransactionDate
from Production.Product as P
  cross apply (
              select T.TransactionID,
                     T.TransactionDate
              from (
                   select T.TransactionID,
                          T.TransactionDate,
                          row_number() over(order by T.TransactionDate desc) as rn
                   from Production.TransactionHistory as T
                   where P.ProductID = T.ProductID
                   ) as T
              where T.rn <= cast(5 * P.DaysToManufacture as bigint)
              ) as T
where P.Name >= N'M' and
      P.Name < N'S';

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

보시다시피 약간 더 복잡한 apply row_number()것과 거의 같습니다 apply top. 실행 시간도 거의 같거나 조금 느립니다.

그렇다면 왜 우리가 이미 가지고있는 것보다 낫지 않은 대답을 생각해 냈습니까? 글쎄, 당신은 현실 세계에서 시도해야 할 것이 하나 더 있으며 실제로 읽기에는 차이가 있습니다. 설명이없는 것 *.

APPLY - ROW_NUMBER
(961 row(s) affected)
Table 'TransactionHistory'. Scan count 115, logical reads 230, physical reads 0, read-ahead reads 0, lob logical reads 0, lob physical reads 0, lob read-ahead reads 0.
Table 'Product'. Scan count 1, logical reads 15, physical reads 0, read-ahead reads 0, lob logical reads 0, lob physical reads 0, lob read-ahead reads 0.

APPLY - TOP
(961 row(s) affected)
Table 'TransactionHistory'. Scan count 115, logical reads 268, physical reads 0, read-ahead reads 0, lob logical reads 0, lob physical reads 0, lob read-ahead reads 0.
Table 'Product'. Scan count 1, logical reads 15, physical reads 0, read-ahead reads 0, lob logical reads 0, lob physical reads 0, lob read-ahead reads 0.

내가 그것에있는 동안 나는 또한 row_number()어떤 경우에는 갈 길이 될 수 있는 두 번째 버전을 던질 수 있습니다. 이러한 특정 경우는 실제로 대부분의 행이 필요할 것으로 예상되는 경우입니다. Production.TransactionHistory여기에서 Production.Product와 열거 된 사이에 병합 조인이 있기 때문 Production.TransactionHistory입니다.

with C as
(
  select T.TransactionID,
         T.TransactionDate,
         T.ProductID,
         row_number() over(partition by T.ProductID order by T.TransactionDate desc) as rn
  from Production.TransactionHistory as T
)
select C.TransactionID,
       C.TransactionDate
from C
 inner join Production.Product as P
      on P.ProductID = C.ProductID
where P.Name >= N'M' and
      P.Name < N'S' and
      C.rn <= 5 * P.DaysToManufacture;

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

정렬 연산자없이 위의 모양을 얻으려면 TransactionDate내림차순으로 지원 색인을 순서대로 변경해야합니다 .

create index IX_TransactionHistoryX on 
  Production.TransactionHistory(ProductID, TransactionDate desc)

* 편집 : 추가적인 논리적 읽기는 apply-top과 함께 사용되는 중첩 루프 프리 페치 때문 입니다. undoc'd TF 8744 (및 / 또는 이후 버전에서는 9115)로이를 비활성화하여 동일한 수의 논리적 읽기를 얻을 수 있습니다. 프리 페치는 올바른 환경에서 적용 가능한 대안의 이점이 될 수 있습니다. -폴 화이트


11

나는 일반적으로 CTE와 윈도우 기능의 조합을 사용합니다. 다음과 같은 방법으로이 답변을 얻을 수 있습니다.

;WITH GiveMeCounts
AS (
    SELECT CustomerID
        ,OrderDate
        ,TotalAmt

        ,ROW_NUMBER() OVER (
            PARTITION BY CustomerID ORDER BY 
            --You can change the following field or sort order to whatever you'd like to order by.
            TotalAmt desc
            ) AS MySeqNum
    )
SELECT CustomerID, OrderDate, TotalAmt
FROM GiveMeCounts
--Set n per group here
where MySeqNum <= 10

다른 그룹이 다른 수의 행을 리턴 할 수있는 추가 크레딧 부분의 경우 별도의 테이블을 사용할 수 있습니다. state와 같은 지리적 기준을 사용한다고 가정 해 보겠습니다.

+-------+-----------+
| State | MaxSeqnum |
+-------+-----------+
| AK    |        10 |
| NY    |         5 |
| NC    |        23 |
+-------+-----------+

값이 다를 수있는 위치에서이를 달성하려면 CTE를 다음과 유사한 상태 테이블에 조인해야합니다.

SELECT [CustomerID]
    ,[OrderDate]
    ,[TotalAmt]
    ,[State]
FROM GiveMeCounts gmc
INNER JOIN StateTable st ON gmc.[State] = st.[State]
    AND gmc.MySeqNum <= st.MaxSeqNum
당사 사이트를 사용함과 동시에 당사의 쿠키 정책개인정보 보호정책을 읽고 이해하였음을 인정하는 것으로 간주합니다.
Licensed under cc by-sa 3.0 with attribution required.