Database
 sql >> Datenbank >  >> RDS >> Database

Leistungsüberraschungen und Annahmen:Willkürlich TOP 1

In einem aktuellen Thread auf StackExchange hatte ein Benutzer das folgende Problem:

Ich möchte eine Abfrage, die die erste Person in der Tabelle mit einer GroupID =2 zurückgibt. Wenn niemand mit einer GroupID =2 existiert, möchte ich die erste Person mit einer RoleID =2.

Lassen Sie uns vorerst die Tatsache verwerfen, dass „zuerst“ schrecklich definiert ist. In Wirklichkeit war es dem Benutzer egal, welche Person er bekam, ob sie zufällig, willkürlich oder durch eine explizite Logik zusätzlich zu seinen Hauptkriterien kam. Nehmen wir an, Sie haben eine einfache Tabelle:

CREATE TABLE dbo.Users
(
  UserID  INT PRIMARY KEY,
  GroupID INT,
  RoleID  INT
);

In der realen Welt gibt es wahrscheinlich andere Spalten, zusätzliche Einschränkungen, vielleicht Fremdschlüssel zu anderen Tabellen und sicherlich andere Indizes. Aber lassen Sie es uns einfach halten und uns eine Abfrage einfallen lassen.

Wahrscheinliche Lösungen

Mit diesem Tabellendesign scheint die Lösung des Problems einfach zu sein, oder? Der erste Versuch, den Sie wahrscheinlich machen würden, ist:

SELECT TOP (1) UserID, GroupID, RoleID
  FROM dbo.Users
  WHERE GroupID = 2 OR RoleID = 2
  ORDER BY CASE GroupID WHEN 2 THEN 1 ELSE 2 END;

Dies verwendet TOP und ein bedingtes ORDER BY Benutzer mit einer GroupID =2 mit höherer Priorität zu behandeln. Der Plan für diese Abfrage ist ziemlich einfach, wobei die meisten Kosten in einer Sortieroperation anfallen. Hier sind Laufzeitmetriken für eine leere Tabelle:

Das sieht so gut aus, wie Sie es können – ein einfacher Plan, der den Tisch nur einmal scannt, und abgesehen von einer lästigen Sorte, mit der Sie leben können sollten, kein Problem, oder?

Nun, eine andere Antwort im Thread bot diese komplexere Variante an:

SELECT TOP (1) UserID, GroupID, RoleID FROM 
(
  SELECT TOP (1) UserID, GroupID, RoleID, o = 1
  FROM dbo.Users
  WHERE GroupId = 2 
 
  UNION ALL
 
  SELECT TOP (1) UserID, GroupID, RoleID, o = 2
  FROM dbo.Users
  WHERE RoleID = 2
) 
AS x ORDER BY o;

Auf den ersten Blick würden Sie wahrscheinlich denken, dass diese Abfrage extrem ineffizient ist, da sie zwei Clustered-Index-Scans erfordert. Damit haben Sie definitiv Recht; Hier sind die Plan- und Laufzeitmetriken für eine leere Tabelle:

Aber jetzt fügen wir Daten hinzu

Um diese Abfragen zu testen, wollte ich einige realistische Daten verwenden. Also habe ich zuerst 1.000 Zeilen aus sys.all_objects gefüllt, mit Modulo-Operationen gegen die object_id, um eine anständige Verteilung zu erhalten:

INSERT dbo.Users(UserID, GroupID, RoleID)
SELECT TOP (1000) ABS([object_id]), ABS([object_id]) % 7, ABS([object_id]) % 4
FROM sys.all_objects
ORDER BY [object_id]; 
 
SELECT COUNT(*) FROM dbo.Users WHERE GroupID = 2; -- 126
SELECT COUNT(*) FROM dbo.Users WHERE RoleID = 2;  -- 248
SELECT COUNT(*) FROM dbo.Users WHERE GroupID = 2 AND RoleID = 2; -- 26 overlap

Wenn ich jetzt die beiden Abfragen ausführe, sind hier die Laufzeitmetriken:

