Отримання n рядків у групі


88

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

Наприклад, я можу захотіти перерахувати найвищі або найнижчі значення останніх замовлень на одного клієнта.

У більш складних випадках кількість рядків для списку може змінюватися в залежності від групи (визначається атрибутом запису групування / батьків). Ця частина, безумовно, необов'язкова / для додаткового кредитування і не має на меті відмовити людей відповідати.

Які основні варіанти вирішення цих типів проблем у SQL Server 2005 та пізніших версіях? Які основні переваги та недоліки кожного методу?

Приклади AdventureWorks (для наочності, необов’язково)

  1. Перерахуйте п'ять останніх дат транзакцій та ідентифікаторів з TransactionHistoryтаблиці для кожного продукту, що починається з літери від M до R включно.
  2. Знову те ж саме, але з nрядками історії на продукт, де атрибут продукту nп’ять разів більший DaysToManufacture.
  3. Те ж саме, для особливого випадку, коли потрібно рівно один рядок історії на продукт (єдиний останній запис TransactionDate, перехід на тай-брейк) 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 читання. їхня робота. Оптимізатор запитів, безумовно, визнає ідею фільтрації на 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 читання проти продукту та 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, Вкладений цикл поверне перші рядки дуже швидко, лише через декілька прочитань, але за допомогою Сортування ROW_NUMBER()повертає рядки лише після того, як більшість робіт закінчена.

Цікаво, що якщо ROW_NUMBER()запит використовується INNER JOINзамість LEFT JOIN, тоді виходить інший план.

ROW_NUMBER () із ВНУТРІШНЕМУ ПРИЄДНАННІ

У цьому плані використовується вкладена петля, як і в APPLY. Але немає топ-оператора, тому він здійснює всі транзакції для кожного продукту і використовує набагато більше читань, ніж раніше - 492 читання проти TransactionHistory. Тут не є вагомою причиною того, щоб не вибрати тут варіант об’єднання об'єднань, тому я думаю, що план вважався "Хорошим". Все-таки - це не блокує, що приємно - просто не так приємно, як 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.

РОЗДІЛ 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';

І обидва плани майже ідентичні тому, що вони були раніше!

Змінні рядки

Знову ж таки, ігноруйте передбачувані витрати - але мені все одно подобається ТОП-сценарій, оскільки він набагато простіший, і план не має оператора блокування. Читання в 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

Зауважте комп’ютерний скаляр, який витягує дані з таблиці продуктів. Це відпрацьовує 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, і це рідко викликає блокування.


44

Типовий спосіб зробити це в SQL Server 2005 і новіших - це використання CTE та функцій вікон. Для верхнього п в групу , ви можете просто використовувати 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рядками історії на продукт, де атрибут продукту 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). Повільний шлях буде трикутним хрестовим з'єднанням з сукупністю. Швидший спосіб (але все-таки і, мабуть, не настільки ефективний, як використання крос-застосунку або функції 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...)для роботи з більш ранніми версіями.


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. У всіх випадках мій метод явно кращий, ніж деякі інші, але ніколи не такий хороший, як топ-2 або 2 способи.

Тест 1 Тест 1 Результати - без індексу
CTE Аарона тут явно переможець.

Тест 2 Тест 2 Результати - без індексу
АТС Аарона (знову ж таки) і другий 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()метод Мікаїла переможець, коли дивиться на «Читання», тоді як раніше це був один із найгірших виконавців. І тепер мій метод посідає дуже близьке друге місце, коли дивлюся на «Читання». Насправді, за межами методу 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 Aaron (без кешування вони були рівними). Але насправді дивним є те, що ми вперше бачимо метод, на який негативно впливає кешування: метод CTE Аарона (який раніше був найкращим для тесту № 3). Але я не збираюся брати кредит там, де цього не потрібно, і оскільки без кешування метод CTE Аарона все ще швидший, ніж у мене метод кешування, найкращим підходом для цієї конкретної ситуації є метод АТР Аарона.

Висновок Перегляньте оновлення донизу (під рядком)
Ситуації, які повторно використовують результати вторинного запиту, можуть часто (але не завжди) отримувати користь від кешування цих результатів. Але якщо кешування - це користь, використання пам'яті для згаданого кешування має певну перевагу перед використанням тимчасових таблиць.

Метод

Взагалі

