Technisch

Grundsätze der SQL-Abfrageoptimierung

Nur einer pro Tabelle

Abschnitt 1

Indexierung & Statistiken

Indizes sind das wirkungsvollste Mittel zur Beschleunigung von SQL-Abfragen. Fehlt ein geeigneter Index, liest SQL Server bei jeder Abfrage alle Zeilen der Tabelle – ein sogenannter Table Scan. Bei großen Tabellen ist das der häufigste Grund für schlechte Laufzeiten.

Clustered Index

Bestimmt die physische Speicherreihenfolge der Tabelle. Jede Tabelle kann nur einen haben – typischerweise auf dem Primärschlüssel.

Non-Clustered Index

Separate Struktur mit Zeigern auf die Originaldaten. Mehrere pro Tabelle möglich. Ideal für häufig gefilterte Nicht-PK-Spalten.

Covering Index

Enthält alle von einer Abfrage benötigten Spalten direkt im Index. Verhindert den Rückgriff auf die Basistabelle (Key Lookup).

Funktionen auf indizierten Spalten vermeiden

Wird eine Funktion direkt auf eine indizierte Spalte angewendet, kann SQL Server den Index nicht nutzen. Der Optimizer muss dann jede Zeile einzeln auswerten – ein vollständiger Scan ist die Folge. Die Lösung: den Wert transformieren, nicht die Spalte.

Langsam – Index nicht nutzbar
SELECT *
FROM Orders
WHERE YEAR(OrderDate) = 2024
Schnell – Bereich auf indexierter Spalte
SELECT *
FROM Orders
WHERE OrderDate >= '2024-01-01'
  AND OrderDate < '2025-01-01'
Regel: Funktionen auf indizierten Spalten – YEAR(), UPPER(), CONVERT(), CAST() – machen den Index wirkungslos. Immer den Vergleichswert anpassen, nie die Spalte.

Index-Typen im Überblick

TypWann sinnvollBesonderheit
ClusteredBereichsabfragen, ORDER BY auf PKNur einer pro Tabelle
Non-ClusteredWHERE-Filter auf Nicht-PK-SpaltenHäufigste Index-Art
CoveringAbfragen ohne Key LookupINCLUDE-Klausel nutzen
FilteredTeilmengen (z.B. WHERE IsActive = 1)Selten, aber sehr effektiv
ColumnstoreAnalytische Abfragen, AggregationenAb SQL Server 2012

Covering Index mit INCLUDE

Ein Key Lookup entsteht, wenn SQL Server nach einem Index Seek noch zusätzliche Spalten aus der Basistabelle nachladen muss. Das lässt sich durch Aufnahme dieser Spalten in den Index mit INCLUDE verhindern.

T-SQL — Covering Index
-- Abfrage, die einen Key Lookup erzeugt:
SELECT CustomerID, CompanyName, ContactEmail
FROM Customers
WHERE Region = 'Bayern';

-- Covering Index: Suchspalte als Schlüssel, restliche als INCLUDE
CREATE NONCLUSTERED INDEX IX_Customers_Region
  ON Customers (Region)
  INCLUDE (CustomerID, CompanyName, ContactEmail);
GO

Statistiken und Fragmentierung

SQL Server verwendet Statistiken, um die Anzahl der betroffenen Zeilen zu schätzen und darauf basierend den besten Execution Plan zu wählen. Veraltete Statistiken führen zu falschen Schätzungen – und damit zu schlechten Plänen.

T-SQL — Statistiken & Fragmentierung
-- Statistiken für eine Tabelle aktualisieren
UPDATE STATISTICS Orders WITH FULLSCAN;

-- Alle Statistiken der Datenbank aktualisieren
EXEC sp_updatestats;

-- Fragmentierung aller Indizes prüfen
SELECT
  OBJECT_NAME(ips.object_id) AS Tabelle,
  i.[name] AS Indexname,
  ips.avg_fragmentation_in_percent AS Fragmentierung_Pct
FROM sys.dm_db_index_physical_stats
     (DB_ID(), NULL, NULL, NULL, 'LIMITED') ips
INNER JOIN sys.indexes i
  ON ips.object_id = i.object_id AND ips.index_id = i.index_id
WHERE ips.avg_fragmentation_in_percent > 10
ORDER BY Fragmentierung_Pct DESC;
Faustregel Fragmentierung: Unter 10 % – keine Maßnahme nötig. 10–30 % → ALTER INDEX ... REORGANIZE. Über 30 % → ALTER INDEX ... REBUILD. Statistiken sollten nach jedem größeren Ladevorgang aktualisiert werden.

Abschnitt 2

Abfragestruktur

Die Formulierung einer Abfrage beeinflusst direkt, welchen Execution Plan der Optimizer wählt. Kleine syntaktische Unterschiede können dabei über Index-Nutzung oder vollständigen Table Scan entscheiden.

SELECT * vermeiden

Das Sternchen liest alle Spalten – auch breite Textfelder (NVARCHAR(MAX)) oder Binärspalten, die für die Abfrage irrelevant sind. Das erhöht I/O und Netzwerklast und verhindert die Nutzung von Covering Indexes.

Ineffizient – alle Spalten
SELECT *
FROM Customers
WHERE Region = 'Bayern'
Besser – nur benötigte Spalten
SELECT CustomerID,
    CompanyName,
    ContactEmail
FROM Customers
WHERE Region = 'Bayern'

CTE, Subquery oder temporäre Tabelle?

CTEs (Common Table Expressions) verbessern die Lesbarkeit erheblich. SQL Server führt sie jedoch meist inline aus – das bedeutet, das Ergebnis wird bei mehrfacher Referenzierung im selben Query unter Umständen mehrfach berechnet. Für große Zwischenergebnisse, die mehrfach verwendet werden, sind temporäre Tabellen oft die bessere Wahl, da sie eigene Statistiken und Indizes erhalten können.

T-SQL — CTE vs. temporäre Tabelle
-- CTE: gut lesbar, sinnvoll bei einmaliger Verwendung
WITH TopCustomers AS (
  SELECT CustomerID, SUM(Amount) AS Total
  FROM Orders
  GROUP BY CustomerID
  HAVING SUM(Amount) > 10000
)
SELECT c.CompanyName, tc.Total
FROM TopCustomers tc
JOIN Customers c ON tc.CustomerID = c.CustomerID;

-- Temporäre Tabelle: besser bei Wiederverwendung oder großen Ergebnismengen
SELECT CustomerID, SUM(Amount) AS Total
INTO #TopCustomers
FROM Orders
GROUP BY CustomerID
HAVING SUM(Amount) > 10000;

-- Index auf der Temp-Tabelle verbessert nachfolgende JOINs
CREATE INDEX IX_TC ON #TopCustomers (CustomerID);

SELECT c.CompanyName, tc.Total
FROM #TopCustomers tc
JOIN Customers c ON tc.CustomerID = c.CustomerID;

DROP TABLE #TopCustomers;
Wann Temp-Tabelle, wann CTE? CTE für Lesbarkeit bei einmaliger Verwendung, kleinen Ergebnismengen und rekursiven Abfragen. Temporäre Tabelle wenn das Ergebnis mehrfach genutzt wird, groß ist oder von einem eigenen Index profitiert.

Explizite JOINs verwenden

Die veraltete Syntax mit impliziten Joins (Komma-getrennte Tabellen in FROM mit Join-Bedingung in WHERE) ist schwerer zu lesen und fehleranfälliger – Cross Joins entstehen leicht versehentlich, wenn eine Bedingung vergessen wird.

Veraltet – impliziter Join
SELECT o.OrderID, c.CompanyName
FROM Orders o, Customers c
WHERE o.CustomerID = c.CustomerID
  AND o.Status = 'Open'
Korrekt – expliziter INNER JOIN
SELECT o.OrderID, c.CompanyName
FROM Orders o
INNER JOIN Customers c
  ON o.CustomerID = c.CustomerID
WHERE o.Status = 'Open'

Häufige Stolperstellen

MusterProblemEmpfehlung
LIKE '%Wert%' Führendes Wildcard verhindert Index-Nutzung – immer Table Scan Fulltext-Suche prüfen
NOT IN mit Subquery Schlägt bei NULL-Werten lautlos fehl; kann Full Scan erzwingen NOT EXISTS bevorzugen
OR in WHERE Verhindert häufig einheitliche Index-Nutzung UNION ALL in Betracht ziehen
Implizite Typkonvertierung nvarchar vs. varchar – Konvertierung auf jeder Zeile Datentypen angleichen
Cursor / WHILE-Schleife Zeilenweise Verarbeitung statt mengenbasierter Logik Set-basierte Lösung suchen
SELECT DISTINCT Erzwingt Sortierung / Deduplizierung – oft ein JOIN-Problem JOIN-Logik prüfen

Abschnitt 3

Execution Plan Analyse

Der Execution Plan zeigt, wie SQL Server eine Abfrage intern ausführt: welche Indizes verwendet werden, wie Tabellen verknüpft werden und wo der größte Anteil der Gesamtkosten entsteht. Das Lesen von Plänen ist die effektivste Methode zur Diagnose von Leistungsproblemen.

Plan anzeigen lassen

T-SQL — Execution Plan aktivieren
-- Geschätzter Plan (kein Ausführen erforderlich) – SSMS: Strg+L
SET SHOWPLAN_XML ON;
GO
SELECT OrderID, Amount FROM Orders WHERE CustomerID = 42;
GO
SET SHOWPLAN_XML OFF;

