it-swarm.com.de

N Zeilen pro Gruppe abrufen

Ich muss oft eine Anzahl von Zeilen aus jeder Gruppe in einer Ergebnismenge auswählen.

Zum Beispiel möchte ich vielleicht die 'n' höchsten oder niedrigsten letzten Bestellwerte pro Kunde auflisten.

In komplexeren Fällen kann die Anzahl der aufzulistenden Zeilen pro Gruppe variieren (definiert durch ein Attribut der Gruppierung/des übergeordneten Datensatzes). Dieser Teil ist definitiv optional/für zusätzliche Gutschriften und nicht dazu gedacht, Leute von der Antwort abzubringen.

Was sind die Hauptoptionen zum Lösen dieser Art von Problemen in SQL Server 2005 und höher? Was sind die wichtigsten Vor- und Nachteile jeder Methode?

AdventureWorks-Beispiele (aus Gründen der Übersichtlichkeit optional)

  1. Listen Sie die fünf letzten Transaktionsdaten und IDs aus der Tabelle TransactionHistory für jedes Produkt auf, das mit einem Buchstaben von M bis einschließlich R beginnt.
  2. Wieder dasselbe, aber mit n Verlaufszeilen pro Produkt, wobei n das Fünffache des DaysToManufacture Produktattributs ist.
  3. Gleiches gilt für den Sonderfall, in dem genau eine Verlaufszeile pro Produkt erforderlich ist (der letzte Eintrag von TransactionDate, Tie-Break für TransactionID.
92
Paul White 9

Beginnen wir mit dem Grundszenario.

Wenn ich eine bestimmte Anzahl von Zeilen aus einer Tabelle herausholen möchte, habe ich zwei Hauptoptionen: Rangfolgenfunktionen; oder TOP.

Betrachten wir zunächst die gesamte Menge von Production.TransactionHistory für ein bestimmtes ProductID:

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

Dies gibt 418 Zeilen zurück, und der Plan zeigt, dass jede Zeile in der Tabelle daraufhin überprüft wird - ein uneingeschränkter Clustered-Index-Scan mit einem Prädikat zur Bereitstellung des Filters. 797 liest hier, was hässlich ist.

Expensive Scan with 'Residual' Predicate

Seien wir also fair und erstellen einen Index, der nützlicher wäre. Unsere Bedingungen erfordern eine Gleichheitsübereinstimmung für ProductID, gefolgt von einer Suche nach der neuesten von TransactionDate. Wir müssen auch das TransactionID zurückgeben, also gehen wir weiter mit: CREATE INDEX ix_FindingMostRecent ON Production.TransactionHistory (ProductID, TransactionDate) INCLUDE (TransactionID);.

Nachdem wir dies getan haben, ändert sich unser Plan erheblich und senkt die Messwerte auf nur 3. Wir verbessern die Dinge also bereits um mehr als das 250-fache ...

Improved plan

Nachdem wir das Spielfeld ausgeglichen haben, schauen wir uns die wichtigsten Optionen an - Ranglistenfunktionen und 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;

Two plans - basic TOP\RowNum

Sie werden feststellen, dass die zweite Abfrage (TOP) sowohl in der Abfrage als auch im Plan viel einfacher ist als die erste. Sehr wichtig ist jedoch, dass beide TOP verwenden, um die Anzahl der Zeilen zu begrenzen, die tatsächlich aus dem Index gezogen werden. Die Kosten sind nur Schätzungen und es lohnt sich, sie zu ignorieren, aber Sie können eine große Ähnlichkeit in den beiden Plänen feststellen, da die ROW_NUMBER() -Version einen winzigen zusätzlichen Aufwand für die Zuweisung von Zahlen und die entsprechende Filterung leistet und beide Abfragen enden mache nur 2 Lesungen, um ihre Arbeit zu erledigen. Das Abfrageoptimierungsprogramm erkennt sicherlich die Idee, nach einem ROW_NUMBER() -Feld zu filtern, und erkennt, dass es einen Top-Operator verwenden kann, um nicht benötigte Zeilen zu ignorieren. Beide Abfragen sind gut genug - TOP ist nicht so viel besser, dass es sich lohnt, den Code zu ändern, aber für Anfänger ist es einfacher und wahrscheinlich klarer.

Das funktioniert also über ein einziges Produkt hinweg. Wir müssen jedoch überlegen, was passiert, wenn wir dies für mehrere Produkte tun müssen.

Der iterative Programmierer wird die Idee in Betracht ziehen, die interessierenden Produkte zu durchlaufen und diese Abfrage mehrmals aufzurufen, und wir können tatsächlich damit durchkommen, eine Abfrage in dieser Form zu schreiben - nicht mit Cursorn, sondern mit APPLY . Ich verwende OUTER APPLY und stelle fest, dass wir das Produkt möglicherweise mit NULL zurückgeben möchten, wenn keine Transaktionen dafür vorhanden sind.

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

Der Plan hierfür ist die Methode der iterativen Programmierer - Verschachtelte Schleife, die eine Top-Operation ausführt und für jedes Produkt nach den beiden zuvor gelesenen Lesevorgängen sucht. Dies ergibt 4 Lesevorgänge für das Produkt und 360 Lesevorgänge für die Transaktionsgeschichte.

APPLY plan

Bei Verwendung von ROW_NUMBER() wird PARTITION BY in der OVER -Klausel verwendet, sodass die Nummerierung für jedes Produkt neu gestartet wird. Dies kann dann wie zuvor gefiltert werden. Der Plan ist am Ende ganz anders. Die logischen Lesevorgänge sind in TransactionHistory um etwa 15% niedriger, und es wird ein vollständiger Index-Scan durchgeführt, um die Zeilen herauszuholen.

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 plan

Bezeichnenderweise hat dieser Plan jedoch einen teuren Sortieroperator. Der Merge Join scheint die Reihenfolge der Zeilen in TransactionHistory nicht beizubehalten. Die Daten müssen neu sortiert werden, um die Rownummern finden zu können. Es sind weniger Lesevorgänge, aber diese blockierende Sortierung kann sich schmerzhaft anfühlen. Mit APPLY gibt die verschachtelte Schleife die ersten Zeilen nach nur wenigen Lesevorgängen sehr schnell zurück. Bei einer Sortierung gibt ROW_NUMBER() jedoch nur Zeilen zurück, nachdem ein Großteil der Arbeit abgeschlossen wurde.

Interessanterweise wird ein anderer Plan angezeigt, wenn die Abfrage ROW_NUMBER()INNER JOIN anstelle von LEFT JOIN verwendet.

ROW_NUMBER() with INNER JOIN

Dieser Plan verwendet eine verschachtelte Schleife, genau wie bei APPLY. Da es jedoch keinen Top-Operator gibt, werden alle Transaktionen für jedes Produkt abgerufen und es werden viel mehr Lesevorgänge als zuvor verwendet - 492 Lesevorgänge für TransactionHistory. Es gibt keinen guten Grund dafür, die Option Zusammenführen hier nicht zu wählen. Ich denke, der Plan wurde als "Gut genug" eingestuft. Trotzdem - es blockiert nicht, was schön ist - nur nicht so schön wie APPLY.

Die Spalte PARTITION BY, die ich für ROW_NUMBER() verwendet habe, war in beiden Fällen h.ProductID, da ich der QO die Möglichkeit geben wollte, den RowNum-Wert vor dem Beitritt zum zu erzeugen Produkttabelle. Wenn ich p.ProductID verwende, sehen wir den gleichen Formplan wie bei der Variation 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;

Der Join-Operator sagt jedoch "Left Outer Join" anstelle von "Inner Join". Die Anzahl der Lesevorgänge liegt immer noch bei knapp 500 Lesevorgängen für die TransactionHistory-Tabelle.

PARTITION BY on p.ProductID instead of h.ProductID

Wie auch immer - zurück zur Frage ...

Wir haben Frage 1 mit zwei Optionen beantwortet, aus denen Sie auswählen können. Persönlich mag ich die Option APPLY.

Um dies auf die Verwendung einer Variablennummer (Frage 2) zu erweitern, muss das 5 nur entsprechend geändert werden. Oh, und ich habe einen weiteren Index hinzugefügt, sodass es einen Index für Production.Product.Name gab, der die Spalte DaysToManufacture enthielt.

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

Und beide Pläne sind fast identisch mit dem, was sie vorher waren!

Variable rows

Ignorieren Sie auch hier die geschätzten Kosten - aber ich mag das TOP-Szenario immer noch, da es so viel einfacher ist und der Plan keinen Sperroperator hat. Die Lesevorgänge in TransactionHistory sind aufgrund der hohen Anzahl von Nullen in DaysToManufacture geringer, aber im wirklichen Leben bezweifle ich, dass wir diese Spalte auswählen würden. ;)

Eine Möglichkeit, den Block zu vermeiden, besteht darin, einen Plan zu erstellen, der das Bit ROW_NUMBER() rechts (im Plan) des Joins verarbeitet. Wir können dies davon überzeugen, indem wir den Join außerhalb des CTE durchführen.

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

Der Plan hier sieht einfacher aus - er blockiert nicht, aber es besteht eine versteckte Gefahr.

Joining outside CTE

Beachten Sie den Berechnungsskalar, der Daten aus der Produkttabelle abruft. Dies berechnet den Wert 5 * p.DaysToManufacture. Dieser Wert wird nicht an den Zweig übergeben, der Daten aus der TransactionHistory-Tabelle abruft, sondern im Merge Join verwendet. Als Rest.

Sneaky Residual!

Der Merge Join verbraucht also ALLE Zeilen, nicht nur die ersten, wie viele auch immer benötigt werden, sondern alle und führt dann eine Restprüfung durch. Dies ist gefährlich, da die Anzahl der Transaktionen zunimmt. Ich bin kein Fan dieses Szenarios - verbleibende Prädikate in Merge Joins können schnell eskalieren. Ein weiterer Grund, warum ich das Szenario APPLY/TOP bevorzuge.

In dem speziellen Fall, in dem es genau eine Zeile ist, können wir für Frage offensichtlich dieselben Abfragen verwenden, jedoch mit 1 anstelle von 5. Aber dann haben wir eine zusätzliche Option, nämlich die Verwendung regulärer Aggregate.

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

