Abrufen von n Zeilen pro Gruppe

88

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 des Gruppierungs- / übergeordneten Datensatzes). Dieser Teil ist definitiv optional / für zusätzliche Gutschriften und nicht dazu gedacht, Leute von der Beantwortung abzubringen.

Was sind die Hauptoptionen zum Lösen solcher Probleme in SQL Server 2005 und höher? Was sind die wichtigsten Vor- und Nachteile jeder Methode?

AdventureWorks-Beispiele (zur Verdeutlichung optional)

  1. Listen Sie die fünf letzten Transaktionsdaten und IDs aus der TransactionHistoryTabelle für jedes Produkt auf, das mit einem Buchstaben von M bis einschließlich R beginnt.
  2. Wieder dasselbe, aber mit nVerlaufszeilen pro Produkt, wobei ndas fünffache des DaysToManufactureProduct-Attributs gilt.
  3. Gleiches gilt für den Sonderfall, dass genau eine Historienzeile pro Produkt erforderlich ist (der letzte Eintrag von TransactionDate, Tie-Break on TransactionID.
Paul White
quelle

Antworten:

70

Beginnen wir mit dem Basisszenario.

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 ganze Menge von Production.TransactionHistoryfür einen bestimmten 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, das den Filter bereitstellt. 797 liest hier, was hässlich ist.

Teurer Scan mit 'Residual'-Prädikat

Seien wir also fair und erstellen Sie einen Index, der nützlicher wäre. Unsere Bedingungen sehen eine Übereinstimmung mit Gleichheit vor ProductID, gefolgt von einer Suche nach der neuesten von TransactionDate. Wir müssen das TransactionIDauch wieder, so gehen wir mit: CREATE INDEX ix_FindingMostRecent ON Production.TransactionHistory (ProductID, TransactionDate) INCLUDE (TransactionID);.

Nachdem wir dies getan haben, ändert sich unser Plan erheblich und wir reduzieren die Werte auf nur 3. Also verbessern wir die Dinge bereits um ungefähr das 250-fache ...

Verbesserter Plan

Nachdem wir das Spielfeld geebnet 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;

Zwei Pläne - Grund TOP \ RowNum

Sie werden feststellen, dass die second ( TOP) - Abfrage sowohl in der Abfrage als auch im Plan viel einfacher ist als die erste. Bezeichnenderweise TOPbegrenzen beide die Anzahl der Zeilen, die tatsächlich aus dem Index entfernt werden. Die Kosten sind nur Schätzungen und es lohnt sich, sie zu ignorieren. Sie können jedoch eine große Ähnlichkeit in den beiden Plänen feststellen, da die ROW_NUMBER()Version nur einen geringen zusätzlichen Aufwand für die Zuweisung von Zahlen und die entsprechende Filterung leistet und beide Abfragen nur zwei Lesevorgänge benötigen ihre Arbeit. Das Abfrageoptimierungsprogramm erkennt sicherlich die Idee des Filterns in einem ROW_NUMBER()Feld an und erkennt, dass es einen Top-Operator verwenden kann, um nicht benötigte Zeilen zu ignorieren. Beide Abfragen sind gut genug - TOPnicht so gut, dass es sich lohnt, den Code zu ändern, aber für Anfänger ist es einfacher und wahrscheinlich klarer.

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

Der iterative Programmierer wird die Idee in Betracht ziehen, die Produkte von Interesse zu durchlaufen und diese Abfrage mehrmals aufzurufen, und wir können tatsächlich davon abkommen, eine Abfrage in dieser Form zu schreiben - nicht mit Cursorn, sondern mit APPLY. Ich benutze OUTER APPLY, um herauszufinden, dass wir das Produkt möglicherweise mit NULL zurückgeben möchten, wenn es keine Transaktionen dafür gibt.

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 iterative Programmiermethode - Nested Loop, bei der für jedes Produkt eine Top-Operation und ein Suchvorgang (die beiden zuvor durchgeführten Lesevorgänge) ausgeführt werden. Dies ergibt 4 Lesevorgänge für Produkt und 360 Lesevorgänge für TransactionHistory.

Plan anwenden

Unter Verwendung ROW_NUMBER()der Methode wird PARTITION BYin der OVERKlausel verwendet, so dass wir die Nummerierung für jedes Produkt neu starten. Dies kann dann wie zuvor gefiltert werden. Der Plan endet ganz anders. Die logischen Lesevorgänge sind in TransactionHistory um etwa 15% niedriger, wobei ein vollständiger Index-Scan durchgeführt wird, um die Zeilen zu entfernen.

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 Rownumbers zu finden. Es sind weniger Lesevorgänge, aber diese blockierende Sortierung könnte sich schmerzhaft anfühlen. Bei Verwendung von APPLYgibt die verschachtelte Schleife die ersten Zeilen nach nur wenigen Lesevorgängen sehr schnell zurück, bei einer Sortierung ROW_NUMBER()jedoch erst, nachdem ein Großteil der Arbeit abgeschlossen wurde.

Interessanterweise wird ein anderer Plan erstellt , wenn die ROW_NUMBER()Abfrage INNER JOINanstelle von verwendet LEFT JOINwird.

ROW_NUMBER () mit 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, 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 PARTITION BYSpalte, für die ich verwendet habe, ROW_NUMBER()war h.ProductIDin beiden Fällen, weil ich dem QO die Option geben wollte, den RowNum-Wert vor dem Beitritt zur Product-Tabelle zu erzeugen. Wenn ich benutze p.ProductID, sehen wir den gleichen Formplan wie bei der INNER JOINVariante.

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 in der TransactionHistory-Tabelle liegt immer noch bei knapp 500 Lesevorgängen.

PARTITION BY auf p.ProductID anstelle von 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 APPLYOption.

Um dies zu erweitern, um eine variable Nummer zu verwenden ( Frage 2 ), muss die 5gerade entsprechend geändert werden. Oh, und ich habe einen weiteren Index hinzugefügt, sodass ein Index für Production.Product.Namediese DaysToManufactureSpalte vorhanden ist.

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 Zeilen

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

Eine Möglichkeit, den Block zu umgehen, besteht darin, einen Plan zu erstellen, der das ROW_NUMBER()Bit rechts (im Plan) des Joins behandelt. Wir können dies ü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 gibt eine versteckte Gefahr.

Beitritt außerhalb des CTE

Beachten Sie den Compute Scalar, der Daten aus der Product-Tabelle abruft. Dies 5 * p.DaysToManufactureberechnet den Wert. Dieser Wert wird nicht an den Zweig übergeben, der Daten aus der TransactionHistory-Tabelle abruft, sondern im Merge Join verwendet. Als Rest.

Hinterhältiger Rest!

Der Merge-Join verbraucht also ALLE Zeilen, nicht nur die ersten, aber viele, 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 APPLY/TOPSzenario bevorzuge .

In dem speziellen Fall, in dem es sich genau um eine Zeile handelt, können wir für Frage 3 natürlich dieselben Abfragen verwenden, jedoch mit 1anstelle 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 modifizieren, um die TransactionID auch für Verbindungszwecke herauszuholen (unter Verwendung einer Verkettung, die dann aufgeschlüsselt wird), aber wir betrachten entweder den gesamten Index oder Wir tauchen Produkt für Produkt auf, und wir bekommen nicht wirklich eine große Verbesserung gegenüber dem, was wir zuvor in diesem Szenario hatten.

Ich möchte jedoch darauf hinweisen, dass wir hier ein bestimmtes Szenario betrachten. Bei realen Daten und einer möglicherweise nicht idealen Indizierungsstrategie kann der Kilometerstand erheblich variieren. Obwohl wir gesehen haben, dass dies APPLYhier stark ist, kann es in manchen Situationen langsamer sein. Es blockiert jedoch selten, da es die Tendenz hat, verschachtelte Schleifen zu verwenden, die für viele Leute (einschließlich mir) sehr attraktiv sind.

Ich habe hier nicht versucht, Parallelität zu untersuchen, oder ich habe mich sehr intensiv mit Frage 3 befasst, die ich als besonderen Fall betrachte, den die Leute aufgrund der Komplikation von Verketten und Teilen selten wollen. Das Wichtigste dabei ist, dass diese beiden Optionen sehr stark sind.

Ich ziehe APPLY. Es ist klar, es benutzt den Top-Operator gut und es verursacht selten ein Blockieren.

Rob Farley
quelle
44

Die typische Methode, um 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()eine PARTITIONKlausel verwenden und danach in der äußeren Abfrage filtern. So könnten zum Beispiel 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 tun mit 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;

Wenn die zusätzliche Option Paul angegeben ist, enthält die Customers-Tabelle eine Spalte, die angibt, wie viele Zeilen pro Kunde eingeschlossen werden 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;

Und wieder mit CROSS APPLYder hinzugefügten Option, 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 der unterstützenden Indizes unterschiedlich sind. Die Optimierung der Leistung und die Ermittlung des besten Plans hängen daher von den lokalen Faktoren ab.

Persönlich bevorzuge ich die CTE- und Fensterlösungen gegenüber der CROSS APPLY/, TOPweil 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 allgemeine Wahrheit verstanden werden. Sie sollten Ihre Szenarien immer testen, insbesondere wenn sich die Indizes geändert haben oder Die Daten sind erheblich schief gelaufen.


AdventureWorks-Beispiele - ohne Änderungen

  1. Listen Sie die fünf letzten Transaktionsdaten und IDs aus der TransactionHistoryTabelle 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:

Bildbeschreibung hier eingeben

CTE / OVER()Plan:

Bildbeschreibung hier eingeben

CROSS APPLY planen:

Bildbeschreibung hier eingeben

Der CTE-Plan sieht komplizierter aus, ist jedoch wesentlich effizienter. Schenken Sie den geschätzten Kosten in% wenig Beachtung, konzentrieren Sie sich jedoch auf wichtigere tatsächliche Beobachtungen, wie z. B. viel weniger Lesevorgänge und eine viel kürzere Dauer. Ich habe diese auch ohne Parallelität ausgeführt, und das war nicht der Unterschied. Laufzeitmetriken und der CTE-Plan (der CROSS APPLYPlan blieb derselbe):

Bildbeschreibung hier eingeben

Bildbeschreibung hier eingeben

  1. Wieder dasselbe, aber mit nVerlaufszeilen pro Produkt, wobei ndas fünffache des DaysToManufactureProduct-Attributs gilt.

Hier sind nur 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 die CROSS APPLYkönnen wir die berechnung innerhalb der korrelierten durchführen TOP. Man könnte meinen, dies würde der CROSS APPLYLösung eine gewisse Effizienz verleihen , aber in diesem Fall ist dies nicht der Fall. Abfragen:

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

Bildbeschreibung hier eingeben

Paralleler WAK / OVER()Plan:

Bildbeschreibung hier eingeben

Single-Threaded CTE / OVER()Plan:

Bildbeschreibung hier eingeben

CROSS APPLY planen:

Bildbeschreibung hier eingeben

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

Auch hier geringfügige Änderungen. In der CTE-Lösung fügen wir TransactionIDdie OVER()Klausel hinzu und ändern den äußeren Filter in rn = 1. Für die CROSS APPLYändern wir das TOPzu TOP (1)und ergänzen TransactionIDdas innere 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';

Laufzeitergebnisse:

Bildbeschreibung hier eingeben

Paralleler WAK / OVER()Plan:

Bildbeschreibung hier eingeben

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

Bildbeschreibung hier eingeben

CROSS APPLY planen:

Bildbeschreibung hier eingeben

Fensterfunktionen sind nicht immer die beste Alternative (probieren Sie es aus COUNT(*) OVER()), und dies sind nicht die einzigen zwei Lösungsansätze für das Problem mit n Zeilen pro Gruppe, sondern in diesem speziellen Fall - angesichts des Schemas, der vorhandenen Indizes und der Datenverteilung - Der CTE schnitt bei allen aussagekräftigen Berichten besser ab.


AdventureWorks-Beispiele - mit der Flexibilität, Indizes hinzuzufügen

Wenn Sie jedoch einen unterstützenden Index hinzufügen, der dem von Paul in einem Kommentar erwähnten Index ähnelt, bei dem jedoch die 2. und 3. Spalte nacheinander angeordnet sind DESC:

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

Sie würden tatsächlich viel günstigere Pläne erhalten, und die Metriken würden den CROSS APPLYAnsatz in allen drei Fällen favorisieren :

Bildbeschreibung hier eingeben

Wenn dies meine Produktionsumgebung wäre, wäre ich wahrscheinlich mit der Dauer in diesem Fall zufrieden und würde keine weiteren Optimierungen vornehmen.


Dies alles war in SQL Server 2000 viel hässlicher, was weder APPLYdie OVER()Klausel noch die unterstützte.

Aaron Bertrand
quelle
24

In DBMS, wie MySQL, die keine Fensterfunktionen haben, oder CROSS APPLYdie Möglichkeit, dies zu tun, wäre die Verwendung von Standard-SQL (89). Der langsame Weg wäre eine dreieckige Kreuzverbindung mit Aggregat. Der schnellere Weg (aber immer noch und wahrscheinlich nicht so effizient wie die Verwendung von cross apply oder der row_number-Funktion) wäre das, was ich den "Armen CROSS APPLY" nenne . Es wäre interessant, diese Abfrage mit den anderen zu vergleichen:

Annahme: Orders (CustomerID, OrderDate)hat eine UNIQUEEinschrä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 Reihen 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 AND o.OrderID IN (SELECT TOP(@top) oi.OrderID ...)würde anstelle von einem verwenden AND o.OrderDate >= (SELECT oi.OrderDate ... LIMIT 1 OFFSET (@top - 1)). SQL-Server hat die FETCH / OFFSETSyntax in der Version 2012 hinzugefügt . Die Abfragen hier wurden angepasst IN (TOP...), um mit früheren Versionen zu arbeiten.

ypercubeᵀᴹ
quelle
21

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

Die Prüfung

Warum fangen wir nicht damit an, uns nur anzuschauen, wie sich die verschiedenen Methoden gegenüberstehen. Ich habe drei Sätze von Tests gemacht:

  1. Der erste Satz wurde ohne DB-Änderungen ausgeführt
  2. Der zweite Satz wurde ausgeführt, nachdem ein Index erstellt wurde, um TransactionDateAbfragen zu unterstützen, die auf der Unterstützung basieren Production.TransactionHistory.
  3. Der dritte Satz ging von einer etwas anderen Annahme aus. Was passiert, wenn wir diese Liste zwischengespeichert haben, da alle drei Tests mit derselben Produktliste ausgeführt wurden? Meine Methode verwendet einen speicherinternen Cache, während die anderen Methoden eine entsprechende temporäre Tabelle verwendeten. Der für die zweite Gruppe von Tests erstellte unterstützende Index ist für diese Gruppe von Tests noch vorhanden.

Zusätzliche Testdetails:

  • Die Tests wurden AdventureWorks2012unter SQL Server 2012, SP2 (Developer Edition) ausgeführt.
  • Für jeden Test habe ich angegeben, von wem ich die Abfrage genommen habe und welche bestimmte Abfrage es war.
  • Ich habe die Option "Ergebnisse nach Ausführung verwerfen" unter Abfrageoptionen | verwendet Ergebnisse.
  • Bitte beachten Sie, dass für die ersten beiden Testreihen die RowCountsfür meine Methode "aus" zu sein scheinen. Dies ist darauf zurückzuführen, dass meine Methode eine manuelle Implementierung des aktuellen CROSS APPLYVorgangs ist: Sie führt die erste Abfrage aus Production.Productund ruft 161 Zeilen zurück, die dann für die Abfragen verwendet werden Production.TransactionHistory. Daher sind die RowCountWerte für meine Einträge immer 161 höher als für 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, um die Pläne für ihre Abfragen zu zeigen, 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 weiteren Grund für die Verwendung von Profiler, der jedoch später erwähnt wird.
  • Anstatt das Name >= N'M' AND Name < N'S'Konstrukt zu verwenden, habe ich es ausgewählt Name LIKE N'[M-R]%'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 die der anderen, aber niemals so gut wie die der ersten oder zweiten Methoden.

Test 1 Test 1 Ergebnisse - ohne Index
Aarons CTE ist hier eindeutig der Gewinner.

Test 2 Test 2 Ergebnisse - ohne Index
Aarons CTE (wieder) und Mikaels zweite apply row_number()Methode ist eine knappe Sekunde.

Test 3 Test 3 Ergebnisse - ohne Index
Aarons CTE (wieder) ist der Gewinner.

Fazit
Wenn es keinen unterstützenden Index gibt, ist TransactionDatemeine Methode besser als ein Standard CROSS APPLY, aber dennoch ist die Verwendung der CTE-Methode eindeutig der richtige Weg.

Mit unterstützendem Index (kein Caching)

Für diese Tests habe ich den offensichtlichen Index für hinzugefügt, TransactionHistory.TransactionDateda alle Abfragen in diesem Feld sortiert werden. Ich sage "offensichtlich", da die meisten anderen Antworten in diesem Punkt ebenfalls übereinstimmen. Und da für alle Abfragen die neuesten Daten gewünscht werden, sollte das TransactionDateFeld sortiert DESCwerden. Deshalb habe ich einfach die CREATE INDEXErklärung am Ende von Mikaels Antwort abgerufen und eine explizite hinzugefügt 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 Ergebnisse - mit unterstützendem Index
Dieses Mal ist es meine Methode, die zumindest in Bezug auf logische Lesevorgänge die Nase vorn hat. Die CROSS APPLYMethode, die zuvor die schlechteste Leistung für Test 1 erbrachte, gewinnt bei Duration und übertrifft sogar die CTE-Methode bei logischen Lesevorgängen.

Test 2 Test 2 Ergebnisse - mit unterstützendem Index
Diesmal ist es Mikaels erste apply row_number()Methode, die bei Reads als Sieger hervorgeht, während sie zuvor zu den schlechtesten gehört hat. Und jetzt kommt meine Methode beim Lesen auf einen sehr knappen zweiten Platz. Außerhalb der CTE-Methode liegen die restlichen Werte in Bezug auf die Lesezugriffe ziemlich nahe beieinander.

Test 3 Test 3 Ergebnisse - mit unterstützendem 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 richtigen Indizes vorhanden sind.

Mit unterstützendem Index UND Caching

Für diese Tests habe ich Caching verwendet, weil, na ja, 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 Product.Productfür alle Referenzen in diesen anderen Methoden in allen drei Tests verwendet wurde. Das DaysToManufactureFeld wird nur in Test Nummer 2 verwendet. Es war jedoch einfacher, über alle SQL-Skripte hinweg konsistent zu sein, um dieselbe Tabelle zu verwenden, und es tat nicht weh, 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 Ergebnisse - mit unterstützendem Index UND Caching
Alle Methoden scheinen gleichermaßen vom Caching zu profitieren, und meine Methode hat noch immer die Nase vorn.

Test 2 Test 2 Ergebnisse - mit unterstützendem Index UND Caching
Hier sehen wir jetzt einen Unterschied in der Aufstellung, da meine Methode kaum vorne rauskommt, 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 3 Test 3 Ergebnisse - mit unterstützendem Index UND Caching
Siehe Update unten (unter der Linie) . Hier sehen wir wieder einen Unterschied. Die "parametrisierte" Variante meiner Methode ist jetzt mit 2 Lesevorgängen kaum noch führend im Vergleich zu Aarons CROSS APPLY-Methode (ohne Zwischenspeicherung 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 nehme keine Gutschrift an, wenn sie nicht fällig ist, und da die CTE-Methode von Aaron ohne Caching immer noch schneller ist als meine Methode mit Caching, scheint der beste Ansatz für diese spezielle Situation die CTE-Methode von Aaron zu sein.

Schlussfolgerung Siehe Aktualisierung unten (unter der Zeile)
Situationen, in denen die Ergebnisse einer sekundären Abfrage wiederholt verwendet werden, können häufig (aber nicht immer) davon profitieren, dass diese Ergebnisse zwischengespeichert werden. Wenn das Zwischenspeichern ein Vorteil ist, hat die Verwendung von Speicher für das Zwischenspeichern einen gewissen Vorteil gegenüber der Verwendung von temporären Tabellen.

Die Methode

Allgemein

Ich habe die "Header" -Abfrage (dh das Erhalten der ProductIDs, und in einem Fall auch der DaysToManufacture, basierend auf dem NameBeginnen mit bestimmten Buchstaben) von den "Detail" -Abfragen (dh das Erhalten der TransactionIDs und TransactionDates) getrennt. Das Konzept bestand darin, sehr einfache Abfragen durchzuführen und zu verhindern, dass der Optimierer beim Beitreten verwirrt wird. Dies ist natürlich nicht immer von Vorteil, da es dem Optimierer auch verbietet, zu optimieren. Wie wir jedoch 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 ersetzbaren Werte als Inline-Konstanten, anstatt Parameter zu sein. Dies würde sich auf ProductIDalle drei Tests und auch auf die Anzahl der Zeilen beziehen, die in Test 2 zurückgegeben werden sollen, da dies eine Funktion des "fünffachen DaysToManufactureProduktattributs" ist. Diese Untermethode bedeutet, dass jeder ProductIDseinen eigenen Ausführungsplan erhält, was von Vorteil sein kann, wenn es bei der Datenverteilung große Unterschiede gibt ProductID. Wenn sich die Datenverteilung jedoch nur geringfügig ändert, lohnen sich die Kosten für die Erstellung der zusätzlichen Pläne wahrscheinlich nicht.

  • Parametrisiert: Übermitteln Sie mindestens ProductIDas @ProductID, um Zwischenspeicherung und Wiederverwendung des Ausführungsplans zu ermöglichen. Es gibt eine zusätzliche Testoption, mit der auch die variable Anzahl der Zeilen, die für Test 2 zurückgegeben werden sollen, als Parameter behandelt werden kann.

  • Zu Optimieren Unknown: Bei einem Verweise auf ProductIDwie @ProductID, wenn es große Unterschiede der Datenverteilung ist , dann ist es möglich , einen Plan cachen , die eine negative Wirkung auf andere haben ProductIDWerten , so dass es gut wäre , wenn die Nutzung dieses Abfragehinweises zu wissen , hilft jeder.

  • Cache-Produkte: Anstatt die Production.ProductTabelle jedes Mal abzufragen, nur um genau dieselbe Liste zu erhalten, führen Sie die Abfrage einmal aus (und filtern Sie, während wir gerade dabei sind, alle ProductIDs aus, die nicht einmal in der TransactionHistoryTabelle sind, damit wir keine verschwenden Ressourcen dort) und zwischenspeichern diese Liste. Die Liste sollte das DaysToManufactureFeld enthalten. Bei Verwendung dieser Option wird bei logischen Lesevorgängen bei der ersten Ausführung ein geringfügig höherer Treffer erzielt, danach wird jedoch nur die TransactionHistoryTabelle abgefragt.

Speziell

Ok, aber wie ist es möglich, alle Unterabfragen als separate Abfragen abzusetzen, ohne einen CURSOR zu verwenden und jede Ergebnismenge auf eine temporäre Tabelle oder Tabellenvariable zu sichern? Wenn Sie die CURSOR / Temp-Tabellenmethode verwenden, wird dies in den Lese- und Schreibvorgängen ganz offensichtlich wiedergegeben. Nun, mit SQLCLR :). Durch das Erstellen einer gespeicherten SQLCLR-Prozedur konnte ich eine Ergebnismenge öffnen und die Ergebnisse jeder Unterabfrage als fortlaufende Ergebnismenge (und nicht als mehrere Ergebnismengen) an diese streamen. Außerhalb der Produktinformation (dh ProductID, NameundDaysToManufacture), musste keines der Unterabfrageergebnisse irgendwo gespeichert werden (Speicher oder Datenträger) und wurde nur als Hauptergebnismenge der gespeicherten Prozedur SQLCLR durchgereicht. Auf diese Weise konnte ich eine einfache Abfrage durchführen, um die Produktinformationen abzurufen, und diese dann durchlaufen, wobei sehr einfache Abfragen für ausgegeben wurden TransactionHistory.

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

Für das Zwischenspeichern von Produktinformationen habe ich eine readonly staticallgemeine Liste verwendet (dh _GlobalProductsim folgenden Code). Es scheint, dass das Hinzufügen zu Sammlungen nicht die readonlyOption verletzt , daher funktioniert dieser Code, wenn die Assembly ein PERMISSON_SETvon SAFE:) hat, auch wenn dies nicht intuitiv ist.

Die generierten Abfragen

Die von dieser gespeicherten SQLCLR-Prozedur erstellten 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 (parametriert)

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 + UNBEKANNT OPTIMIEREN)

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 (beide parametrisiert + UNBEKANNT OPTIMIEREN)

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 + UNBEKANNT OPTIMIEREN)

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 genügend Platz vorhanden, um die Tests hier zu veröffentlichen, sodass ich einen anderen Ort finden kann.

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 zum Zwischenspeichern 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 sie flexibler gestaltet werden kann, 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 enthielten, TransactionHistoryverwendeten die folgende Definition:

ProductID ASC, TransactionDate DESC

Ich hatte damals beschlossen, auf das Einschließen TransactionId DESCam Ende zu verzichten , um herauszufinden, dass Test Nummer 3 zwar hilfreich sein könnte (der das Aufbrechen des letzten TransactionIdTests angibt - nun, es wird davon ausgegangen, dass "das Neueste" vorliegt, da dies nicht ausdrücklich angegeben ist, aber es scheint, dass alle dies tun Um dieser Annahme zuzustimmen, würde es wahrscheinlich nicht genug Bindungen geben, um einen Unterschied zu machen.

Doch dann testete Aaron erneut mit einem unterstützenden Index, der dies beinhaltete, TransactionId DESCund stellte fest, dass die CROSS APPLYMethode in allen drei Tests der Gewinner war. Dies war ein Unterschied zu meinen Tests, die zeigten, dass die CTE-Methode für Test Nummer 3 am besten 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 erstellt TransactionIdund 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 durchgefü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 Ergebnisse - mit unterstützendem Index (mit TransactionId DESC)
Die obigen Ergebnisse gelten für den Standardtest ohne Zwischenspeicherung. Dieses Mal hat nicht nur CROSS APPLYder CTE die Nase vorn (wie Aarons Test gezeigt hat), sondern auch der SQLCLR-Proc übernahm die Führung mit 30 Reads (woo hoo).

Test 3 Ergebnisse - mit unterstützendem Index (mit TransactionId DESC) UND Caching
Die obigen Ergebnisse gelten für den Test mit aktiviertem Caching. Diesmal ist die Leistung des CTE nicht beeinträchtigt, obwohl das CROSS APPLYimmer noch übertrifft. Jetzt übernimmt der SQLCLR-Prozess die Führung mit 23 Reads (wieder woo hoo).

Take Aways

  1. Es gibt verschiedene Möglichkeiten. Am besten probieren Sie mehrere aus, da sie alle ihre Stärken haben. Die hier durchgeführten Tests zeigen eine relativ geringe Varianz in Bezug auf Lesevorgänge und Dauer zwischen den besten und schlechtesten Performern über alle Tests hinweg (mit einem unterstützenden Index). Die Abweichung bei den Lesevorgängen beträgt ungefähr 350 und die Dauer beträgt 55 ms. Während der SQLCLR-Prozess in allen Tests außer 1 (in Bezug auf Lesevorgänge) gewonnen hat, sind das Speichern einiger Lesevorgänge in der Regel nicht die Wartungskosten wert, die für den SQLCLR-Test anfallen. In AdventureWorks2012 hat die ProductTabelle jedoch nur 504 Zeilen und TransactionHistorynur 113.443 Zeilen. Der Leistungsunterschied zwischen diesen Methoden wird wahrscheinlich größer, wenn die Zeilenanzahl zunimmt.

  2. Diese Frage war zwar spezifisch für das Abrufen eines bestimmten Satzes von Zeilen, es sollte jedoch nicht übersehen werden, dass der einzige Hauptfaktor für die Leistung die Indizierung und nicht das bestimmte SQL war. Ein guter Index muss vorhanden sein, bevor die wirklich beste Methode ermittelt werden kann.

  3. Die wichtigste Lektion, die hier zu finden ist, handelt nicht von CROSS APPLY vs CTE vs SQLCLR, sondern von TESTING. Geh nicht davon aus Holen Sie sich Ideen von mehreren Personen und testen Sie so viele Szenarien wie möglich.

Solomon Rutzky
quelle
2
Die Gründe für die zusätzlichen logischen Lesevorgänge, die mit apply verbunden sind, finden Sie in meiner Bearbeitung der Antwort von Mikael.
Paul White
18

APPLY TOPoder ROW_NUMBER()? Was kann man noch dazu sagen?

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

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

Die row_number()Abfrage:

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;

Bildbeschreibung hier eingeben

Die apply topVersion:

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

Bildbeschreibung hier eingeben

Der Hauptunterschied zwischen diesen besteht darin, dass apply topFilter im oberen Ausdruck unterhalb des Joins mit verschachtelten Schleifen verwendet werden, bei denen die row_numberVersion nach dem Join gefiltert wird. Das bedeutet, dass mehr Lesevorgänge vorhanden sind, Production.TransactionHistoryals wirklich erforderlich sind.

Wenn es nur eine Möglichkeit gäbe, die Operatoren, die für die Aufzählung der Zeilen verantwortlich sind, vor dem Join in den unteren Zweig zu verschieben, wäre die row_numberVersion möglicherweise besser.

Also apply row_number()Version eingeben .

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

Bildbeschreibung hier eingeben

Wie Sie sehen, apply row_number()ist es so ziemlich das Gleiche wie apply topnur etwas komplizierter. Die Ausführungszeit ist ungefähr gleich oder etwas langsamer.

Warum habe ich mir dann die Mühe gemacht, eine Antwort zu finden, die nicht besser ist als die, die wir bereits haben? Nun, Sie müssen noch eine Sache in der realen Welt ausprobieren, und es gibt tatsächlich einen Unterschied bei den Lesarten. 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 auch eine zweite row_number()Version einwerfen , die in bestimmten Fällen der richtige Weg sein könnte. In bestimmten Fällen müssen Sie davon ausgehen, dass Sie die meisten Zeilen benötigen, Production.TransactionHistoryda hier eine Zusammenführungsverknüpfung zwischen Production.Productund der Aufzählung erfolgt 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;

Bildbeschreibung hier eingeben

Um die obige Form ohne einen Sortieroperator zu erhalten, müssen Sie auch den Hilfsindex in TransactionDateabsteigender Reihenfolge ändern .

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

* Bearbeiten: Die zusätzlichen logischen Lesevorgänge sind auf das Prefetching von 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. Das Vorabrufen könnte unter den richtigen Umständen ein Vorteil der Option "Top anwenden" sein. - Paul White

Mikael Eriksson
quelle
11

Normalerweise verwende ich eine Kombination aus CTEs und Fensterfunktionen. Sie können diese Antwort folgendermaßen 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. Sagen wir mit geografischen Kriterien wie Zustand:

+-------+-----------+
| 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 Statustabelle 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
Kris Gruttemeyer
quelle