-- Tatsächlicher Plan (nach Ausführung) – SSMS: Strg+M, dann F5
-- Alternativ: I/O- und Zeit-Statistiken im Nachrichtenbereich
SET STATISTICS IO ON;
SET STATISTICS TIME ON;
GO
SELECT OrderID, Amount FROM Orders WHERE CustomerID = 42;
GO
SET STATISTICS IO OFF;
SET STATISTICS TIME OFF;
Geschätzt vs. tatsächlich: Der tatsächliche Plan enthält die realen Zeilenzahlen nach der Ausführung und ist damit bei Parametersniffing-Problemen und falschen Schätzungen deutlich aussagekräftiger als der geschätzte Plan.

Verarbeitungsablauf im Optimizer

01
Parsing
Syntaxprüfung
02
Algebrizer
Objektauflösung
03
Optimizer
Kostenbasiert
04
Execution
Planausführung
05
Plan Cache
Wiederverwendung

Die wichtigsten Plan-Operatoren

Table Scan
Liest die gesamte Tabelle. Kein nutzbarer Index vorhanden.
⚠ Kritisch bei großen Tabellen
Index Seek
Navigiert direkt im B-Baum des Index. Geringer I/O – ideal.
Index Scan
Liest den gesamten Index. Besser als Table Scan, aber prüfenswert.
Key Lookup
Rückgriff auf Basistabelle für fehlende Spalten nach einem Index Seek.
⚠ Covering Index erwägen
Hash Match
Join via Hashtabelle im Arbeitsspeicher (TempDB). Entsteht bei fehlenden Join-Indizes.
⚠ Auf Spills achten
Nested Loops
Effizient für kleine äußere Mengen mit Index auf der inneren Seite.
Merge Join
Verknüpft zwei bereits sortierte Eingaben. Setzt indizierte Join-Spalten voraus.
Sort
Explizites Sortieren bei fehlendem Index auf ORDER BY-Spalten. Teuer.
⚠ TempDB-Spills möglich
Parallelism
Verteilung auf mehrere CPU-Kerne. Gut bei Analytik, oft kontraproduktiv bei OLTP.

Warnzeichen im Plan

Warnung / SymptomBedeutungMaßnahme
Gelbes Ausrufezeichen Fehlende Statistiken oder implizite Typkonvertierung im Plan Statistiken aktualisieren, Datentypen prüfen
Missing Index Hint Optimizer schlägt einen potenziell nützlichen Index vor Vorschlag analysieren, nicht blind anlegen
Estimated vs. Actual Rows stark abweichend Veraltete Statistiken oder Parametersniffing-Problem FULLSCAN, OPTION (RECOMPILE)
Spill to TempDB Arbeitsspeicherzuweisung zu gering für Sort- oder Hash-Operation Statistiken, Indexierung, Memory Grant Hints
Key Lookup / RID Lookup Non-Clustered Index enthält nicht alle benötigten Spalten INCLUDE-Spalten im Index ergänzen
Thick Arrows im Plan Sehr viele Zeilen werden zwischen Operatoren übertragen Filter früher anwenden, Indexierung prüfen

Teuerste Abfragen aus dem Plan Cache

Die DMVs sys.dm_exec_query_stats und sys.dm_exec_sql_text liefern die aktuell teuersten Abfragen direkt aus dem Plan Cache – ein idealer Startpunkt für die systematische Optimierung.

T-SQL — Top 10 teuerste Abfragen
SELECT TOP 10
  qs.total_worker_time / qs.execution_count AS avg_cpu_us,
  qs.total_logical_reads / qs.execution_count AS avg_logical_reads,
  qs.execution_count,
  SUBSTRING(qt.[text], (qs.statement_start_offset / 2) + 1,
    ((CASE qs.statement_end_offset
        WHEN -1 THEN DATALENGTH(qt.[text])
        ELSE qs.statement_end_offset
      END - qs.statement_start_offset) / 2) + 1) AS statement_text
FROM sys.dm_exec_query_stats qs
CROSS APPLY sys.dm_exec_sql_text(qs.sql_handle) qt
ORDER BY avg_cpu_us DESC;
Tipp: Die Spalte avg_logical_reads gibt an, wie viele Datenseiten durchschnittlich pro Ausführung gelesen werden. Hohe Werte hier sind ein zuverlässiger Indikator für fehlende oder unpassende Indizes.
MS SQL Server · Abfrageoptimierung · Leitfaden
Nächster Always Encrypted mit Secure Enclaves – Der vollständige Leitfaden Enklaven verstehen & konfigurieren: Die Zukunft von Always Encrypted
Cookies user preferences
We use cookies to ensure you to get the best experience on our website. If you decline the use of cookies, this website may not function as expected.
Accept all
Decline all
Analytics
Tools used to analyze the data to measure the effectiveness of a website and to understand how it works.
Google Analytics
Advertisement
If you accept, the ads on the page will be adapted to your preferences.
Google Ad
Save