Eine Abfrage wie diese wäre ein nützlicher Anfang, und wir könnten sie leicht ändern, um die TransactionID auch für Tie-Break-Zwecke abzurufen (unter Verwendung einer Verkettung, die dann aufgeschlüsselt würde), aber wir betrachten entweder den gesamten Index oder Wir tauchen Produkt für Produkt ein und bekommen keine große Verbesserung gegenüber dem, was wir zuvor in diesem Szenario hatten.

Aber ich sollte darauf hinweisen, dass wir hier ein bestimmtes Szenario betrachten. Bei realen Daten und einer möglicherweise nicht idealen Indizierungsstrategie kann der Kilometerstand erheblich variieren. Trotz der Tatsache, dass wir gesehen haben, dass APPLY hier stark ist, kann es in einigen Situationen langsamer sein. Es blockiert jedoch selten, da es die Tendenz hat, verschachtelte Schleifen zu verwenden, was viele Leute (ich selbst eingeschlossen) sehr ansprechend finden.

Ich habe hier nicht versucht, Parallelität zu untersuchen, oder bin sehr intensiv auf Frage 3 eingegangen, die ich als Sonderfall betrachte, den Menschen aufgrund der Komplikation von Verketten und Aufteilen selten wollen. Die Hauptsache hierbei ist, dass diese beiden Optionen beide sehr stark sind.

Ich bevorzuge APPLY. Es ist klar, es verwendet den Top-Operator gut und verursacht selten Blockierungen.

73
Rob Farley

Der typische Weg, dies in SQL Server 2005 und höher zu tun, ist die Verwendung eines CTE und von Fensterfunktionen. Für top n pro Gruppe können Sie einfach ROW_NUMBER() mit einer PARTITION -Klausel verwenden und in der äußeren Abfrage danach filtern. So könnten beispielsweise die Top 5 der letzten Bestellungen pro Kunde folgendermaßen angezeigt werden:

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;

Sie können dies auch mit CROSS APPLY Tun:

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;

Angenommen, die Tabelle "Kunden" enthält eine Spalte, in der angegeben ist, wie viele Zeilen pro Kunde enthalten sein sollen.

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

Verwenden Sie erneut CROSS APPLY Und fügen Sie die hinzugefügte Option hinzu, dass die Anzahl der Zeilen für einen Kunden durch eine Spalte in der Kundentabelle bestimmt wird:

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;

Beachten Sie, dass diese abhängig von der Datenverteilung und der Verfügbarkeit unterstützender Indizes unterschiedlich funktionieren. Daher hängt die Optimierung der Leistung und der Erzielung des besten Plans wirklich von lokalen Faktoren ab.

Persönlich bevorzuge ich die CTE- und Fensterlösungen gegenüber CROSS APPLY/TOP, weil sie die Logik besser trennen und (für mich) intuitiver sind. Im Allgemeinen (sowohl in diesem Fall als auch nach meiner allgemeinen Erfahrung) führt der CTE-Ansatz zu effizienteren Plänen (Beispiele unten). Dies sollte jedoch nicht als universelle Wahrheit angesehen werden. Sie sollten Ihre Szenarien immer testen, insbesondere wenn sich die Indizes geändert haben oder Daten haben sich erheblich verzerrt.


AdventureWorks-Beispiele - ohne Änderungen

  1. Listen Sie die fünf letzten Transaktionsdaten und IDs aus der Tabelle TransactionHistory für jedes Produkt auf, das mit einem Buchstaben von M bis einschließlich R beginnt.
-- 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';

Vergleich dieser beiden in Laufzeitmetriken:

enter image description here

CTE/OVER() plan:

enter image description here

CROSS APPLY Plan:

enter image description here

Der CTE-Plan sieht komplizierter aus, ist aber tatsächlich viel effizienter. Achten Sie wenig auf die geschätzten Kosten in%, sondern konzentrieren Sie sich auf wichtigere tatsächliche Beobachtungen, wie z. B. weit weniger Lesevorgänge und eine viel geringere Dauer. Ich habe diese auch ohne Parallelität ausgeführt, und das war nicht der Unterschied. Laufzeitmetriken und der CTE-Plan (der CROSS APPLY - Plan blieb unverändert):

enter image description here

enter image description here

  1. Wieder dasselbe, aber mit n Verlaufszeilen pro Produkt, wobei n das Fünffache des DaysToManufacture Produktattributs ist.

Hier sind sehr geringfügige Änderungen erforderlich. Für den CTE können wir der inneren Abfrage eine Spalte hinzufügen und nach der äußeren Abfrage filtern. Für CROSS APPLY können wir die Berechnung innerhalb des korrelierten TOP durchführen. Sie würden denken, dies würde der CROSS APPLY - Lösung eine gewisse Effizienz verleihen, aber das passiert in diesem Fall nicht. Fragen:

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

Laufzeitergebnisse:

enter image description here

Paralleler CTE/OVER() Plan:

enter image description here

Single-Threaded CTE/OVER() Plan:

enter image description here

CROSS APPLY Plan:

enter image description here

  1. Gleiches gilt für den Sonderfall, in dem genau eine Verlaufszeile pro Produkt erforderlich ist (der letzte Eintrag von TransactionDate, Tie-Break für TransactionID.

Auch hier geringfügige Änderungen. In der CTE-Lösung fügen wir der Klausel OVER()TransactionID hinzu und ändern den äußeren Filter in rn = 1. Für CROSS APPLY Ändern wir TOP in TOP (1) und fügen TransactionID zum inneren ORDER BY Hinzu.

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

Laufzeitergebnisse:

enter image description here

Paralleler CTE/OVER() Plan:

enter image description here

Single-Threaded-CTE/OVER () -Plan:

enter image description here

CROSS APPLY Plan:

enter image description here

Fensterfunktionen sind nicht immer die beste Alternative (probieren Sie COUNT(*) OVER() aus), und dies sind nicht die einzigen beiden Ansätze zur Lösung des Problems mit n Zeilen pro Gruppe, aber in diesem speziellen Fall - angesichts des Schemas, Bestehende Indizes und Datenverteilung - der CTE schnitt bei allen aussagekräftigen Konten besser ab.


AdventureWorks-Beispiele - mit Flexibilität zum Hinzufügen von Indizes

Wenn Sie jedoch einen unterstützenden Index hinzufügen, ähnlich wie der von Paul in einem Kommentar erwähnte , jedoch mit der 2. und 3. Spalte in der Reihenfolge DESC:

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

Sie würden tatsächlich rundum viel günstigere Pläne erhalten, und die Metriken würden sich umdrehen, um den CROSS APPLY - Ansatz in allen drei Fällen zu bevorzugen:

enter image description here

Wenn dies meine Produktionsumgebung wäre, wäre ich wahrscheinlich mit der Dauer in diesem Fall zufrieden und würde mich nicht darum kümmern, weiter zu optimieren.


Dies war alles viel hässlicher in SQL Server 2000, das APPLY oder die Klausel OVER() nicht unterstützte.

47
Aaron Bertrand

In DBMS wie MySQL, die keine Fensterfunktionen oder CROSS APPLY Haben, besteht die Möglichkeit darin, Standard-SQL (89) zu verwenden. Der langsame Weg wäre eine dreieckige Kreuzverbindung mit dem Aggregat. Der schnellere Weg (aber immer noch und wahrscheinlich nicht so effizient wie die Verwendung von cross apply oder der Funktion row_number) wäre das, was ich als "CROSS APPLY" Des armen Mannes bezeichne. Es wäre interessant, diese Abfrage mit den anderen zu vergleichen:

Annahme: Orders (CustomerID, OrderDate) hat eine UNIQUE Einschränkung :

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 ;

Für das zusätzliche Problem der benutzerdefinierten oberen Zeilen pro Gruppe:

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 ;

Hinweis: In MySQL würde man anstelle von AND o.OrderID IN (SELECT TOP(@top) oi.OrderID ...)AND o.OrderDate >= (SELECT oi.OrderDate ... LIMIT 1 OFFSET (@top - 1)) verwenden. SQL-Server hat in der Version 2012 die Syntax FETCH / OFFSET Hinzugefügt. Die Abfragen hier wurden mit IN (TOP...) angepasst, um mit früheren Versionen zu arbeiten.

24
ypercubeᵀᴹ

Ich habe einen etwas anderen Ansatz gewählt, hauptsächlich um zu sehen, wie sich diese Technik mit den anderen vergleichen lässt, weil es gut ist, Optionen zu haben, oder?

Das Testen

Warum schauen wir uns nicht zunächst an, wie sich die verschiedenen Methoden gegeneinander stapeln? Ich habe drei Tests durchgeführt:

  1. Der erste Satz wurde ohne DB-Änderungen ausgeführt
  2. Der zweite Satz wurde ausgeführt, nachdem ein Index erstellt wurde, der TransactionDate -basierte Abfragen für Production.TransactionHistory Unterstützt.
  3. Der dritte Satz machte eine etwas andere Annahme. Da alle drei Tests mit derselben Produktliste durchgeführt wurden, was ist, wenn wir diese Liste zwischengespeichert haben? Meine Methode verwendet einen In-Memory-Cache, während die anderen Methoden eine äquivalente temporäre Tabelle verwenden. Der für den zweiten Testsatz erstellte unterstützende Index ist für diesen Testsatz noch vorhanden.

Zusätzliche Testdetails:

  • Die Tests wurden unter AdventureWorks2012 Unter SQL Server 2012, SP2 (Developer Edition) ausgeführt.
  • Für jeden Test habe ich angegeben, wessen Antwort ich die Abfrage entnommen habe und welche bestimmte Abfrage es war.
  • Ich habe die Option "Ergebnisse nach Ausführung verwerfen" von Abfrageoptionen | verwendet Ergebnisse.
  • Bitte beachten Sie, dass für die ersten beiden Testreihen das RowCounts für meine Methode "aus" zu sein scheint. Dies liegt daran, dass meine Methode eine manuelle Implementierung dessen ist, was CROSS APPLY Tut: Sie führt die anfängliche Abfrage für Production.Product Aus und erhält 161 Zeilen zurück, die sie dann für die Abfragen für Production.TransactionHistory. Daher sind die RowCount -Werte für meine Einträge immer 161 mehr als die anderen Einträge. In der dritten Testreihe (mit Caching) sind die Zeilenzahlen für alle Methoden gleich.
  • Ich habe SQL Server Profiler verwendet, um die Statistiken zu erfassen, anstatt mich auf die Ausführungspläne zu verlassen. Aaron und Mikael haben bereits großartige Arbeit geleistet und die Pläne für ihre Anfragen gezeigt, und es besteht keine Notwendigkeit, diese Informationen zu reproduzieren. Und die Absicht meiner Methode ist es, die Abfragen auf eine so einfache Form zu reduzieren, dass es nicht wirklich wichtig wäre. Es gibt einen zusätzlichen Grund für die Verwendung von Profiler, der jedoch später erwähnt wird.
  • Anstatt das Konstrukt Name >= N'M' AND Name < N'S' Zu verwenden, habe ich mich für die Verwendung von Name LIKE N'[M-R]%' Entschieden, und SQL Server behandelt sie gleich.

Die Ergebnisse

Kein unterstützender Index

Dies ist im Wesentlichen ein sofort einsatzbereites AdventureWorks2012. In allen Fällen ist meine Methode eindeutig besser als einige der anderen, aber niemals so gut wie die Top 1 oder 2 Methoden.

Test 1 Test 1 Results-with no index
Aarons CTE ist hier eindeutig der Gewinner.

Test 2 Test 2 Results-with no index
Aarons CTE (wieder) und Mikaels zweite apply row_number() Methode sind eine knappe Sekunde.

Test Test 3 Results-with no index
Aarons CTE ist (wieder) der Gewinner.

Fazit
Wenn es keinen unterstützenden Index für TransactionDate gibt, ist meine Methode besser als eine Standardmethode CROSS APPLY, Aber dennoch ist die Verwendung der CTE-Methode eindeutig der richtige Weg.

Mit unterstützendem Index (kein Caching)

Für diese Testreihe habe ich den offensichtlichen Index für TransactionHistory.TransactionDate Hinzugefügt, da alle Abfragen in diesem Feld sortiert sind. Ich sage "offensichtlich", da die meisten anderen Antworten auch in diesem Punkt übereinstimmen. Und da die Abfragen alle die neuesten Daten wünschen, sollte das Feld TransactionDate nach DESC bestellt werden, also habe ich einfach die Anweisung CREATE INDEX Am Ende von Mikaels Antwort genommen und hinzugefügt ein explizites FILLFACTOR:

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

Sobald dieser Index vorhanden ist, ändern sich die Ergebnisse erheblich.

Test 1 Test 1 Results-with supporting index
Diesmal ist es meine Methode, die sich zumindest in Bezug auf logische Lesevorgänge durchsetzt. Die CROSS APPLY - Methode, die zuvor die schlechteste Leistung für Test 1 erbracht hat, gewinnt bei der Dauer und übertrifft sogar die CTE-Methode bei logischen Lesevorgängen.

Test 2 Test 2 Results-with supporting index
Diesmal ist es Mikaels erste apply row_number() Methode, die beim Betrachten von Reads gewinnt, während sie zuvor eine der schlechtesten Leistungen erbrachte. Und jetzt kommt meine Methode beim Betrachten von Reads auf einen sehr knappen zweiten Platz. Außerhalb der CTE-Methode sind alle anderen in Bezug auf Lesevorgänge ziemlich nahe beieinander.

Test Test 3 Results-with supporting index
Hier ist der CTE immer noch der Gewinner, aber jetzt ist der Unterschied zwischen den anderen Methoden kaum spürbar im Vergleich zu dem drastischen Unterschied, der vor der Erstellung des Index bestand.

Fazit
Die Anwendbarkeit meiner Methode ist jetzt offensichtlicher, obwohl sie weniger widerstandsfähig ist, wenn keine geeigneten Indizes vorhanden sind.

Mit unterstützendem Index UND Caching

Für diese Testreihe habe ich das Caching verwendet, weil, warum nicht? Meine Methode ermöglicht die Verwendung von In-Memory-Caching, auf das die anderen Methoden nicht zugreifen können. Um fair zu sein, habe ich die folgende temporäre Tabelle erstellt, die anstelle von Product.Product Für alle Referenzen in diesen anderen Methoden in allen drei Tests verwendet wurde. Das Feld DaysToManufacture wird nur in Test Nummer 2 verwendet, aber es war einfacher, in allen SQL-Skripten konsistent zu sein, um dieselbe Tabelle zu verwenden, und es hat nicht geschadet, sie dort zu haben.

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

Test 1 Test 1 Results-with supporting index AND caching
Alle Methoden scheinen gleichermaßen vom Caching zu profitieren, und meine Methode hat immer noch die Nase vorn.

Test 2 Test 2 Results-with supporting index AND caching
Hier sehen wir jetzt einen Unterschied in der Aufstellung, da meine Methode kaum voraus ist, nur 2 Lesevorgänge besser als Mikaels erste apply row_number() -Methode, während meine Methode ohne Caching um 4 Lesevorgänge zurückblieb.

Test Test 3 Results-with supporting index AND caching
Siehe Update unten (unter der Zeile) . Hier sehen wir wieder einen Unterschied. Der "parametrisierte" Geschmack meiner Methode liegt jetzt mit 2 Lesevorgängen kaum noch an der Spitze im Vergleich zu Aarons CROSS APPLY-Methode (ohne Caching waren sie gleich). Aber das wirklich Seltsame ist, dass wir zum ersten Mal eine Methode sehen, die durch das Caching negativ beeinflusst wird: Aarons CTE-Methode (die zuvor die beste für Test Nummer 3 war). Aber ich werde keine Gutschrift nehmen, wenn sie nicht fällig ist, und da ohne das Caching die CTE-Methode von Aaron immer noch schneller ist als meine Methode hier mit dem Caching, scheint der beste Ansatz für diese spezielle Situation die CTE-Methode von Aaron zu sein.

Fazit Siehe Update unten (unter der Linie)
Situationen, in denen die Ergebnisse einer sekundären Abfrage wiederholt verwendet werden, können häufig (aber nicht immer) vom Zwischenspeichern dieser Ergebnisse profitieren. Wenn das Zwischenspeichern jedoch von Vorteil ist, hat die Verwendung von Speicher für das Zwischenspeichern einen gewissen Vorteil gegenüber der Verwendung temporärer Tabellen.

Die Methode

Allgemein

Ich habe die "Header" -Abfrage (dh das Abrufen der ProductID und in einem Fall auch der DaysToManufacture, basierend auf der Name beginnend mit bestimmten Buchstaben) vom "Detail" getrennt "Abfragen (dh Abrufen der TransactionID und TransactionDate). Das Konzept bestand darin, sehr einfache Abfragen durchzuführen und dem Optimierer nicht zu erlauben, beim Verbinden verwirrt zu werden. Dies ist natürlich nicht immer vorteilhaft, da es dem Optimierer auch nicht erlaubt, zu optimieren. Aber wie wir in den Ergebnissen gesehen haben, hat diese Methode je nach Art der Abfrage ihre Vorzüge.

Der Unterschied zwischen den verschiedenen Geschmacksrichtungen dieser Methode ist:

  • Konstanten : Übergeben Sie alle austauschbaren Werte als Inline-Konstanten, anstatt Parameter zu sein. Dies würde sich in allen drei Tests auf ProductID und auch auf die Anzahl der in Test 2 zurückzugebenden Zeilen beziehen, da dies eine Funktion des "fünffachen des Produktattributs DaysToManufacture" ist. Diese Untermethode bedeutet, dass jedes ProductID seinen eigenen Ausführungsplan erhält. Dies kann von Vorteil sein, wenn die Datenverteilung für ProductID sehr unterschiedlich ist. Wenn sich die Datenverteilung jedoch kaum ändert, werden sich die Kosten für die Erstellung der zusätzlichen Pläne wahrscheinlich nicht lohnen.

  • Parametrisiert : Senden Sie mindestens ProductID als @ProductID, Um das Caching und die Wiederverwendung des Ausführungsplans zu ermöglichen. Es gibt eine zusätzliche Testoption, mit der auch die variable Anzahl von Zeilen, die für Test 2 zurückgegeben werden sollen, als Parameter behandelt wird.

  • nbekannt optimieren : Wenn Sie ProductID als @ProductID Verweisen und große Unterschiede in der Datenverteilung aufweisen, können Sie einen Plan zwischenspeichern, der sich negativ auf andere auswirkt ProductID -Werte, daher ist es gut zu wissen, ob die Verwendung dieses Abfragehinweises hilfreich ist.

  • Cache Products : Anstatt die Tabelle Production.Product Jedes Mal abzufragen, führen Sie die Abfrage einmal aus, um genau dieselbe Liste zu erhalten (und filtern Sie dabei alle ProductID, die nicht einmal in der Tabelle TransactionHistory enthalten sind, damit wir dort keine Ressourcen verschwenden) und diese Liste zwischenspeichern. Die Liste sollte das Feld DaysToManufacture enthalten. Bei Verwendung dieser Option wird bei der ersten Ausführung ein etwas höherer anfänglicher Treffer bei logischen Lesevorgängen erzielt, danach wird jedoch nur die Tabelle TransactionHistory abgefragt.

Speziell

Ok, aber wie ist es möglich, alle Unterabfragen als separate Abfragen auszugeben, ohne einen CURSOR zu verwenden und jede Ergebnismenge in eine temporäre Tabelle oder Tabellenvariable zu kopieren? Das eindeutige Ausführen der CURSOR/Temp Table-Methode würde sich ganz offensichtlich in den Lese- und Schreibvorgängen widerspiegeln. Nun, mit SQLCLR :). Durch Erstellen einer gespeicherten SQLCLR-Prozedur konnte ich eine Ergebnismenge öffnen und die Ergebnisse jeder Unterabfrage im Wesentlichen als kontinuierliche Ergebnismenge (und nicht als mehrere Ergebnismengen) an diese streamen. Außerhalb der Produktinformationen (dh ProductID, Name und DaysToManufacture) musste keines der Ergebnisse der Unterabfrage irgendwo gespeichert werden (Speicher oder Festplatte) und wurde nur abgerufen als Hauptergebnismenge der gespeicherten SQLCLR-Prozedur durchlaufen. Auf diese Weise konnte ich eine einfache Abfrage durchführen, um die Produktinformationen abzurufen, und diese dann durchlaufen, wobei sehr einfache Abfragen für TransactionHistory ausgegeben wurden.

