Получение n строк на группу

88

Мне часто нужно выбирать количество строк из каждой группы в наборе результатов.

Например, я мог бы хотеть перечислить 'n' самые высокие или самые низкие недавние значения заказа на клиента.

В более сложных случаях количество строк в списке может варьироваться в зависимости от группы (определяется атрибутом записи группировки / родителя). Эта часть, безусловно, не является обязательной / для дополнительного кредита и не предназначена, чтобы отговорить людей от ответа.

Каковы основные варианты решения этих типов проблем в SQL Server 2005 и более поздних версиях? Каковы основные преимущества и недостатки каждого метода?

Примеры AdventureWorks (для ясности, необязательно)

  1. Перечислите пять самых последних дат транзакций и идентификаторы из TransactionHistoryтаблицы для каждого продукта, который начинается с буквы от M до R включительно.
  2. То же самое, но с nисторическими строками для каждого продукта, где атрибут Product в nпять раз больше DaysToManufacture.
  3. То же самое, для особого случая, когда требуется ровно одна строка истории для каждого продукта (единственная последняя запись TransactionDate- «разрыв связи») TransactionID.
Пол Уайт
источник

Ответы:

71

Давайте начнем с основного сценария.

Если я хочу получить некоторое количество строк из таблицы, у меня есть два основных варианта: ранжирование функций; или 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запрос second ( ) намного проще, чем первый, как в запросе, так и в плане. Но очень важно, что они оба используют TOPдля ограничения количества строк, фактически извлекаемых из индекса. Затраты являются лишь оценочными и их стоит игнорировать, но вы можете увидеть большое сходство в двух планах: ROW_NUMBER()версия выполняет небольшое количество дополнительной работы для назначения чисел и соответствующей фильтрации, и оба запроса заканчиваются выполнением всего 2 операций чтения для выполнения их работа. Оптимизатор запросов, безусловно, признает идею фильтрации по 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 чтения, которые у нас были раньше) для каждого продукта. Это дает 4 чтения против Product и 360 против TransactionHistory.

ПРИМЕНИТЬ план

Используя ROW_NUMBER(), метод должен использовать PARTITION BYв OVERпредложении, чтобы мы перезапустили нумерацию для каждого продукта. Это может быть отфильтровано, как раньше. План заканчивается совсем другим. Логические чтения примерно на 15% ниже в TransactionHistory, при этом выполняется полное сканирование индекса, чтобы вывести строки.

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 первые строки будут возвращаться очень быстро, после нескольких чтений, но с сортировкой, ROW_NUMBER()будут возвращать строки только после завершения большей части работы.

Интересно, что если вместо этого ROW_NUMBER()используется запрос , то возникает другой план.INNER JOINLEFT JOIN

ROW_NUMBER () с INNER JOIN

Этот план использует Nested Loop, как и в случае с APPLY. Но здесь нет оператора Top, поэтому он извлекает все транзакции для каждого продукта и использует намного больше операций чтения, чем раньше - 492 операций чтения из TransactionHistory. Нет веских оснований для того, чтобы не выбирать здесь вариант Merge Join, поэтому я думаю, что план был сочтен «Достаточно хорошим». Тем не менее - это не блокирует, что приятно - просто не так хорошо, как APPLY.

PARTITION BYСтолбец , который я использовал для ROW_NUMBER()был h.ProductIDв обоих случаях, потому что я хотел дать Qo возможность получения значения RowNum до присоединения к таблице Product. Если я использую 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 говорит «Левое внешнее соединение» вместо «Внутреннее соединение». Количество операций чтения по-прежнему составляет чуть менее 500 операций чтения таблицы TransactionHistory.

PARTITION BY на p.ProductID вместо h.ProductID

Во всяком случае - вернемся к вопросу под рукой ...

Мы ответили на вопрос 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, так как он намного проще, и в плане нет оператора блокировки. Чтения меньше в 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

Обратите внимание на Compute Scalar, который извлекает данные из таблицы Product. Это работает 5 * p.DaysToManufactureценность. Это значение не передается в ветку, которая извлекает данные из таблицы TransactionHistory, оно используется в объединении слиянием. Как остаточный.

Подлый Остаточный!

Таким образом, объединение слиянием потребляет ВСЕ строки, не только первые, сколько нужно, но все из них, а затем выполняет остаточную проверку. Это опасно, так как количество транзакций увеличивается. Я не фанат этого сценария - остаточные предикаты в Merge Joins могут быстро возрасти. Еще одна причина, почему я предпочитаю APPLY/TOPсценарий.

В особом случае, когда это ровно одна строка, для вопроса 3 мы, очевидно, можем использовать те же запросы, но с 1вместо 5. Но тогда у нас есть дополнительная опция, которая заключается в использовании регулярных агрегатов.

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

Такой запрос был бы полезным началом, и мы могли бы легко изменить его для извлечения TransactionID также в целях разрыва связей (используя конкатенацию, которая затем будет разбита), но мы либо смотрим на весь индекс, либо мы погружаемся от продукта к продукту, и мы не получаем значительного улучшения по сравнению с тем, что мы имели раньше в этом сценарии.

Но я должен отметить, что мы рассматриваем конкретный сценарий здесь. С реальными данными и стратегией индексирования, которая может быть не идеальной, пробег может значительно отличаться. Несмотря на то, что мы видели, что APPLYэто сильно здесь, в некоторых ситуациях это может быть медленнее. Однако он редко блокируется, поскольку имеет тенденцию использовать вложенные циклы, которые многие люди (включая меня) находят очень привлекательными.

Я не пытался исследовать параллелизм здесь и не углублялся в вопрос 3, который я рассматриваю как особый случай, который люди редко хотят получить из-за сложности объединения и разделения. Здесь важно учитывать, что оба эти варианта очень сильны.

Я предпочитаю APPLY. Понятно, что он хорошо использует оператор Top и редко вызывает блокировку.

Роб Фарли
источник
45

Типичный способ сделать это в SQL Server 2005 и выше - это использовать CTE и функции управления окнами. Для top 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;

С указанным дополнительным параметром «Пол», скажем, в таблице «Клиенты» есть столбец, показывающий, сколько строк нужно включить для каждого клиента:

;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и добавляя добавленную опцию, чтобы число строк для клиента определялось некоторым столбцом в таблице клиентов:

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;

Обратите внимание, что они будут работать по-разному в зависимости от распределения данных и доступности вспомогательных индексов, поэтому оптимизация производительности и получение наилучшего плана действительно будут зависеть от местных факторов.

Лично я предпочитаю CTE и оконные решения над CROSS APPLY/ TOPпотому что они лучше разделяют логику и более интуитивны (для меня). В целом (как в этом случае, так и в моем общем опыте) подход CTE дает более эффективные планы (примеры ниже), но это не должно восприниматься как универсальная истина - вы всегда должны проверять свои сценарии, особенно если индексы изменились или данные значительно искажены.


Примеры AdventureWorks - без изменений

  1. Перечислите пять самых последних дат транзакций и идентификаторы из TransactionHistoryтаблицы для каждого продукта, который начинается с буквы от M до R включительно.
-- 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историческими строками для каждого продукта, где атрибут Product в nпять раз больше 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- «разрыв связи») TransactionID.

Опять мелкие изменения здесь. В решении CTE мы добавляем TransactionIDк OVER()предложению и изменяем внешний фильтр на rn = 1. Для CROSS APPLY, мы изменить , TOPчтобы TOP (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 - с возможностью добавления индексов

Однако, если вы добавите вспомогательный индекс, аналогичный тому, который Павел упомянул в комментарии, но с упорядоченными 2-м и 3-м столбцами DESC:

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

На самом деле вы получите гораздо более выгодные планы, и показатели будут меняться, чтобы одобрить CROSS APPLYподход во всех трех случаях:

введите описание изображения здесь

Если бы это была моя производственная среда, я бы, вероятно, остался доволен продолжительностью в этом случае и не стал бы заниматься дальнейшей оптимизацией.


Все это было намного хуже в SQL Server 2000, который не поддерживал APPLYни OVER()пункт.

Аарон Бертран
источник
24

В СУБД, как и в MySQL, которые не имеют оконных функций или CROSS APPLY, способ сделать это - использовать стандартный SQL (89). Медленным путем будет треугольное перекрестное соединение с заполнителем. Более быстрый путь (но все же и, вероятно, не такой эффективный, как использование cross apply или функции row_number) был бы тем, что я называю «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-сервер добавил FETCH / OFFSETсинтаксис в версии 2012 года. Запросы здесь были скорректированы IN (TOP...)для работы с более ранними версиями.

ypercubeᵀᴹ
источник
21

Я выбрал немного другой подход, главным образом, чтобы увидеть, как этот метод будет сравниваться с другими, потому что наличие вариантов - это хорошо, правда?

Тестирование

Почему бы нам не начать с простого рассмотрения того, как различные методы складываются друг против друга. Я сделал три набора тестов:

  1. Первый набор прошел без изменений БД
  2. Второй набор выполнялся после создания индекса для поддержки TransactionDateзапросов на основе Production.TransactionHistory.
  3. Третий сет сделал немного другое предположение. Поскольку все три теста выполнялись для одного и того же списка продуктов, что, если мы кэшируем этот список? Мой метод использует кэш в памяти, в то время как другие методы использовали эквивалентную временную таблицу. Поддерживающий индекс, созданный для второго набора тестов, все еще существует для этого набора тестов.

Дополнительные детали теста:

  • Тесты были выполнены AdventureWorks2012на SQL Server 2012, SP2 (Developer Edition).
  • Для каждого теста я помечал, чей ответ я взял запрос и какой конкретный запрос это был.
  • Я использовал опцию «Отменить результаты после выполнения» в параметрах запроса | Результаты.
  • Обратите внимание, что для первых двух наборов тестов, RowCountsпохоже, «выключен» для моего метода. Это связано с тем, что мой метод представляет собой ручную реализацию того, что CROSS APPLYпроисходит: он выполняет исходный запрос Production.Productи возвращает 161 строку назад, которую затем использует для запросов Production.TransactionHistory. Следовательно, RowCountзначения для моих записей всегда на 161 больше, чем для других записей. В третьем наборе тестов (с кэшированием) число строк одинаково для всех методов.
  • Я использовал SQL Server Profiler для сбора статистики вместо того, чтобы полагаться на планы выполнения. Аарон и Микаэль уже проделали большую работу, показав планы своих запросов, и нет необходимости воспроизводить эту информацию. И цель моего метода - свести запросы к такой простой форме, что это на самом деле не имеет значения. Существует дополнительная причина для использования Profiler, но это будет упомянуто позже.
  • Вместо того, чтобы использовать Name >= N'M' AND Name < N'S'конструкцию, я решил использовать Name LIKE N'[M-R]%', и SQL Server обрабатывает их так же.

Результаты, достижения

Нет поддержки индекса

Это по сути из AdventureWorks2012 из коробки. Во всех случаях мой метод явно лучше, чем некоторые другие, но никогда не так хорош, как лучшие 1 или 2 метода.

Тест 1 Результаты теста 1 - без индекса
CTE Аарона, безусловно, победитель.

Тест 2 Результаты теста 2 - без индекса
CTE Аарона (снова) и второй apply row_number()метод Микаэля - вторая секунда.

Тест 3 Результаты теста 3 - без индекса
CTE Аарона (снова) является победителем.

Заключение
Когда нет поддерживающего индекса TransactionDate, мой метод лучше, чем стандартный CROSS APPLY, но все же, использование метода CTE - лучший путь.

С индексом поддержки (без кэширования)

Для этого набора тестов я добавил очевидный индекс, TransactionHistory.TransactionDateпоскольку все запросы сортируются по этому полю. Я говорю «очевидно», так как большинство других ответов также согласны с этим. И поскольку все запросы требуют самых последних дат, TransactionDateполе должно быть упорядочено DESC, поэтому я просто взял CREATE INDEXутверждение в нижней части ответа Микаэля и добавил явное FILLFACTOR:

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

Как только этот индекс будет создан, результаты меняются совсем немного.

Тест 1 На Результаты теста 1 - с опорным индексом
этот раз мой метод выходит вперед, по крайней мере, с точки зрения логического чтения. CROSS APPLYМетод, ранее худший для теста 1, выигрывает по продолжительности и даже превосходит метод КТРА на логических чтениях.

Тест 2 На Результаты теста 2 с индексом поддержки
этот раз это первый apply row_number()метод Микаэля, который является победителем при чтении Рединса, тогда как раньше он был одним из худших исполнителей. И теперь мой метод находится на очень близком втором месте, если смотреть на Reads. Фактически, вне метода 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 - с поддержкой индекса и кэшированием
Кажется, что все методы одинаково выигрывают от кэширования, и мой метод все еще впереди.

Тест 2 Результаты теста 2 - с поддержкой индекса и кэшированием
Здесь мы видим различие в линейке, поскольку мой метод выходит далеко вперед, только на 2 чтения лучше, чем первый apply row_number()метод Микаэля , тогда как без кеширования мой метод отставал на 4 чтения.

Тест 3 Результаты теста 3 - с поддержкой индекса и кэшированием
Пожалуйста, смотрите обновление в нижней части (ниже линии) . Здесь мы снова видим некоторую разницу. «Параметризованный» вариант моего метода теперь едва ли лидирует на 2 операции чтения по сравнению с методом Аарона CROSS APPLY (без кеширования они были равны). Но действительно странная вещь заключается в том, что впервые мы видим метод, на который кеширование оказывает негативное влияние: метод CTE Аарона (который ранее был лучшим для теста № 3). Но я не собираюсь брать кредит там, где это не нужно, и, поскольку без кэширования метод CTE Аарона все еще быстрее, чем мой метод с кэшированием, лучшим подходом для этой конкретной ситуации является метод CTE Аарона.

Заключение Пожалуйста, смотрите обновление в нижней части (ниже строки).
Ситуации, в которых многократно используются результаты вторичного запроса, могут часто (но не всегда) извлекать выгоду из кэширования этих результатов. Но когда кэширование является преимуществом, использование памяти для указанного кэширования имеет некоторое преимущество по сравнению с использованием временных таблиц.

Метод

В общем

Я отделил запрос «заголовок» (т.е. получение ProductIDs и, в одном случае, также DaysToManufacture, основываясь на Nameначале с определенными буквами) от запросов «подробно» (т.е. получение TransactionIDs и TransactionDates). Идея заключалась в том, чтобы выполнять очень простые запросы и не позволить оптимизатору запутаться при их присоединении. Понятно, что это не всегда выгодно, так как не позволяет оптимизатору оптимизировать. Но, как мы видели в результатах, в зависимости от типа запроса этот метод имеет свои достоинства.

Разница между различными вкусами этого метода:

  • Константы: передайте любые заменяемые значения в виде встроенных констант, а не параметров. Это относится ко ProductIDвсем трем тестам, а также к числу строк, которые нужно вернуть в тесте 2, поскольку это функция «пятикратного DaysToManufactureатрибута Product». Этот под-метод означает, что каждый ProductIDполучит свой собственный план выполнения, который может быть полезен, если существует широкий разброс в распределении данных для ProductID. Но если распределение данных будет незначительным, стоимость создания дополнительных планов, скорее всего, не будет того стоить.

  • Параметризованный: Отправить хотя бы ProductIDкак @ProductID, что позволяет кэширование и повторное использование плана выполнения. Существует дополнительная опция теста, которая также обрабатывает переменное число строк, возвращаемых для теста 2, в качестве параметра.

  • Оптимизировать неизвестно: при ссылке ProductIDна @ProductID, если существует широкий разброс в распределении данных, можно кэшировать план, который отрицательно влияет на другие ProductIDзначения, поэтому было бы полезно узнать, помогает ли это использование подсказки запроса.

  • Продукты кэширования: вместо того, чтобы Production.Productкаждый раз запрашивать таблицу, только чтобы получить один и тот же список, выполните запрос один раз (и пока мы на нем, отфильтровываем любые ProductIDs, которых нет даже в TransactionHistoryтаблице, поэтому мы не теряем ничего ресурсы там) и кэшировать этот список. Список должен включать DaysToManufactureполе. При использовании этого параметра начальное попадание при логическом чтении для первого выполнения несколько выше, но после этого TransactionHistoryзапрашивается только таблица.

конкретно

Хорошо, но так, как можно выполнить все подзапросы как отдельные запросы без использования CURSOR и вывода каждого результирующего набора во временную таблицу или табличную переменную? Очевидно, что использование метода CURSOR / Temp Table вполне отражается в «Чтении и записи». Ну, используя SQLCLR :). Создав хранимую процедуру SQLCLR, я смог открыть набор результатов и, по существу, передать в него результаты каждого подзапроса в виде непрерывного набора результатов (а не нескольких наборов результатов). Вне информации о продукте (то есть ProductID, NameиDaysToManufacture), ни один из результатов подзапроса не должен был храниться где-либо (память или диск) и просто передавался как основной набор результатов хранимой процедуры SQLCLR. Это позволило мне сделать простой запрос, чтобы получить информацию о продукте, а затем циклически просмотреть его, выпуская очень простые запросы TransactionHistory.

И именно поэтому мне пришлось использовать SQL Server Profiler для сбора статистики. Хранимая процедура SQLCLR не возвращала план выполнения, либо путем установки параметра запроса «Включить фактический план выполнения», либо путем выдачи SET STATISTICS XML ON;.

Для кеширования информации о продукте я использовал readonly staticобщий список (т.е. _GlobalProductsв приведенном ниже коде). Кажется , что добавление к коллекциям не нарушает readonlyвариант, следовательно , этот код работает , когда сборка имеет PERMISSON_SETв SAFE:), даже если это нелогичное.

Сгенерированные запросы

Запросы, произведенные этой хранимой процедурой 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 (Параметризованный + ОПТИМИЗИРОВАТЬ НЕИЗВЕСТНЫЙ)

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 (Параметризованный + ОПТИМИЗИРОВАТЬ НЕИЗВЕСТНЫЙ)

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связей по самым последним - хорошо, «самое последнее» предполагается, поскольку не указано явно, но всем кажется, согласиться с этим предположением), вероятно, будет недостаточно связей, чтобы изменить ситуацию.

Но затем Аарон еще раз проверил со вспомогательным индексом, который включал TransactionId DESCи обнаружил, что CROSS APPLYметод был победителем во всех трех тестах. Это отличалось от моего тестирования, которое показало, что метод CTE был лучшим для теста № 3 (когда не использовалось кэширование, что отражает тест Аарона). Было ясно, что существует дополнительный вариант, который необходимо протестировать.

Я удалил текущий поддерживающий индекс, создал новый с 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побил CTE (как показал тест Аарона), но и процесс SQLCLR взял на себя инициативу на 30 операций чтения (ух-ху).

Результаты теста 3 - с поддерживающим индексом (с TransactionId DESC) и кэшированием
Приведенные выше результаты относятся к тесту с включенным кэшированием. На этот раз производительность CTE не ухудшилась, хотя CROSS APPLYвсе же превосходит его. Тем не менее, теперь процесс SQLCLR лидирует на 23 операции чтения (опять же, ух ты).

Убери прочь

  1. Существуют различные варианты использования. Лучше всего попробовать несколько, так как у каждого из них есть свои сильные стороны. Тесты, проведенные здесь, показывают довольно небольшую разницу в чтениях и продолжительности между лучшими и худшими показателями во всех тестах (с поддерживающим индексом); изменение в чтениях составляет около 350, а продолжительность составляет 55 мс. Хотя процесс SQLCLR победил во всех тестах, кроме 1 (с точки зрения операций чтения), сохранение только нескольких операций чтения обычно не стоит затрат на обслуживание при прохождении маршрута SQLCLR. Но в AdventureWorks2012 Productтаблица имеет только 504 строки и TransactionHistoryтолько 113 443 строки. Разница в производительности между этими методами, вероятно, становится более заметной по мере увеличения числа строк.

  2. Хотя этот вопрос был специфичен для получения определенного набора строк, не следует упускать из виду, что самым большим фактором производительности является индексирование, а не конкретный SQL. Хороший индекс должен быть на месте, прежде чем определить, какой метод действительно лучше.

  3. Самый важный урок, найденный здесь, не о CROSS APPLY против CTE против SQLCLR: это о ТЕСТИРОВАНИИ. Не думай Получите идеи от нескольких людей и протестируйте как можно больше сценариев.

Соломон Руцкий
источник
2
См. Мое изменение к ответу Микаэля по причине дополнительных логических чтений, связанных с apply.
Пол Уайт
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 | 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
Крис Груттемейер
источник