Query Performance & Ausführungspläne:
Was der Optimizer denkt — und warum er manchmal irrt
Du hast schon mal eine Query gehabt, die gestern noch in 50 ms lief und heute 40 Sekunden braucht. Alles ist gleich geblieben — gleiche Daten, gleicher Server, gleiche Abfrage. Was ist passiert? Willkommen in der Welt der Ausführungspläne, wo SQL Server jeden Morgen neu entscheidet, wie er deine Queries ausführt — und manchmal einfach falsch liegt.
Dieses Kapitel ist das Herzstück der Query-Diagnose. Wir schauen uns an, wie der Query Optimizer arbeitet, wie man Ausführungspläne liest (und dabei keine falschen Schlüsse zieht), warum manche Join-Typen deine CPU in Brand setzen, und wie du mit konkreten Werkzeugen die teuersten Queries deines Systems aufspürst. CPU als Diagnoseachse zieht sich durch das gesamte Kapitel — denn die meisten Query-Probleme enden irgendwann als CPU-Problem.
Querverweise vorab: Die Wait Statistics aus Kapitel 9 — insbesondere CXPACKET und SOS_SCHEDULER_YIELD — sind eng mit dem verknüpft, was wir hier sehen werden. Die MAXDOP-Konfiguration aus Kapitel 5 bestimmt, wie aggressiv SQL Server parallelisiert. Und was wir hier über Ausführungspläne lernen, ist die Grundlage für Kapitel 16 (Statistiken und Plan Regression) und Kapitel 18 (Parameter Sniffing). Alles hängt zusammen — dieses Kapitel ist der Knotenpunkt.
Der Query Optimizer: Guter Ratgeber mit schlechten Informationen
Bevor wir Ausführungspläne lesen können, müssen wir verstehen, wer sie produziert. Der Query Optimizer ist ein Cost-Based Optimizer — er berechnet für jede denkbare Art, eine Query auszuführen, geschätzte Kosten und wählt den günstigsten Plan. "Kosten" bedeutet dabei eine abstrakte Einheit aus CPU-Aufwand, IO-Operationen und Speicherbedarf, keine Millisekunden.
Das klingt vernünftig, und für den Normalfall funktioniert es auch gut. Das Problem: Der Optimizer arbeitet mit Schätzungen, nicht mit Gewissheiten. Er kennt die Statistiken deiner Tabellen — ungefähre Verteilungen der Spaltenwerte — aber nicht den exakten Datenbestand zum Zeitpunkt der Abfrage. Sind diese Statistiken veraltet oder ungenau, fällt der Optimizer Entscheidungen auf Basis falscher Annahmen. Und falsche Annahmen führen zu falschen Plänen.
Compiled Plan, Cached Plan, Executed Plan — drei verschiedene Realitäten
Hier lauert eine häufige Verwechslung. SQL Server unterscheidet drei Zustände eines Ausführungsplans:
Der Unterschied zwischen Estimated und Actual ist die wichtigste Information im Ausführungsplan. Eine große Diskrepanz — Optimizer schätzt 10 Zeilen, tatsächlich werden 1.000.000 verarbeitet — ist fast immer ein Signal für veraltete Statistiken. Was damit zu tun ist, erklärt Kapitel 16 in aller Tiefe.
|
Hinweis: Compiled vs. Actual: der Unterschied zählt |
|---|
|
SSMS zeigt standardmäßig den Estimated Plan — der entspricht dem Compiled Plan und enthält keine echten Laufzeitdaten. Erst "Include Actual Execution Plan" (Strg+M) zeigt, was wirklich passiert ist. |
|
Für die Diagnose immer den Actual Plan nehmen. Der Estimated Plan lügt — nicht böswillig, aber systematisch. |
Was der Optimizer nicht kann
Der Optimizer ist kein Wunderkind. Er sieht nicht, ob gerade 200 andere Queries gleichzeitig laufen. Er weiß nicht, wie warm der Buffer Pool ist. Er kennt keine Echtzeit-IO-Latenz. Und er modelliert Korrelationen zwischen Spalten nur begrenzt — wenn zwei Spalten zusammen gefiltert werden und ihre Werte voneinander abhängen (z.B. Stadt und Postleitzahl), unterschätzt er regelmäßig die Selektivität.
Das Ergebnis: Pläne sind immer Approximationen. Meistens gute Approximationen — aber, wenn sie schief liegen, liegen sie oft dramatisch schief. Ein Nested Loop über eine Tabelle mit 10 Millionen Zeilen statt einem Hash Join: Faktor 1000 in der Ausführungszeit ist keine Seltenheit.
Ausführungspläne lesen: Von rechts nach links, von oben nach unten
Der grafische Ausführungsplan in SSMS sieht zunächst einschüchternd aus — eine Kaskade von Icons, verbunden durch Pfeile unterschiedlicher Dicke. Wenn du die Grundregeln kennst, wird er zum wichtigsten Diagnosewerkzeug überhaupt.
Die Grundregeln
Für den Plan als XML: SET STATISTICS XML ON zeigt denselben Plan als XML-Dokument — wichtig für automatisierte Analyse oder, wenn SSMS nicht verfügbar ist.
Die wichtigsten Plan-Operatoren
|
Operator |
Was er tut |
Wann er auftritt |
Warnsignal |
|---|---|---|---|
|
Table Scan |
Liest gesamte Tabelle ohne Index (Heap) |
Keine Indizes, oder kein nutzbarer Index vorhanden |
Immer bei großen Tabellen |
|
Index Scan |
Liest gesamten Index (alle Blattseiten) |
Index vorhanden, aber nicht selektiv genug für Seek |
Bei selektiver Filterung |
|
Index Seek |
Springt direkt zur gesuchten Position im Index (B-Tree-Navigation) |
Selektive Filterung mit passendem Index |
Kein Warnsignal — das ist das Ziel |
|
Key Lookup |
Holt fehlende Spalten aus dem Clustered Index nach |
Non-Clustered Index deckt nicht alle benötigten Spalten |
Sehr häufig bei hohem Datenvolumen |
|
Nested Loops |
Iteriert über äußere Menge, sucht für jede Zeile in innerer Menge |
Kleine äußere Menge + Index auf innere Menge |
Wenn äußere Menge groß ist |
|
Hash Join |
Baut Hash-Tabelle aus kleinerer Menge, sondiert mit größerer |
Große Mengen ohne passenden Index |
Wenn Speicher für Build-Phase zu klein |
|
Merge Join |
Führt zwei sortierte Eingaben zusammen |
Beide Seiten bereits sortiert (Index) |
Wenn eine Seite unerwartet sortiert werden muss |
|
Sort |
Sortiert Datenmenge |
ORDER BY, GROUP BY, Merge Join-Vorbereitung |
Wenn Spill in TempDB möglich |
|
Spool |
Speichert Zwischenergebnis für Wiederverwendung |
Korrelierte Subqueries, recursive CTEs |
Lazy Spool mit vielen Rewinds |
Tabelle 15.1: Häufige Plan-Operatoren und ihre Bedeutung
Warnings im Ausführungsplan — was sie wirklich bedeuten
Warnungen im Ausführungsplan sind kein kosmetisches Problem. Sie sind Fehlermeldungen des Optimizers — Hinweise, dass etwas nicht so gelaufen ist wie geplant oder, dass Informationen fehlen. Die häufigsten:
Die drei Join-Typen — und warum sie deine CPU interessieren
Joins sind der CPU-Verbrauch-Treiber Nummer eins in analytischen Workloads. Welchen Join-Typ der Optimizer wählt, bestimmt maßgeblich, ob eine Query in 100 ms oder 10 Minuten fertig ist. Das Entscheidende: Der Optimizer wählt den Join-Typ auf Basis seiner Kardinalitätsschätzungen — und, wenn die falsch sind, wählt er den falschen Typ, mit entsprechenden CPU-Konsequenzen.
Nested Loops: Gut gemeint, oft überschätzt
Der Nested Loop Join iteriert für jede Zeile der äußeren (treibenden) Tabelle durch die innere Tabelle. Die Komplexität ist O(n × m) — wobei n die Größe der äußeren Menge und m die durchschnittliche Lookup-Kosten in der inneren Menge ist. Mit einem Index auf der inneren Tabelle reduziert sich m auf O(log m) pro Lookup.
Nested Loops sind optimal, wenn: die äußere Menge klein ist (wenige hundert oder tausend Zeilen) und die innere Tabelle einen passenden Index hat. Dann ist jeder Lookup günstig, und der Join ist insgesamt sehr effizient.
Das CPU-Problem entsteht, wenn der Optimizer die äußere Menge unterschätzt. Schätzt er 50 Zeilen, wählt er Nested Loops — tatsächlich kommen 500.000 Zeilen. Jetzt werden 500.000 Index-Lookups ausgeführt, die jeweils CPU und IO kosten. Das summiert sich zu einer CPU-Explosion, die im Task Manager wie ein simpler Lastspike aussieht. In den Wait Statistics (Kapitel 9) siehst du dann SOS_SCHEDULER_YIELD — die CPUs sind vollständig gesättigt, die Queries rennen, aber die Wartezeiten steigen trotzdem.
Hash Join: Der Allrounder für große Mengen
Hash Join funktioniert in zwei Phasen: Build Phase (kleinere Tabelle wird als Hash-Tabelle im Arbeitsspeicher aufgebaut) und Probe Phase (größere Tabelle wird durchlaufen, jede Zeile gegen die Hash-Tabelle geprüft). Die Komplexität ist O(n + m) — deutlich besser als Nested Loops bei großen Mengen.
Hash Join braucht einen Memory Grant — SQL Server reserviert Speicher vor der Ausführung für die Build-Phase. Wenn der tatsächliche Speicherbedarf den Grant übersteigt, spilled die Hash-Tabelle nach TempDB. Das ist sehr teuer: Statt RAM-Operationen folgen IO-Operationen auf TempDB, und die Ausführungszeit kann sich ver-10-fachen. Das Warning "Operator used tempdb to spill data" im Ausführungsplan zeigt genau das.
Hash Join ist ohne Vorbedingungen an die Sortierung der Eingaben: Er funktioniert auf unsortierten Mengen. Daher ist er der Standard-Join-Typ, wenn kein passender Index für Merge oder Nested Loops vorhanden ist. Das ist kein Fehler des Optimizers — oft ist Hash Join tatsächlich die beste verfügbare Option.
Merge Join: Der Elegante — aber mit Vorbedingungen
Merge Join führt zwei sortierte Eingaben zusammen, wie beim Mischen von zwei Kartenstapeln. Komplexität O(n + m) — genau wie Hash Join, aber ohne den Speicherbedarf der Build-Phase. Der Haken: Beide Eingaben müssen nach der Join-Spalte sortiert sein.
In der Praxis bedeutet das: Merge Join ist optimal, wenn ein Index auf der Join-Spalte vorhanden ist, der die Daten bereits sortiert liefert. Dann entstehen keine zusätzlichen Sort-Operatoren, der Merge ist günstig, und der Speicherbedarf ist minimal. Ohne passendem Index muss der Optimizer zuerst sortieren — eine teure Operation, die möglicherweise einen Hash Join doch günstiger macht.
|
Join-Typ |
Komplexität |
Vorbedingung |
CPU-Charakteristik |
Speicherbedarf |
Typischer Einsatz |
|---|---|---|---|---|---|
|
Nested Loops |
O(n × m) |
Index auf innerer Tabelle empfohlen |
Niedrig bei kleinen Mengen, Explosion bei falscher Schätzung |
Minimal |
Kleine äußere Menge (< 1.000 Zeilen), OLTP-Punkt-Lookups |
|
Hash Join |
O(n + m) |
Keine — funktioniert immer |
Moderat und vorhersehbar |
Hoch (Build-Phase), Spill möglich |
Große Mengen, kein passender Index, DWH-Abfragen |
|
Merge Join |
O(n + m) |
Beide Seiten sortiert nach Join-Spalte |
Niedrig, wenn Index vorhanden, teuer bei explizitem Sort |
Minimal |
Indizierte Join-Spalten, Range-Scans, geordnete Ausgabe |
Tabelle 15.2: Join-Typen im Vergleich — Komplexität, CPU und Speicher
|
Warnung: Falsche Kardinalitätsschätzung → CPU-Explosion |
|---|
|
Der gefährlichste Fall: Optimizer schätzt 100 Zeilen, wählt Nested Loops. Tatsächlich kommen 1.000.000 Zeilen. Ergebnis: 1.000.000 Index-Lookups, jeder mit CPU- und IO-Kosten. |
|
Im Actual Execution Plan erkennbar an: Estimated Rows 100 vs. Actual Rows 1.000.000 beim Join-Operator. Ursache ist fast immer veraltete Statistik — Kapitel 16 erklärt die Diagnose und Lösung. |
|
In den Wait Statistics (Kapitel 9): Wenn SOS_SCHEDULER_YIELD dominiert und die verdächtige Query läuft, ist das das Muster. CPU ist 100%, aber die Wartezeiten steigen — nicht, weil gewohnt wird, sondern, weil die Arbeit selbst irrsinnig viel CPU kostet. |
Parallelismus: Wenn SQL Server alle CPUs gleichzeitig anfasst
SQL Server kann Queries parallel ausführen — mehrere Threads arbeiten gleichzeitig an verschiedenen Teilen der Query. Das klingt wie eine gute Idee, und für die richtigen Workloads ist es das auch. Für die falschen Workloads ist Parallelismus die schnellste Methode, einen Server in die Knie zu zwingen.
Wie parallele Pläne entstehen
Der Optimizer berechnet für jede Query sowohl einen seriellen als auch einen parallelen Plan. Wenn die geschätzten Kosten des seriellen Plans den Schwellwert "Cost Threshold for Parallelism" überschreiten (Standard: 5 — absurd niedrig, wie wir in Kapitel 5 gesehen haben), wählt der Optimizer den parallelen Plan, sofern MAXDOP > 1 ist.
Der Schwellwert von 5 wurde für Hardware aus den 1990er Jahren kalibriert. Auf moderner Hardware führt er dazu, dass triviale Queries parallelisiert werden, obwohl der Overhead des Parallelismus größer ist als sein Nutzen. Empfehlung aus Kapitel 5: Cost Threshold auf mindestens 50, besser 75-100 setzen.
MAXDOP: Der Regler für den Parallelismus-Grad
MAXDOP (Maximum Degree of Parallelism) begrenzt, wie viele Threads eine einzelne Query nutzen darf. Der Server-weite MAXDOP (sp_configure, Kapitel 5) ist die Obergrenze. Hinzu kommt der Query-Level-MAXDOP per OPTION(MAXDOP n)-Hint, der den Server-Wert für diese eine Query überschreibt.
Auf einem 16-Kern-System mit 2 NUMA-Nodes: MAXDOP = 8 ist ein vernünftiger Ausgangswert — begrenzt auf einen NUMA-Node, vermeidet NUMA-Crossing-Overhead. Die vollständige MAXDOP-Berechnungsformel haben wir in Kapitel 1 (Hardware-Grundlagen) und Kapitel 3 (Virtualisierung) diskutiert.
CXPACKET-Waits: Das Fingerabdruck des Parallelismus
CXPACKET ist der Wait Type, der entsteht, wenn ein Thread auf andere Threads desselben parallelen Plans wartet. Er ist einer der am häufigsten missverstandenen Wait Types überhaupt. Wie wir in Kapitel 9 (Wait Statistics) gesehen haben: CXPACKET allein ist kein Problem. CXPACKET mit hohem Signal Wait-Anteil ist ein ernstes CPU-Problem.
Die Unterscheidung ist kritisch: Wenn eine CPU-intensive Query parallelisiert und alle Threads gleichmäßig ausgelastet sind, entstehen CXPACKET-Waits als normales Synchronisationsartefakt. Wenn hingegen ein Thread fertig ist und auf andere wartet, weil die Arbeitsteilung ungleich ist (Skewed Parallelism), entstehen CXPACKET-Waits aus echter Ineffizienz. Letzteres erkennst du daran, dass die Thread-Zeiten stark variieren.
Die Exchange-Operatoren im parallelen Plan
In einem parallelen Ausführungsplan siehst du Operatoren, die es im seriellen Plan nicht gibt: Repartition Streams, Distribute Streams und Gather Streams. Diese Exchange-Operatoren sind die Synchronisationspunkte zwischen parallelen Threads.
Jeder Exchange-Operator hat Overhead — Synchronisation, Puffer zwischen Threads, Thread-Kontext-Wechsel. Bei kleinen Queries überwiegt dieser Overhead leicht den Nutzen der Parallelisierung. Das ist der Grund, warum OLTP-Workloads mit vielen kleinen Queries besser ohne Parallelismus laufen.
Wann Parallelismus hilft und wann er schadet
|
Szenario |
Parallelismus sinnvoll? |
Begründung |
|---|---|---|
|
Große Aggregation (SUM, COUNT über Millionen Zeilen) |
Ja |
Arbeit lässt sich gut aufteilen, Overhead gering relativ zum Nutzen |
|
Hash Join über zwei große Tabellen |
Ja |
Beide Phasen (Build und Probe) parallelisierbar |
|
Einzelne Zeile nach Primary Key suchen |
Nein |
Overhead des Parallelismus > gesamte Query-Kosten |
|
1.000 kleine OLTP-Queries gleichzeitig |
Nein |
Alle Queries um dieselben Threads konkurrieren, Gesamtdurchsatz sinkt |
|
Reporting-Query auf einer OLTP-Instanz |
Abhängig |
Kann OLTP-Queries ausbremsen — Resource Governor oder MAXDOP-Hint erwägen |
|
Batch-Verarbeitung nachts, Server sonst leer |
Ja |
Maximaler Durchsatz, keine Konkurrenz |
Tabelle 15.3: Parallelismus — wann sinnvoll, wann kontraproduktiv
|
Tipp: MAXDOP-Hint für einzelne Queries |
|---|
|
Wenn eine schwere Reporting-Query regelmäßig CXPACKET-Waits verursacht und OLTP-Queries ausbremst, hilft OPTION(MAXDOP 4) direkt in der Query — ohne den Server-weiten MAXDOP anzutasten. |
|
Umgekehrt: Wenn eine Query nie parallelisiert werden soll (z.B. weil sie nachts läuft und der Server ausgelastet ist), erzwingt OPTION(MAXDOP 1) seriellen Betrieb. |
|
Diese Hints sind eine Überbrückung, keine Dauerlösung. Die richtige Antwort ist oft eine bessere Indexstruktur oder ein angepasster Cost Threshold for Parallelism. |
Key Lookup: Der Preis für unvollständige Indizes
Der Key Lookup ist einer der häufigsten Performance-Killer in der Praxis — und einer, der im Ausführungsplan sofort sichtbar ist. Das Muster: SQL Server findet relevante Zeilen über einen Non-Clustered Index, muss dann aber für jede gefundene Zeile noch einmal in den Clustered Index nachschlagen, um fehlende Spalten zu holen.
Warum Key Lookups entstehen
Stell dir eine Tabelle mit 5 Millionen Kunden vor. Du hast einen Index auf Nachname + Vorname für Suchen. Eine Query sucht nach Kunden aus Berlin und gibt Nachname, Vorname, E-Mail und Telefonnummer aus. Der Index auf Nachname + Vorname ist für die Suche optimal — aber er enthält keine E-Mail und keine Telefonnummer. Also: Index Seek findet die passenden Zeilen, dann Key Lookup für jede Zeile, um die fehlenden Spalten aus dem Clustered Index zu holen.
Für 10 gefundene Zeilen: 10 Key Lookups — harmlos. Für 100.000 gefundene Zeilen: 100.000 Key Lookups — jeder ein einzelner B-Tree-Zugriff auf den Clustered Index. Das können Millionen von logischen Reads sein, aus einem einzigen Query.
Das Praxisbeispiel mit T-SQL
-- Tabelle: Kunden (5 Mio. Zeilen)
-- Index: IX_Kunden_Name auf (Nachname, Vorname)
-- Clustered: PK_Kunden auf KundenID
-- Diese Query produziert Key Lookups:
-- Index Seek findet Zeilen nach Name,
-- Key Lookup holt Email und Telefon aus dem Clustered Index nach.
SELECT Nachname, Vorname, Email, Telefon
FROM Kunden
WHERE Stadt = N'Berlin';
-- Im Ausführungsplan sichtbar:
-- [Index Seek] --> (Key Lookup) --> [SELECT]
-- Key Lookup trägt 80% der geschätzten Kosten.
-- Lösung: Covering Index mit INCLUDE für die fehlenden Spalten.
-- Die INCLUDE-Spalten werden nur auf Blattebene gespeichert,
-- nicht in den inneren B-Tree-Knoten — schlanker Index,
-- aber vollständige Abdeckung der Query.
CREATE INDEX IX_Kunden_Stadt_Covering
ON Kunden (Stadt)
INCLUDE (Nachname, Vorname, Email, Telefon);
-- Jetzt: Index Seek reicht aus, kein Key Lookup mehr.
-- Logische Reads: von ~500.000 auf ~2.000 gesunken.
Nach dem Erstellen des Covering Index verschwindet der Key Lookup aus dem Plan. Die Logischen Reads sinken typischerweise um 90-99%. Ausführliche Erklärungen zu Covering Indizes, INCLUDE-Klausel und Index-Design findest du in Kapitel 17 (Index-Strategie & Wartung).
|
Hinweis: Ab wann ist ein Key Lookup ein Problem? |
|---|
|
Faustregel: Wenn der Key Lookup mehr als 1% der gefilterten Zeilen betrifft und die Query häufig ausgeführt wird, ist ein Covering Index eine Überlegung wert. |
|
Im Ausführungsplan: Wenn der Key Lookup-Operator > 30% der geschätzten Kosten trägt, sollte er als erstes betrachtet werden. |
|
Missing Index Hint im Plan prüfen: SQL Server zeigt oft direkt im grafischen Plan an, welcher Index helfen würde. Diese Hinweise sind nicht immer optimal, aber ein guter Ausgangspunkt. |
Implicit Conversion: Der stille Killer
Es gibt Probleme, die laut auftreten und sofort sichtbar sind. Und dann gibt es Implicit Conversion — das Problem, das leise im Hintergrund einen Index nach dem anderen außer Kraft setzt, ohne, dass jemand bemerkt warum die Queries plötzlich langsam sind.
Was Implicit Conversion ist
Implicit Conversion entsteht, wenn SQL Server einen Datentyp-Mismatch zwischen einem Filterprädikat und der gefilterten Spalte ausgleichen muss. Beispiel: Eine Spalte ist als VARCHAR(100) definiert, das Filterprädikat übergibt NVARCHAR. SQL Server muss jetzt entweder den Parameter konvertieren (gut — einmal) oder die Spalte konvertieren (schlecht — für jede Zeile in der Tabelle).
Das Problem: In vielen Fällen konvertiert SQL Server die Spalte, nicht den Parameter. Das bedeutet: Für jede Zeile in der Tabelle wird die Konvertierung berechnet, bevor der Filter angewendet werden kann. Ein Index auf dieser Spalte ist nun nutzlos — ein Index Seek setzt voraus, dass die Werte im Index direkt mit dem Prädikat vergleichbar sind. Stattdessen: vollständiger Index Scan oder schlimmer noch Table Scan.
Die häufigsten Fälle
|
Spaltentyp |
Parametertyp |
Ergebnis |
Typische Ursache |
|---|---|---|---|
|
VARCHAR |
NVARCHAR |
Index Scan statt Index Seek |
ORM übergibt immer NVARCHAR; .NET strings sind Unicode |
|
INT |
BIGINT oder VARCHAR |
Index Scan |
Applikation übergibt Wert als String oder mit falschem Typ |
|
DATE |
DATETIME oder DATETIME2 |
Keine Konvertierung nötig — aber Achtung bei DATETIME2 vs. DATETIME |
Datumsberechnungen mit falscher Präzision |
|
DECIMAL(10,2) |
FLOAT |
Präzisionsverlust möglich, Index oft weiter nutzbar |
Berechnungen in der Applikation mit FLOAT-Arithmetik |
|
CHAR(10) |
VARCHAR(20) |
Meist kein Problem, aber Längenunterschiede prüfen |
Altes Schema mit CHAR, moderne App mit VARCHAR |
Tabelle 15.4: Häufige Implicit Conversion-Muster
Trendforge Digital GmbH: ORM und die NVARCHAR-Falle
Trendforge Digital hat eine sauber konfigurierte Instanz — gute Hardware, korrekte MAXDOP-Einstellungen, durchdachte Indexstrategien. Und trotzdem schleichen sich in der Produktion immer wieder Performance-Probleme ein. Das Diagnosemuster bei Trendforge in Kapitel 34: Im Ausführungsplan taucht regelmäßig das Warning "Type conversion in expression may affect CardinalityEstimate" auf, verbunden mit Index Scans auf einer 50-Millionen-Zeilen-Tabelle (Kundentransaktionen).
Der Übeltäter: Entity Framework übergibt String-Parameter standardmäßig als NVARCHAR(4000). Die Spalte KundenNr ist VARCHAR(20). SQL Server kann den Index auf KundenNr nicht per Seek nutzen — und scannt stattdessen die gesamte Tabelle. Mit 50 Millionen Zeilen dauert das im schlechtesten Fall 8-12 Sekunden statt 0,3 ms per Seek. Faktor 30.000.
Die Lösung bei Trendforge war auf zwei Ebenen: Einerseits explizite Typ-Annotationen im ORM (CHAR/VARCHAR statt Unicode für Legacy-Spalten), andererseits mittelfristig eine Datenbankschema-Bereinigung mit konsequentem NVARCHAR für alle Textspalten — damit die ORM-Defaults stimmen. Mehr zu ORM-Mustern und ihren Auswirkungen folgt in Kapitel 30.
-- Implicit Conversion erkennen: Suche nach Warnings im Actual Execution Plan
-- oder direkt per DMV:
-- Queries mit Implicit Conversion im Plan Cache finden.
-- plan_text enthält "CONVERT_IMPLICIT", wenn SQL Server
-- eine implizite Typkonvertierung vornimmt.
SELECT TOP 20
qs.execution_count,
qs.total_worker_time / qs.execution_count AS avg_cpu_us,
qs.total_logical_reads / qs.execution_count AS avg_reads,
SUBSTRING(st.text, (qs.statement_start_offset/2)+1,
((CASE qs.statement_end_offset
WHEN -1 THEN DATALENGTH(st.text)
ELSE qs.statement_end_offset
END - qs.statement_start_offset)/2)+1) AS query_text,
qp.query_plan
FROM sys.dm_exec_query_stats qs
CROSS APPLY sys.dm_exec_sql_text(qs.sql_handle) st
CROSS APPLY sys.dm_exec_query_plan(qs.plan_handle) qp
-- CONVERT_IMPLICIT im XML-Plan ist das Zeichen für implizite Konvertierung
WHERE CAST(qp.query_plan AS NVARCHAR(MAX)) LIKE N'%CONVERT_IMPLICIT%'
ORDER BY qs.total_logical_reads DESC;
Verweise: Datentypen und ihre Performance-Implikationen behandelt Kapitel 20 (Datenbankdesign & Datentypen) umfassend. ORM-spezifische Muster und wie man Entity Framework dazu bringt, die richtigen Typen zu übergeben, sind Thema in Kapitel 30 (ORM & Applikationsdesign).
sys.dm_exec_query_stats: Die teuersten Queries finden
Wenn der Server langsam ist und du nicht weißt, womit er sich beschäftigt, ist sys.dm_exec_query_stats dein erster Anlaufpunkt. Diese DMV enthält aggregierte Laufzeitstatistiken für alle Queries im Plan Cache: wie oft ausgeführt, wie viel CPU insgesamt, wie viele logische Reads, wie viel Elapsed Time.
Worauf du achtest: Total vs. Average
Der häufigste Fehler bei der DMV-Analyse: Nur nach total_worker_time sortieren und die erste Query als Schuldige behandeln. Das greift zu kurz. Eine Query die 10 Millionen Mal pro Tag ausgeführt wird und jedes Mal 50 µs CPU kostet, erscheint in der total-Ansicht möglicherweise schlimmer als eine Query die einmal täglich läuft und 45 Minuten CPU frisst — obwohl letztere das eigentliche Problem ist.
Die Regel: Immer beide Perspektiven prüfen. total / execution_count = Durchschnitt pro Ausführung. Hohe Gesamt-CPU bei niedrigem Durchschnitt → häufig ausgeführte Query mit Optimierungspotenzial. Hoher Durchschnitt → einzelne teure Query, die direkte Aufmerksamkeit braucht.
-- Top 10 teuerste Queries nach CPU-Gesamtverbrauch.
-- Gibt sowohl Total- als auch Durchschnittswerte aus,
-- damit man nicht in die "total"-Falle tappt.
SELECT TOP 10
qs.execution_count,
-- Gesamte CPU-Zeit in Millisekunden (worker_time ist in Mikrosekunden)
qs.total_worker_time / 1000 AS total_cpu_ms,
-- Durchschnittliche CPU-Zeit pro Ausführung in Millisekunden
qs.total_worker_time / qs.execution_count / 1000 AS avg_cpu_ms,
-- Gesamte logische Reads (Buffer Pool-Zugriffe)
qs.total_logical_reads,
-- Durchschnittliche logische Reads pro Ausführung
qs.total_logical_reads / qs.execution_count AS avg_logical_reads,
-- Gesamte Elapsed Time in Millisekunden
qs.total_elapsed_time / 1000 AS total_elapsed_ms,
-- Durchschnittliche Elapsed Time — bei Parallelismus
-- ist elapsed < cpu_time möglich, weil mehrere Threads parallel arbeiten
qs.total_elapsed_time / qs.execution_count / 1000 AS avg_elapsed_ms,
-- Zeitpunkt der letzten Ausführung
qs.last_execution_time,
-- Query-Text aus dem SQL Handle
SUBSTRING(st.text, (qs.statement_start_offset/2)+1,
((CASE qs.statement_end_offset
WHEN -1 THEN DATALENGTH(st.text)
ELSE qs.statement_end_offset
END - qs.statement_start_offset)/2)+1) AS query_text
FROM sys.dm_exec_query_stats qs
CROSS APPLY sys.dm_exec_sql_text(qs.sql_handle) st
-- Nach Gesamt-CPU sortieren — für den zweiten Blick
-- die Sortierung auf avg_cpu_ms ändern
ORDER BY qs.total_worker_time DESC;
Den Ausführungsplan aus dem Cache holen
Wenn du eine teure Query gefunden hast, willst du ihren Ausführungsplan sehen — nicht den Estimated Plan (der basiert auf den Statistiken zum Zeitpunkt der Kompilierung), sondern den gecachten Plan. Den holst du per sys.dm_exec_query_plan:
-- Ausführungsplan für eine bestimmte Query aus dem Cache holen.
-- plan_handle kommt aus sys.dm_exec_query_stats.
SELECT
qs.execution_count,
qs.total_worker_time / 1000 AS total_cpu_ms,
qs.total_logical_reads,
-- query_plan ist XML — in SSMS klickbar, öffnet grafischen Plan
qp.query_plan,
-- Vollständiger Query-Text
st.text AS full_batch_text
FROM sys.dm_exec_query_stats qs
CROSS APPLY sys.dm_exec_sql_text(qs.sql_handle) st
CROSS APPLY sys.dm_exec_query_plan(qs.plan_handle) qp
WHERE qs.total_worker_time > 5000000 -- Nur Queries mit > 5 Sekunden Gesamt-CPU
ORDER BY qs.total_worker_time DESC;
-- Hinweis: Der Plan aus dem Cache ist der Estimated Plan —
-- nicht der Actual Plan. Für Actual Plan muss die Query
-- mit "Include Actual Execution Plan" in SSMS erneut ausgeführt werden.
|
Tipp: Logical Reads als primäre Messgröße |
|---|
|
Logical Reads (Seitenlesungen aus dem Buffer Pool) sind die wichtigste Kennzahl für Query-Effizienz. Physical Reads (von Disk) hängen vom Cache-Zustand ab und variieren stark. Logical Reads sind deterministisch — gleiche Daten, gleiche Query, gleiche Logical Reads. |
|
Faustregel: Eine Query mit 100.000 Logical Reads pro Ausführung ist verdächtig. Eine mit 10.000.000 ist ein Problem. Eine mit > 100.000.000 ist ein Notfall. |
|
SET STATISTICS IO ON vor der Query zeigt Logical Reads pro Tabelle — unverzichtbar für die detaillierte Diagnose. |
SET STATISTICS IO und SET STATISTICS TIME: Die Lupe für einzelne Queries
Während sys.dm_exec_query_stats einen Überblick über alle Queries gibt, brauchst du für die Tiefenanalyse einer einzelnen Query ein anderes Werkzeug: SET STATISTICS IO und SET STATISTICS TIME. Beide sind in SSMS einfach zu aktivieren und liefern direkt umsetzbare Informationen.
-- STATISTICS IO und TIME für eine einzelne Query aktivieren.
-- Gibt nach der Ausführung im Meldungs-Tab detaillierte
-- IO- und Zeitstatistiken aus.
SET STATISTICS IO ON;
SET STATISTICS TIME ON;
-- Deine Query hier:
SELECT o.OrderID, c.CustomerName, SUM(od.Quantity * od.UnitPrice) AS OrderTotal
FROM Orders o
JOIN Customers c ON o.CustomerID = c.CustomerID
JOIN OrderDetails od ON o.OrderID = od.OrderID
WHERE o.OrderDate >= '2024-01-01'
GROUP BY o.OrderID, c.CustomerName;
SET STATISTICS IO OFF;
SET STATISTICS TIME OFF;
Die Ausgabe im SSMS-Meldungs-Tab zeigt für jede beteiligte Tabelle:
Bei SET STATISTICS TIME:
Der Unterschied zwischen CPU time und elapsed time verrät dir mehr als beide Werte einzeln: Wenn CPU time >> elapsed time ist, läuft die Query parallel (mehrere Threads arbeiten gleichzeitig). Wenn elapsed time >> CPU time ist, wartet die Query auf etwas — IO, Locks, Netzwerk. In letzterem Fall sind die Wait Statistics (Kapitel 9) der nächste Schritt.
Musterwerk GmbH: vorher/nachher mit STATISTICS IO
Bei Musterwerk GmbH (Instanz MWSQL01) haben wir eine Produktionsquery analysiert, die regelmäßig für Beschwerden vom Vertriebsteam sorgte — Bestellübersicht lädt zu langsam. STATISTICS IO hat sofort gezeigt:
-- Vorher: Query ohne Covering Index
-- Table 'Orders': Scan count 1, logical reads 45.280
-- Table 'OrderDetails': Scan count 1, logical reads 312.441
-- Table 'Customers': Scan count 1, logical reads 18.923
-- CPU time = 2.847 ms, elapsed time = 3.102 ms
-- Nachher: Mit Covering Index auf OrderDetails(OrderID)
-- INCLUDE (Quantity, UnitPrice)
-- Table 'Orders': Scan count 1, logical reads 812
-- Table 'OrderDetails': Scan count 1, logical reads 1.243
-- Table 'Customers': Scan count 1, logical reads 18.923
-- CPU time = 203 ms, elapsed time = 241 ms
-- Ergebnis: 93% weniger Logical Reads auf OrderDetails,
-- CPU-Zeit von 2,8 Sekunden auf 0,2 Sekunden.
-- Der Index auf Customers wäre der nächste Schritt.
Plan Forcing: Wenn der Optimizer mit Nachdruck überzeugt werden muss
Es gibt Situationen, in denen du weißt, welcher Plan korrekt ist — und der Optimizer trotzdem stur einen schlechteren Plan wählt. Manchmal liegt das an veralteten Statistiken (Kapitel 16), manchmal an Parameter Sniffing (Kapitel 18), manchmal an echten Optimizer-Schwächen bei komplexen Queries. In diesen Fällen gibt es Plan Forcing.
Plan Forcing ist eine Notlösung, keine Dauerstrategie. Wenn du einen Plan forcen musst, hast du eine Ursache, die du noch nicht behoben hast. Behandle Plan Forcing als temporäres Pflaster — und dokumentiere, warum du es eingesetzt hast.
USE HINT: Der sanfte Weg
Bevor du einen konkreten Plan erzwingst, probiere USE HINT. Diese Hints erlauben dir, einzelne Optimizer-Verhaltensweisen zu steuern, ohne einen festen Plan zu binden:
-- USE HINT-Beispiele für gezielte Optimizer-Beeinflussung
-- Legacy Cardinality Estimator erzwingen (SQL 2012-Verhalten)
-- Einsatz: Wenn eine Query nach Upgrade auf SQL 2016+ plötzlich langsamer ist
-- und du Zeit brauchst, das eigentliche Problem zu beheben.
SELECT * FROM Orders WHERE CustomerID = 1234
OPTION (USE HINT('FORCE_LEGACY_CARDINALITY_ESTIMATION'));
-- Parallelen Plan verhindern (entspricht MAXDOP 1 für diese Query)
SELECT * FROM Orders WHERE CustomerID = 1234
OPTION (USE HINT('DISABLE_OPTIMIZED_PLAN_FORCING'));
-- Hash Join erzwingen — wenn du weißt, dass Nested Loops falsch ist
SELECT o.OrderID, c.CustomerName
FROM Orders o
INNER HASH JOIN Customers c ON o.CustomerID = c.CustomerID;
-- HASH JOIN als Join-Hint direkt an der JOIN-Klausel
Query Store für Plan Forcing: Der empfohlene Weg
Der richtige Weg für dauerhaftes Plan Forcing ist der Query Store — er erlaubt Plan Forcing per GUI oder T-SQL, ohne den Query-Code zu ändern, und dokumentiert automatisch welcher Plan erzwungen wird. Kapitel 19 (Query Store) behandelt Plan Forcing im Detail, inklusive Automatic Tuning als Erweiterung.
-- Plan Forcing über den Query Store.
-- Voraussetzung: Query Store ist aktiv (ALTER DATABASE SET QUERY_STORE = ON)
-- Schritt 1: Query ID und Plan ID im Query Store finden
SELECT
q.query_id,
p.plan_id,
rs.avg_duration / 1000 AS avg_dauer_ms,
rs.count_executions,
qt.query_sql_text
FROM sys.query_store_query q
JOIN sys.query_store_query_text qt ON q.query_text_id = qt.query_text_id
JOIN sys.query_store_plan p ON q.query_id = p.query_id
JOIN sys.query_store_runtime_stats rs ON p.plan_id = rs.plan_id
ORDER BY rs.avg_duration DESC;
-- Schritt 2: Guten Plan für diese Query erzwingen.
-- plan_id ist die ID des Plans, der gut gelaufen ist.
EXEC sp_query_store_force_plan
@query_id = 42, -- Query ID aus dem Query Store
@plan_id = 17; -- Plan ID des gewünschten Plans
-- Plan Forcing aufheben, wenn Problem behoben:
EXEC sp_query_store_unforce_plan
@query_id = 42,
@plan_id = 17;
Diagnose: Query Performance in der Praxis
|
Warnung: Symptome: Woran erkennst du Query-Performance-Probleme? |
|---|
|
Nutzer berichten über sporadisch langsame Abfragen — nicht immer, aber immer öfter. |
|
Im Wait Statistics Dashboard (Kapitel 9) dominieren CXPACKET in Kombination mit hohen Signal Waits → parallele Queries sättigen die CPUs. |
|
sys.dm_exec_query_stats zeigt eine Handvoll Queries mit exorbitant hohem total_worker_time — 80% der CPU-Last kommen von 3 Queries. |
|
Ausführungsplan-Warnings: "Type conversion" oder "No statistics for column" in mehreren Plänen im Cache. |
|
Key Lookup Operatoren mit > 50% der Plankosten in häufig ausgeführten Queries. |
|
Plötzlicher Anstieg der Ausführungszeit ohne Datenvolumenänderung → Plan Regression (Kapitel 16). |
|
Tipp: So misst du das: Top-10-Query-Analyse |
|---|
|
Schritt 1: Teuerste Queries nach CPU identifizieren (sys.dm_exec_query_stats mit total_worker_time-Sortierung). |
|
Schritt 2: Für verdächtige Queries den Actual Execution Plan in SSMS abrufen (Query mit Strg+M ausführen). |
|
Schritt 3: Plan auf Warnings prüfen (gelbe Ausrufezeichen an Operatoren). |
|
Schritt 4: Estimated Rows vs. Actual Rows vergleichen — große Diskrepanzen zeigen Statistikprobleme. |
|
Schritt 5: SET STATISTICS IO ON für detaillierte Logical Reads pro Tabelle. |
|
Code: Die vollständige Top-10-Query mit Plan-Abruf steht weiter oben in diesem Kapitel — direkt einsetzbar. |
|
Hintergrund: Typische Fehlinterpretationen im Ausführungsplan |
|---|
|
"Estimated = 1, Actual = 1.000.000 ist ein Bug" — Nein. Das ist eine veraltete Statistik. SQL Server hat mit den verfügbaren Informationen eine Schätzung getroffen. Kapitel 16 erklärt wie man Statistiken aktuell hält und wann der Cardinality Estimator systematisch falsch schätzt. |
|
"Parallelismus ist immer besser" — Falsch. Für OLTP-Queries mit kleinen Datenmengen kostet Parallelismus mehr als er spart. CXPACKET im Wait Stats bedeutet nicht automatisch "MAXDOP erhöhen" — manchmal bedeutet es genau das Gegenteil. |
|
"Table Scan ist immer schlecht" — Falsch. Eine 500-Zeilen-Lookup-Tabelle wird schneller gescannt als per Index gesucht. Ein Table Scan auf einer 10-GB-Tabelle für eine Punkt-Suche ist ein Problem. Kontext zählt. |
|
"Key Lookup = Index fehlt" — Oft, aber nicht immer. Manchmal ist es günstiger, einen Key Lookup zu tolerieren als einen breiten Covering Index zu pflegen, der alle Schreiboperationen verlangsamt. |
|
"Merge Join ist besser als Hash Join, weil er kein Memory braucht" — Stimmt nur, wenn die Daten ohnehin sortiert sind. Wenn SQL Server erst sortieren muss, ist Hash Join oft günstiger. |
|
Praxisbeispiel: Erste Gegenmaßnahmen bei Query-Performance-Problemen |
|---|
|
1. Missing Index Hints im Actual Execution Plan prüfen: SQL Server markiert direkt im Plan, wenn ein Index fehlt. Ist kein Ersatz für durchdachtes Index-Design, aber ein guter erster Schritt. |
|
2. SET STATISTICS IO für die verdächtige Query ausführen und Logical Reads identifizieren. > 100.000 Reads pro Ausführung → hohe Priorität. |
|
3. Actual Execution Plan mit Warnings prüfen: Implicit Conversion und fehlende Statistiken sind sofort behebbar. |
|
4. UPDATE STATISTICS für involvierte Tabellen, wenn Estimated vs. Actual Rows stark divergiert. In der Produktion immer in einem Wartungsfenster oder mit FULLSCAN-Option prüfen. |
|
5. Query Store aktivieren, wenn nicht bereits aktiv — er zeichnet Plan-Regressionen automatisch auf und ermöglicht schnelles Plan Forcing bei akuten Problemen. |
|
Was nicht funktioniert: MAXDOP einfach erhöhen in der Hoffnung "dann geht es schneller". Wenn der Plan fundamental falsch ist, beschleunigt mehr Parallelismus nur den falschen Plan. |
Zusammenfassung
Query Performance und Ausführungspläne sind kein Hexenwerk — aber sie erfordern systematisches Vorgehen und die Bereitschaft, in die Details zu schauen. Die wichtigsten Erkenntnisse dieses Kapitels:
Dieses Kapitel hat den Boden bereitet für eines der komplexesten und zugleich praxisrelevantesten Themen in der SQL Server Performance: Was passiert, wenn der Optimizer systematisch falsch schätzt? Nicht zufällig, sondern wiederholt und vorhersehbar? Das ist das Thema von Kapitel 16 — Statistiken, Cardinality Estimator und Plan Regression. Wenn du nach dem Lesen dieses Kapitels einen Ausführungsplan mit Diskrepanzen zwischen Estimated und Actual Rows siehst und weißt, dass die Antwort in den Statistiken liegt — dann war dieses Kapitel erfolgreich.
Ausblick: Kapitel 16 — Statistiken, Cardinality Estimator & Plan Regression
Kapitel 16 geht tiefer: Wie funktioniert der Cardinality Estimator intern? Was hat sich mit SQL Server 2014 verändert (CE 120 vs. CE 70)? Warum schätzt SQL Server bei bestimmten Prädikaten systematisch zu niedrig — und was kann man dagegen tun? Und: Was ist Plan Regression, wie erkennst du sie, und wie nutzt du den Query Store um einen einmal guten Plan dauerhaft zu halten? Nach Kapitel 15 hast du gelernt wie man Pläne liest. Nach Kapitel 16 verstehst du, warum sie gut oder schlecht sind.

Abb. 1: Plan-Operatoren im Überblick

Abb. 2: Join-Algorithmen: Nested Loop, Hash, Merge

Abb. 3: Parallelismus und Exchange-Operatoren
Kapitel 16