Я відокремив запит "заголовка" (тобто отримання ProductIDs, і в одному випадку також DaysToManufacture, на основі Nameпочатку з певних літер) від "детальних" запитів (тобто отримання TransactionIDs і TransactionDates). Концепція полягала в тому, щоб виконувати дуже прості запити і не дозволяти оптимізатору плутатись при приєднанні до них. Зрозуміло, що це не завжди вигідно, оскільки це також відключає оптимізатор від, ну, оптимізації. Але, як ми бачили в результатах, залежно від типу запиту, у цього методу є свої достоїнства.

Різниця між різними ароматами цього способу полягає в:

  • Константи: подайте будь-які змінні значення як вбудовані константи, а не параметри. Це стосується ProductIDвсіх трьох тестів, а також кількість рядків для повернення в тесті 2, оскільки це функція "п’ять разів більше DaysToManufactureатрибута Product". Цей під-метод означає, що кожен ProductIDотримає свій власний план виконання, що може бути корисним, якщо є широка різниця в розподілі даних для ProductID. Але якщо в розповсюдженні даних є невеликі розбіжності, вартість створення додаткових планів, швидше за все, не варто.

  • Параметризовано: Надішліть принаймні ProductIDтак @ProductID, що дозволяє кешувати план виконання та використовувати повторно. Існує додатковий варіант тестування, щоб також розглядати змінну кількість рядків для повернення для тесту 2 як параметр.

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

  • Продукти кешування: Замість того, щоб запитувати Production.Productтаблицю кожен раз, лише щоб отримати абсолютно той самий список, запустіть запит один раз (і поки ми в ньому, відфільтруйте всі ProductID, яких немає навіть у TransactionHistoryтаблиці, щоб ми не витрачали жодного ресурси там) і кешувати цей список. Список повинен містити DaysToManufactureполе. Використовуючи цю опцію, для першого виконання є трохи вищий початковий хіт на Logical Reads, але після цього TransactionHistoryзапитується лише таблиця.

Конкретно

Гаразд, але так, гм, як можна видавати всі підзапити як окремі запити без використання CURSOR і скидання кожного результату, встановленого до тимчасової таблиці або змінної таблиці? Очевидно, що використання методу CURSOR / Temp Table відображається цілком очевидно у "Read and Writes". Ну, використовуючи 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 proc взяв на себе лідерство на 30 читачів (ву-ху).

Тест 3 Результати - із підтримкою індексу (з TransactionId DESC) І кешування
Наведені вище результати для тесту з увімкненим кешуванням. Цього разу ефективність роботи CTE не погіршується, хоча вона CROSS APPLYвсе ще перемагає її. Однак зараз SQLCLR Pro переймає лідируючі позиції 23-х читачів (знову ж таки).

Забирай

  1. Існують різні варіанти використання. Найкраще спробувати декілька, оскільки кожен має свої сильні сторони. Тести, зроблені тут, демонструють досить малу різницю як у режимах читання, так і в тривалості між найкращими та найгіршими виконавцями у всіх тестах (із допоміжним показником); варіація читання становить приблизно 350, а тривалість - 55 мс. Хоча Pro SQLCLR виграв у всіх, окрім 1 тесту (з точки зору "Читання"), заощадження лише декількох читань зазвичай не вартує витрат на обслуговування проходження маршруту SQLCLR. Але в AdventureWorks2012 Productтаблиця налічує лише 504 рядки і TransactionHistoryмістить лише 113 443 рядки. Різниця в ефективності цих методів, ймовірно, стає більш вираженою в міру збільшення кількості рядків.

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

  3. Тут найважливіший урок - це не про CROSS APPLY vs CTE проти SQLCLR: це про тестування. Не припускайте. Отримайте ідеї від кількох людей і протестуйте якомога більше сценаріїв.


2
Дивіться мою редакцію відповіді Мікаеля про причину додаткових логічних читань, пов’язаних із застосувати.
Пол Білий

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)

* Редагувати: додаткові логічні зчитування зумовлені попередньою вкладеною циклом циклу, що використовується для вершини звернення. Ви можете відключити це за допомогою скасування 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 до таблиці State, подібної до цієї:

SELECT [CustomerID]
    ,[OrderDate]
    ,[TotalAmt]
    ,[State]
FROM GiveMeCounts gmc
INNER JOIN StateTable st ON gmc.[State] = st.[State]
    AND gmc.MySeqNum <= st.MaxSeqNum
Використовуючи наш веб-сайт, ви визнаєте, що прочитали та зрозуміли наші Політику щодо файлів cookie та Політику конфіденційності.
Licensed under cc by-sa 3.0 with attribution required.