Die UNION ALL-Version kommt mit etwas weniger E/A (4 Lesevorgänge gegenüber 5), geringerer Dauer und niedrigeren geschätzten Gesamtkosten, während die bedingte ORDER BY-Version niedrigere geschätzte CPU-Kosten hat. Die Daten hier sind ziemlich klein, um irgendwelche Schlussfolgerungen zu ziehen; Ich wollte es nur als Pfahl im Boden. Ändern wir nun die Verteilung so, dass die meisten Zeilen mindestens eines der Kriterien (und manchmal beide) erfüllen:

DROP TABLE dbo.Users;
GO
 
CREATE TABLE dbo.Users
(
  UserID INT PRIMARY KEY,
  GroupID INT,
  RoleID INT
);
GO
 
INSERT dbo.Users(UserID, GroupID, RoleID)
SELECT TOP (1000) ABS([object_id]), ABS([object_id]) % 2 + 1, 
  SUBSTRING(RTRIM([object_id]),7,1) % 2 + 1
FROM sys.all_objects
WHERE ABS([object_id]) > 9999999
ORDER BY [object_id]; 
 
SELECT COUNT(*) FROM dbo.Users WHERE GroupID = 2; -- 500
SELECT COUNT(*) FROM dbo.Users WHERE RoleID = 2;  -- 475
SELECT COUNT(*) FROM dbo.Users WHERE GroupID = 2 AND RoleID = 2; -- 221 overlap

Dieses Mal hat die bedingte Bestellung nach die höchsten geschätzten Kosten sowohl in CPU als auch in E/A:

Aber noch einmal, bei dieser Datengröße gibt es relativ unbedeutende Auswirkungen auf die Dauer und die Lesevorgänge, und abgesehen von den geschätzten Kosten (die sowieso größtenteils gedeckt sind) ist es schwierig, hier einen Gewinner zu nennen.

Also fügen wir noch viel mehr Daten hinzu

Obwohl ich lieber Beispieldaten aus den Katalogansichten erstelle, da jeder diese hat, werde ich dieses Mal auf die Tabelle Sales.SalesOrderHeaderEnlarged von AdventureWorks2012 zurückgreifen, die mit diesem Skript von Jonathan Kehayias erweitert wurde. Auf meinem System hat diese Tabelle 1.258.600 Zeilen. Das folgende Skript fügt eine Million dieser Zeilen in unsere dbo.Users-Tabelle ein:

-- DROP and CREATE, as before
 
INSERT dbo.Users(UserID, GroupID, RoleID)
SELECT TOP (1000000) SalesOrderID, SalesOrderID % 7, SalesOrderID % 4
FROM Sales.SalesOrderHeaderEnlarged;
 
SELECT COUNT(*) FROM dbo.Users WHERE GroupID = 2; -- 142,857
SELECT COUNT(*) FROM dbo.Users WHERE RoleID = 2;  -- 250,000
SELECT COUNT(*) FROM dbo.Users WHERE GroupID = 2 AND RoleID = 2; -- 35,714 overlap

Okay, wenn wir jetzt die Abfragen ausführen, sehen wir ein Problem:Die ORDER BY-Variante ist parallel gelaufen und hat sowohl Lesevorgänge als auch CPU ausgelöscht, was zu einem fast 120-fachen Unterschied in der Dauer führt:

Das Eliminieren der Parallelität (mithilfe von MAXDOP) hat nicht geholfen:

(Der Plan UNION ALL sieht immer noch gleich aus.)

Und wenn wir die Schiefe so ändern, dass sie gerade ist, wobei 95 % der Zeilen mindestens ein Kriterium erfüllen:

-- DROP and CREATE, as before
 
INSERT dbo.Users(UserID, GroupID, RoleID)
SELECT TOP (475000) SalesOrderID, 2, SalesOrderID % 7
FROM Sales.SalesOrderHeaderEnlarged
WHERE SalesOrderID % 2 = 1
UNION ALL
SELECT TOP (475000) SalesOrderID, SalesOrderID % 7, 2
FROM Sales.SalesOrderHeaderEnlarged
WHERE SalesOrderID % 2 = 0;
 
INSERT dbo.Users(UserID, GroupID, RoleID)
SELECT TOP (50000) SalesOrderID, 1, 1
FROM Sales.SalesOrderHeaderEnlarged AS h
WHERE NOT EXISTS (SELECT 1 FROM dbo.Users
  WHERE UserID = h.SalesOrderID);
 