Aus diesem Grund musste ich SQL Server Profiler verwenden, um die Statistiken zu erfassen. Die gespeicherte SQLCLR-Prozedur hat keinen Ausführungsplan zurückgegeben, weder durch Festlegen der Abfrageoption "Aktuellen Ausführungsplan einschließen" noch durch Ausgeben von SET STATISTICS XML ON;.

Für das Zwischenspeichern von Produktinformationen habe ich eine generische Liste readonly static Verwendet (d. H. _GlobalProducts Im folgenden Code). Es scheint, dass das Hinzufügen zu Sammlungen nicht gegen die Option readonly verstößt. Daher funktioniert dieser Code, wenn die Assembly einen PERMISSON_SET Von SAFE :) hat, auch wenn dies nicht intuitiv ist .

Die generierten Abfragen

Die von dieser gespeicherten SQLCLR-Prozedur erzeugten Abfragen lauten wie folgt:

Produktinformation

Testnummern 1 und 3 (kein Caching)

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

Test Nummer 2 (kein Caching)

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

Testnummern 1, 2 und 3 (Caching)

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

Transaktionsinfo

Testnummern 1 und 2 (Konstanten)

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

Testnummern 1 und 2 (parametrisiert)

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

Testnummern 1 und 2 (Parametriert + OPTIMIEREN UNBEKANNT)

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

