Unique-Constraints und Performance

leave a comment »

Ein Unique-Constraint wird in einer Tabelle definiert, wenn in einer Spalte nur eindeutige Werte (inklusive NULL) möglich sind. Das ist der einzige Constraint, der durch eine Index-Erstellung umgesetzt wird. Um einen Unique-Constraint zu erstellen, muss man einen UNIQUE Index anlegen. Constraints sind in erster Linie eine Garantie für Datenintegrität. Man vergisst aber oft, dass diese auch den SQL Server Optimizer bei der Ausführungsplan-Generierung unterstützen.

Code-Beispiel

Als Beispiel erstellen wir eine Tabelle (dbo.OrderTest). Die Tabelle hat nur drei Spalten, die für unser Problem ausreichen:

--Tabelle OrderTest
IF OBJECT_ID('dbo.OrderTest') IS NOT NULL DROP TABLE dbo.OrderTest
GO
CREATE TABLE dbo.OrderTest(
order_id int NOT NULL,
order_date date NOT NULL,
status_id tinyint NOT NULL
CONSTRAINT PK_OrderTest PRIMARY KEY CLUSTERED(order_id)
)
GO

Mit diesem Skript füllen wir die Tabelle mit Order-Details. Nach der Ausführung befinden sich in der Tabelle eine Million Orders, mit dem Datum zwischen dem 1.1.2007 und 31.12.2010 und einer Zahl zwischen 1 und 5 als Status ID.

Aufgabe

Unsere Aufgabe ist einfach: Alle Order-Details anzeigen, wobei die Status ID durch die entsprechenden Bezeichnung ersetzt werden soll.

Diese zusätzliche Anforderung wird mit einer sogenannten Lookup-Tabelle umgesetzt. Erstellen wir nun die Lookup-Tabelle und füllen wir sie mit fünf Einträgen:

--Tabelle StatusLookup anlegen
IF OBJECT_ID('dbo.StatusLookup') IS NOT NULL
DROP TABLE dbo.StatusLookup
GO
CREATE TABLE dbo.StatusLookup(
id tinyint not null,
text varchar(20) not null)
GO
--Tabelle StatusLookup füllen
INSERT INTO dbo.StatusLookup(id, text)
VALUES(1, 'eingelangt'),(2, 'in Bearbeitung'),(3, 'Rückgabe'),(4, 'geschlossen'),(5, 'gelöscht')
GO

Die Tabelle dbo.StatusLookup ist eine ganz einfache Tabelle mit nur fünf Einträgen. Deshalb ist es üblich, dass eine solche Tabelle als Heap Tabelle (ohne Clustered Index) umgesetzt wird.

Ergebnis

Jetzt haben wir beide Tabellen und können die gewünschte Abfrage erstellen:


SELECT o.order_id,o.order_date,
(SELECT s.text FROM dbo.StatusLookup s WHERE s.id=o.status_id) status
FROM dbo.OrderTest o

Wir listen die ID und das Datum des Orders aus und fügen die Statusbezeichnung mit einer Subquery hinzu. Auf meinem Notebook ist die Ausführungszeit für diese Abfrage 10.9 Sekunden. Hier sind die Ausführungsinformationen bzw. der Ausführungsplan:

Ergebnisanalyse

Was kann man hier sehen? Die Tabelle dbo.StatusLookup wurde 1.000.000 mal gescannt! Auf jede Zeile aus der OrdeTest Tabelle wurden drei Operatoren angewandt (Table Scan, Stream Aggregate und Assert). Warum?

Eine Subquery kann nur einen skalaren Wert liefern. Deshalb muss der SQL Server überprüfen, ob die innere Abfrage für jede äußere Zeile nur einen Datensatz zurückliefert. Falls es mehrere Datensätze gibt, wird ein Fehler ausgelöst. Mit den Operatoren Table Scan und Stream Aggregate wird die Anzahl von inneren Datensätzen festgelegt und dann der Assert-Operator überprüft dann, ob diese Anzahl größer als 1 ist. Wie gesagt, wird das für jede Zeile durchgeführt – d.h. 1 Mio Ausführungen!

Diese Überprüfung limitiert den SQL Optimizer bei der Auswahl der Join-Strategie und somit bleibt ihm nur Nested Loops Join als einzige Option. Diese Option ist aber ineffizient, wenn die äußere Tabelle zu groß ist. Die Tabelle dbo.StatusLookup enthält nur fünf Zeilen, ohne ID-Duplikate. Aber Duplikate sind nicht explizit verboten. Daher muss immer überprüft werden, ob es zwei Zeilen mit gleicher ID gibt. Bei der Tabellenerstellung haben wir nicht gesagt, dass ein Duplikat nicht möglich ist.