SELECT COUNT(*) FROM dbo.Users WHERE GroupID = 2; -- 542,851
SELECT COUNT(*) FROM dbo.Users WHERE RoleID = 2;  -- 542,851
SELECT COUNT(*) FROM dbo.Users WHERE GroupID = 2 AND RoleID = 2; -- 135,702 overlap

Die Abfragen zeigen immer noch, dass die Sortierung unerschwinglich teuer ist:

Und mit MAXDOP =1 war es viel schlimmer (siehe Dauer):

Wie wäre es schließlich mit 95 % Schräglage in beide Richtungen (z. B. die meisten Zeilen erfüllen die GroupID-Kriterien oder die meisten Zeilen die RoleID-Kriterien)? Dieses Skript stellt sicher, dass mindestens 95 % der Daten GroupID =2:

haben
-- DROP and CREATE, as before
 
INSERT dbo.Users(UserID, GroupID, RoleID)
SELECT TOP (950000) SalesOrderID, 2, SalesOrderID % 7
FROM Sales.SalesOrderHeaderEnlarged;
 
INSERT dbo.Users(UserID, GroupID, RoleID)
SELECT TOP (50000) SalesOrderID, SalesOrderID % 7, 2
FROM Sales.SalesOrderHeaderEnlarged AS h
WHERE NOT EXISTS (SELECT 1 FROM dbo.Users
  WHERE UserID = h.SalesOrderID);
 
SELECT COUNT(*) FROM dbo.Users WHERE GroupID = 2; -- 957,143
SELECT COUNT(*) FROM dbo.Users WHERE RoleID = 2;  -- 185,714
SELECT COUNT(*) FROM dbo.Users WHERE GroupID = 2 AND RoleID = 2; -- 142,857 overlap

Die Ergebnisse sind ziemlich ähnlich (ich werde von nun an aufhören, das MAXDOP-Ding auszuprobieren):

Und wenn wir es in die andere Richtung drehen, wo mindestens 95 % der Daten RoleID =2 haben:

-- DROP and CREATE, as before
 
INSERT dbo.Users(UserID, GroupID, RoleID)
SELECT TOP (950000) SalesOrderID, 2, SalesOrderID % 7
FROM Sales.SalesOrderHeaderEnlarged;
 
INSERT dbo.Users(UserID, GroupID, RoleID)
SELECT TOP (50000) SalesOrderID, SalesOrderID % 7, 2
FROM Sales.SalesOrderHeaderEnlarged AS h
WHERE NOT EXISTS (SELECT 1 FROM dbo.Users
  WHERE UserID = h.SalesOrderID);
 
SELECT COUNT(*) FROM dbo.Users WHERE GroupID = 2; -- 185,714
SELECT COUNT(*) FROM dbo.Users WHERE RoleID = 2;  -- 957,143
SELECT COUNT(*) FROM dbo.Users WHERE GroupID = 2 AND RoleID = 2; -- 142,857 overlap

Ergebnisse:

Schlussfolgerung

In keinem einzigen Fall, den ich herstellen konnte, übertraf die „einfachere“ ORDER BY-Abfrage – sogar mit einem Clustered-Index-Scan weniger – die komplexere UNION ALL-Abfrage. Manchmal müssen Sie sehr vorsichtig sein, was SQL Server tun muss, wenn Sie Operationen wie Sortierungen in Ihre Abfragesemantik einführen, und sich nicht allein auf die Einfachheit des Plans verlassen (ganz zu schweigen von etwaigen Vorurteilen, die Sie aufgrund früherer Szenarien haben könnten). P>

Ihr erster Instinkt mag oft richtig sein, aber ich wette, es gibt Zeiten, in denen es eine bessere Option gibt, die oberflächlich betrachtet so aussieht, als könnte sie unmöglich besser funktionieren. Wie in diesem Beispiel. Ich werde ein bisschen besser darin, Annahmen zu hinterfragen, die ich aus Beobachtungen gemacht habe, und keine pauschalen Aussagen wie „Scans funktionieren nie gut“ und „einfachere Abfragen laufen immer schneller“ zu machen. Wenn Sie die Wörter niemals und immer aus Ihrem Vokabular streichen, stellen Sie möglicherweise mehr dieser Annahmen und pauschalen Aussagen auf die Probe und sind am Ende viel besser dran.