Test Nummer 2 (beide parametrisiert)

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

Test Nummer 2 (Parametrisiert beide + OPTIMIEREN UNBEKANNT)

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

Test Nummer 3 (Konstanten)

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

Test Nummer 3 (parametrisiert)

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

Test Nummer 3 (Parametriert + OPTIMIEREN UNBEKANNT)

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

Der Code

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


    }
}

Die Testabfragen

Es ist nicht genug Platz, um die Tests hier zu veröffentlichen, so dass ich einen anderen Ort finden werde.

Das Fazit

In bestimmten Szenarien kann SQLCLR verwendet werden, um bestimmte Aspekte von Abfragen zu bearbeiten, die in T-SQL nicht ausgeführt werden können. Und es gibt die Möglichkeit, Speicher für das Caching anstelle von temporären Tabellen zu verwenden. Dies sollte jedoch sparsam und vorsichtig erfolgen, da der Speicher nicht automatisch an das System zurückgegeben wird. Diese Methode hilft auch nicht bei Ad-hoc-Abfragen, obwohl es möglich ist, sie flexibler zu gestalten, als ich hier gezeigt habe, indem einfach Parameter hinzugefügt werden, um mehr Aspekte der ausgeführten Abfragen anzupassen.


AKTUALISIEREN

zusätzlicher Test
Meine ursprünglichen Tests, die einen unterstützenden Index für TransactionHistory enthielten, verwendeten die folgende Definition:

ProductID ASC, TransactionDate DESC

Ich hatte zu der Zeit beschlossen, am Ende auf die Aufnahme von TransactionId DESC Zu verzichten, da ich dachte, dass dies Test Nr. 3 helfen könnte (der das Brechen von Krawatten für das neueste TransactionId angibt - na ja, "die meisten" Neuere "wird angenommen, da nicht explizit angegeben, aber alle scheinen dieser Annahme zuzustimmen.) Es würde wahrscheinlich nicht genug Bindungen geben, um einen Unterschied zu machen.

Aber dann testete Aaron erneut mit einem unterstützenden Index, der TransactionId DESC Enthielt, und stellte fest, dass die CROSS APPLY - Methode der Gewinner aller drei Tests war. Dies war anders als bei meinen Tests, die zeigten, dass die CTE-Methode für Test Nummer 3 am besten geeignet war (wenn kein Caching verwendet wurde, was Aarons Test widerspiegelt). Es war klar, dass es eine zusätzliche Variation gab, die getestet werden musste.

Ich habe den aktuellen unterstützenden Index entfernt, einen neuen mit TransactionId erstellt und den Plan-Cache geleert (nur um sicherzugehen):

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;

Ich habe Test Nummer 1 erneut ausgeführt und die Ergebnisse waren erwartungsgemäß dieselben. Ich habe dann Test Nummer 3 erneut ausgeführt und die Ergebnisse haben sich tatsächlich geändert:

Test 3 Results-with supporting index (with TransactionId DESC)
Die obigen Ergebnisse gelten für den Standardtest ohne Caching. Dieses Mal schlägt nicht nur der CROSS APPLY Den CTE (genau wie Aarons Test gezeigt hat), sondern der SQLCLR-Prozess übernahm die Führung mit 30 Reads (woo hoo).

Test 3 Results-with supporting index (with TransactionId DESC) AND caching
Die obigen Ergebnisse gelten für den Test mit aktiviertem Caching. Diesmal wird die Leistung des CTE nicht beeinträchtigt, obwohl der CROSS APPLY Sie immer noch übertrifft. Jetzt übernimmt der SQLCLR-Prozess jedoch die Führung mit 23 Lesevorgängen (wieder woo hoo).

Take Aways

  1. Es gibt verschiedene Optionen. Es ist am besten, mehrere zu probieren, da jeder seine Stärken hat. Die hier durchgeführten Tests zeigen eine relativ geringe Abweichung sowohl bei den Lesevorgängen als auch bei der Dauer zwischen den besten und den schlechtesten Leistungsträgern über alle Tests hinweg (mit einem unterstützenden Index). Die Variation der Lesevorgänge beträgt ca. 350 und die Dauer 55 ms. Während der SQLCLR-Prozess in allen Tests bis auf einen (in Bezug auf Lesevorgänge) gewonnen hat, ist das Speichern von nur wenigen Lesevorgängen normalerweise nicht die Wartungskosten für die SQLCLR-Route wert. In AdventureWorks2012 enthält die Tabelle Product jedoch nur 504 Zeilen und TransactionHistory nur 113.443 Zeilen. Der Leistungsunterschied zwischen diesen Methoden wird wahrscheinlich mit zunehmender Anzahl der Zeilen ausgeprägter.

  2. Während diese Frage spezifisch für das Abrufen eines bestimmten Satzes von Zeilen war, sollte nicht übersehen werden, dass der größte einzelne Leistungsfaktor die Indizierung und nicht das bestimmte SQL war. Ein guter Index muss vorhanden sein, bevor ermittelt werden kann, welche Methode wirklich die beste ist.

  3. Die wichtigste Lektion hier ist nicht CROSS APPLY vs CTE vs SQLCLR: Es geht um TESTING. Nimm nicht an. Holen Sie sich Ideen von mehreren Personen und testen Sie so viele Szenarien wie möglich.

21
Solomon Rutzky

APPLY TOP Oder ROW_NUMBER()? Was könnte dazu möglicherweise noch mehr zu sagen sein?

Eine kurze Zusammenfassung der Unterschiede und um es wirklich kurz zu halten, ich werde nur die Pläne für Option 2 zeigen und ich habe den Index für Production.TransactionHistory Hinzugefügt.

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

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

enter image description here

Die apply top Version:

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

enter image description here

Der Hauptunterschied zwischen diesen besteht darin, dass apply top Im oberen Ausdruck unterhalb der Verknüpfung mit verschachtelten Schleifen filtert, wobei die Version row_number Nach der Verknüpfung filtert. Das heißt, es gibt mehr Lesevorgänge von Production.TransactionHistory Als wirklich notwendig ist.

Wenn es nur eine Möglichkeit gäbe, die für die Aufzählung von Zeilen verantwortlichen Operatoren vor dem Join in den unteren Zweig zu verschieben, ist die Version row_number Möglicherweise besser geeignet.

Geben Sie also die Version apply row_number() ein.

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

enter image description here

Wie Sie sehen können, ist apply row_number() so ziemlich dasselbe wie apply top, Nur etwas komplizierter. Die Ausführungszeit ist ebenfalls ungefähr gleich oder etwas langsamer.

Warum habe ich mir die Mühe gemacht, eine Antwort zu finden, die nicht besser ist als die, die wir bereits haben? Nun, Sie müssen noch etwas in der realen Welt ausprobieren, und es gibt tatsächlich einen Unterschied beim Lesen. Eine, für die ich keine Erklärung habe *.

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.

Während ich dabei bin, könnte ich genauso gut eine zweite row_number() Version einwerfen, die in bestimmten Fällen der richtige Weg sein könnte. In bestimmten Fällen erwarten Sie, dass Sie tatsächlich die meisten Zeilen von Production.TransactionHistory Benötigen, da hier eine Zusammenführungsverbindung zwischen Production.Product Und dem aufgezählten Production.TransactionHistory Erhalten wird.

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;

enter image description here

Um die obige Form ohne Sortieroperator zu erhalten, müssen Sie auch den unterstützenden Index in absteigender Reihenfolge nach TransactionDate ändern.

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

* * Bearbeiten: Die zusätzlichen logischen Lesevorgänge sind auf das Prefetching der verschachtelten Schleifen zurückzuführen, das mit dem Apply-Top verwendet wird. Sie können dies mit undoc'd TF 8744 (und/oder 9115 in späteren Versionen) deaktivieren, um die gleiche Anzahl logischer Lesevorgänge zu erhalten. Prefetching könnte unter den richtigen Umständen ein Vorteil der Apply-Top-Alternative sein. - Paul White

18
Mikael Eriksson

Ich verwende normalerweise eine Kombination aus CTEs und Fensterfunktionen. Sie können diese Antwort wie folgt erreichen:

;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

Für den zusätzlichen Kreditanteil, bei dem verschiedene Gruppen möglicherweise eine unterschiedliche Anzahl von Zeilen zurückgeben möchten, können Sie eine separate Tabelle verwenden. Nehmen wir an, wir verwenden geografische Kriterien wie den Status:

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

Um dies zu erreichen, wenn die Werte unterschiedlich sein können, müssen Sie Ihren CTE mit der State-Tabelle wie folgt verknüpfen:

SELECT [CustomerID]
    ,[OrderDate]
    ,[TotalAmt]
    ,[State]
FROM GiveMeCounts gmc
INNER JOIN StateTable st ON gmc.[State] = st.[State]
    AND gmc.MySeqNum <= st.MaxSeqNum
11