So, was können wir hier tun? Dem SQL Server Optimizer sagen, dass er diese Überprüfung nicht machen muss! Das sagen wir mit einem Unique Constraint. Erstellen wir noch eine Lookup-Tabelle:

Unique Constraint als Performanceverbesserungsfaktor

IF OBJECT_ID('dbo.StatusLookup2') IS NOT NULL DROP TABLE dbo.StatusLookup2
GO
CREATE TABLE dbo.StatusLookup2(
id tinyint not null,
text varchar(20) not null)
GO
CREATE UNIQUE INDEX ix_StatusLookup2_id on dbo.StatusLookup2(id)
go
INSERT INTO dbo.StatusLookup2(id, text)
VALUES(1, 'eingelangt'),(2, 'in Bearbeitung'),(3, 'Rückgabe'),(4, 'geschlossen'),(5, 'gelöscht')
GO

Diesmal haben wir einen Unique Index hinzugefügt und damit die Werte für die Spalte id beschränkt. Führen wir unsere Abfrage erneut aus – diesmal mit der neuen Lookup-Tabelle:

SELECT o.order_id,o.order_date,
(SELECT s.text FROM dbo.StatusLookup2 s WHERE s.id=o.status_id) status
FROM dbo.OrderTest o

Schauen wir jetzt die Ausführungsinfo an:

Analyse

Die Ausführungszeit ist um 2 Sekunden reduziert und die CPU Zeit von 9.079 auf 749 ms! Anhand des Plans können wir sehen, dass der Optimizer aufgrund des hinzugefügten Unique Constraint nicht mehr Duplikatten in der Lookup-Tabelle überprüft und dass er finden konnte, dass diese Subquery-Abfrage äquivalent zu einem Left Join ist. Deshalb hat er einen optimalen Plan generiert, bei dem die Tabelle dbo.StatusLookup2 nur einmal gelesen werden musste! Wenn wir diese zwei Pläne im SQL Server Management Studio direkt miteinander vergleichen, sehen wir, dass die Version mit dem Unique Constraint nur 8% der gesamten Ressourcen verwendet – d.h. ganz grob gesagt ist dieser Plan mehr als 10 mal besser als die Nested Loop Join Variante.

So, eine Kleinigkeit, die man oft vergißt, da Lookup-Tabellen klein sind und Indizierung dort keine Rolle spielt.

Nun kann man sich fragen, warum die Ausführungszeit mit dem zweiten Plan “nur” zwei Sekunden besser ist, wenn der Plan selbst von SSMS als 10-mal besser bezeichnet wurde?

Abfrageperformance Vergleichen

Die Ergebnisliste wird zunächst generiert und dann noch dem Client (SSMS) gesendet. Die gesamte Zeit ist in diesem Fall keine gute Auswahl für einen Performance-Vergleich, da dem Client 1 Mio Zeilen_gesendet werden sollen. Wenn wir hingegen die CPU-Zeiten vergleichen (9.079 ms vs. 749 ms), bekommen wir ein realistisches Bild. Wenn wir aber die zweite Zeitkomponente annullieren möchten, können wir die Abfragen im SSMS so ausführen, dass die Ergebnisse nicht dem Client gesendet werden. Das können wir mit der Option “Discards results after execution” im Screen “Query/Query Options/Results/Grid” einstellen.

Wenn wir beide Abfragen mit dieser Option ausführen, bekommen wir für die Ausführungszeit 3.613 ms bzw. 573 ms.

Fazit

Eine Lookup-Tabelle kann freilich als Heap-Tabelle implementiert werden, aber dann darf nicht auf ein Unique Constraint auf die ID-Spalte vergessen werden. Und ganz generell: Den SQL Server mit allen relevanten Informationen füttern! Richtige und relevante Informationen können nur zur Generierung eines besseren Ausführungplans führen!

Den Beispiel-Code können Sie hier herunterladen.



Written by Milos R.

5. Mai 2011 um 21:25

Veröffentlicht in Performance

Tagged with , ,

Schreibe einen Kommentar

Trage deine Daten unten ein oder klicke ein Icon um dich einzuloggen:

WordPress.com-Logo

Du kommentierst mit Deinem WordPress.com-Konto. Abmelden / Ändern )

Twitter-Bild

Du kommentierst mit Deinem Twitter-Konto. Abmelden / Ändern )

Facebook-Foto

Du kommentierst mit Deinem Facebook-Konto. Abmelden / Ändern )

Google+ Foto

Du kommentierst mit Deinem Google+-Konto. Abmelden / Ändern )

Verbinde mit %s

%d Bloggern gefällt das: