SQL Server Indizes und Performance-Tuning fuer Admins
Clustered vs. Nonclustered Indizes, Fragmentierung, Missing-Index-DMVs und Execution Plans fuer echtes SQL-Server-Tuning im KMU.
Warum Index-Tuning kein “Nice to have” ist
Wenn im KMU der ERP-Report morgens um 8 Uhr plötzlich 40 statt 4 Sekunden braucht, liegt das in den meisten Fällen nicht an zu wenig CPU oder RAM, sondern an einem fehlenden oder kaputten Index. SQL Server ist erstaunlich gutmütig: Er liefert auch bei einem kompletten Table Scan über zehn Millionen Zeilen ein korrektes Ergebnis - nur eben langsam. Als Admin ohne DBA-Background bist du oft die erste (und einzige) Instanz, die das reparieren kann, bevor der Softwarehersteller mit “Server zu langsam, bitte aufrüsten” antwortet.
Dieser Artikel ist bewusst auf Praxis ausgelegt: Du bekommst fertige Skripte, die du in der Produktion einsetzen kannst, plus die Denkmodelle dahinter, damit du auch bei unbekannten Symptomen selbst weiterkommst. Vorausgesetzt wird Grundwissen aus SQL Server Grundlagen für den Support und Datenbank-Grundbegriffe für den IT-Support.
Clustered vs. Nonclustered Index: die Architektur dahinter
Ein Index in SQL Server ist ein B-Baum (B-Tree). Der Unterschied zwischen Clustered und Nonclustered liegt nicht in der Baumstruktur, sondern darin, was auf der Blattebene (Leaf Level) liegt.
Clustered Index: Die Blattebene ist die Tabelle selbst. Die Datenzeilen werden physisch in der Reihenfolge des Index-Schlüssels auf der Festplatte bzw. in den Datenseiten (Pages) gespeichert. Eine Tabelle kann deshalb maximal einen Clustered Index haben - es gibt nur eine physische Sortierreihenfolge. Hat eine Tabelle keinen Clustered Index, spricht man von einem Heap; die Zeilen liegen dann unsortiert, referenziert über RID (Row Identifier).
Nonclustered Index: Die Blattebene enthält nur den Index-Schlüssel plus einen Verweis auf die eigentliche Datenzeile - bei einer Heap-Tabelle die RID, bei einer Tabelle mit Clustered Index den sogenannten Clustering Key. Eine Tabelle kann bis zu 999 Nonclustered Indizes haben (pro SQL-Server-Doku), in der Praxis sind mehr als 5-8 pro Tabelle aber fast immer ein Zeichen von Über-Indexierung.
Willst du eine GUID als Primärschlüssel und brauchst trotzdem sequenzielle Einfügungen, nutze NEWSEQUENTIALID() als Default oder generiere die GUID serverseitig sortiert - das reduziert Fragmentierung erheblich gegenüber zufälligen NEWID()-Werten.
Entscheidungstabelle: Welchen Index wofür?
| Szenario | Index-Typ | Begründung |
|---|---|---|
Primärschlüssel, häufige Bereichsabfragen (WHERE Datum BETWEEN ...) | Clustered | Physische Sortierung nutzt Bereichsscans optimal |
| Fremdschlüssel-Spalte für JOINs | Nonclustered | Kein Grund, die Tabelle danach zu sortieren |
Spalte in WHERE-Klausel mit hoher Selektivität (z. B. E-Mail-Adresse) | Nonclustered | Schneller Lookup ohne physische Umordnung |
Spalte mit wenigen unterschiedlichen Werten (z. B. Status mit 3 Werten) | Meist kein Index, oder Filtered Index | Geringe Selektivität - Optimizer bevorzugt oft Scan |
| Volltextsuche in langen Texten | Full-Text-Index | B-Baum ungeeignet für Text-Matching |
| Reporting-Query mit vielen Aggregationen über grosse Tabellen | Columnstore-Index | Spaltenorientierte Kompression, massiv schneller bei OLAP-Mustern |
Fillfactor und Page Splits verstehen
Jede Datenseite (Page) in SQL Server ist 8 KB gross. Beim Einfügen einer neuen Zeile in eine bereits volle Page muss der Server Platz schaffen: Er teilt die Seite in zwei, verschiebt etwa die Hälfte der Zeilen auf eine neue Page und verlinkt sie in der Indexstruktur. Das ist der Page Split - eine teure Operation, die zusätzlich I/O erzeugt, Transaktionslog-Einträge produziert und zu logischer Fragmentierung führt, weil die neue Page physisch woanders liegt als ihre Nachbarn.
Der Fillfactor legt fest, wie voll eine Seite beim Erstellen oder Rebuild eines Index initial befüllt wird. Ein Fillfactor von 100 (Standard) füllt die Seite komplett - gut für rein lesende, selten wachsende Tabellen. Ein Fillfactor von 80 lässt 20 % Platz für künftige Einfügungen frei und reduziert damit Page Splits auf stark wachsenden Tabellen mit zufälliger Einfügereihenfolge.
-- Fillfactor für einen bestehenden Index setzen und sofort per Rebuild anwenden
ALTER INDEX IX_Bestellungen_KundeId
ON dbo.Bestellungen
REBUILD WITH (FILLFACTOR = 85, ONLINE = ON);
-- Serverweiten Standard-Fillfactor abfragen
EXEC sys.sp_configure 'fill factor';
Page Splits pro Sekunde lassen sich über den Performance Counter SQLServer:Access Methods\Page Splits/sec in Kombination mit der Batch-Request-Rate beobachten - siehe auch Monitoring-Grundlagen für den generellen Aufbau von Schwellenwert-Alarmen.
Index-Fragmentierung messen und beheben
Fragmentierung entsteht durch Page Splits, gelöschte Zeilen und ungünstige Einfügemuster. Man unterscheidet zwei Arten:
- Logische (externe) Fragmentierung: Die logische Reihenfolge der Index-Seiten stimmt nicht mit der physischen Reihenfolge auf der Platte überein. Relevant vor allem für sequenzielle Scans und rotierende Platten, spielt auf SSD/NVMe eine kleinere, aber nicht vernachlässigbare Rolle.
- Interne Fragmentierung: Datenseiten sind nur teilweise gefüllt (niedrige “Page Density”), was mehr Pages für dieselbe Datenmenge bedeutet und den Buffer Pool ineffizienter nutzt.
Beide misst du mit der DMV sys.dm_db_index_physical_stats:
SELECT
OBJECT_NAME(ips.object_id) AS TabelleName,
i.name AS IndexName,
ips.index_type_desc,
ips.avg_fragmentation_in_percent,
ips.page_count,
ips.avg_page_space_used_in_percent AS PageDensity
FROM sys.dm_db_index_physical_stats(DB_ID(), NULL, NULL, NULL, 'LIMITED') AS ips
INNER JOIN sys.indexes AS i
ON ips.object_id = i.object_id AND ips.index_id = i.index_id
WHERE ips.page_count > 1000 -- kleine Indizes ignorieren, Fragmentierung dort irrelevant
ORDER BY ips.avg_fragmentation_in_percent DESC;
Entscheidungsbaum: Rebuild oder Reorganize?
avg_fragmentation_in_percent ermitteln
│
├─ < 5 % → nichts tun
│
├─ 5 % bis 30 % → REORGANIZE
│ (Online per Definition, geringe Ressourcenlast,
│ aktualisiert KEINE Statistiken automatisch)
│
└─ > 30 % → REBUILD
(Standard: Offline mit Tabellensperre;
Enterprise/Developer: WITH (ONLINE = ON) möglich;
aktualisiert Statistiken automatisch mit vollem Sample)
-- Reorganize: leicht, online, aber ohne Statistik-Update
ALTER INDEX IX_Auftraege_Status ON dbo.Auftraege REORGANIZE;
-- Rebuild: schwerer, aktualisiert Statistiken automatisch
ALTER INDEX IX_Auftraege_Status ON dbo.Auftraege
REBUILD WITH (ONLINE = ON, MAXDOP = 4);
-- Alle Indizes einer Tabelle in einem Rutsch neu aufbauen
ALTER INDEX ALL ON dbo.Auftraege REBUILD WITH (ONLINE = ON);
Für den produktiven Einsatz lohnt sich statt eigener Skripte oft Ola Hallengrens kostenloses Maintenance-Solution-Skriptpaket, das Rebuild/Reorganize-Entscheidungen, Statistik-Updates und Backups sauber kombiniert und in tausenden Produktivumgebungen im Einsatz ist.
-- Beispielaufruf nach Installation von Ola Hallengrens IndexOptimize-Prozedur
EXEC dbo.IndexOptimize
@Databases = 'USER_DATABASES',
@FragmentationLow = NULL,
@FragmentationMedium = 'INDEX_REORGANIZE,INDEX_REBUILD_ONLINE,INDEX_REBUILD_OFFLINE',
@FragmentationHigh = 'INDEX_REBUILD_ONLINE,INDEX_REBUILD_OFFLINE',
@FragmentationLevel1 = 5,
@FragmentationLevel2 = 30,
@UpdateStatistics = 'ALL',
@OnlyModifiedStatistics = 'Y';
Missing Index DMVs: SQL Server sagt dir, was fehlt
Der Query Optimizer protokolliert bei jeder Kompilierung, welche Indizes eine Abfrage beschleunigt hätten. Diese Information steht - solange der Dienst seit dem letzten Neustart läuft - in drei zusammenhängenden DMVs zur Verfügung:
SELECT
mid.statement AS Tabelle,
migs.avg_total_user_cost * migs.avg_user_impact * (migs.user_seeks + migs.user_scans) AS GeschaetzterNutzen,
migs.user_seeks + migs.user_scans AS AnzahlAnfragen,
mid.equality_columns,
mid.inequality_columns,
mid.included_columns,
'CREATE INDEX IX_' + REPLACE(REPLACE(REPLACE(mid.statement, '[', ''), ']', ''), '.', '_')
+ ' ON ' + mid.statement
+ ' (' + ISNULL(mid.equality_columns, '')
+ CASE WHEN mid.equality_columns IS NOT NULL AND mid.inequality_columns IS NOT NULL THEN ',' ELSE '' END
+ ISNULL(mid.inequality_columns, '') + ')'
+ ISNULL(' INCLUDE (' + mid.included_columns + ')', '') AS VorschlagCreateIndex
FROM sys.dm_db_missing_index_groups mig
INNER JOIN sys.dm_db_missing_index_group_stats migs
ON migs.group_handle = mig.index_group_handle
INNER JOIN sys.dm_db_missing_index_details mid
ON mig.index_handle = mid.index_handle
ORDER BY GeschaetzterNutzen DESC;
Wichtige Einschränkungen der Missing-Index-Funktion, die in der Praxis oft übersehen werden:
- Die Daten werden bei jedem Dienst-Neustart und bei jedem Failover gelöscht - für belastbare Trends brauchst du eine periodische Persistierung (z. B. per SQL Agent Job in eine History-Tabelle).
- Das Feature berücksichtigt keine Spaltenreihenfolge-Optimierung und keine bestehenden Indizes vollständig - es ist eine Heuristik, kein Ersatz für Execution-Plan-Analyse.
- Begrenzung auf 500 fehlende Index-Gruppen pro Datenbank (600 Zeilen in
sys.dm_db_missing_index_details) - auf sehr grossen, ungepflegten Datenbanken kann das Feature “übervoll” laufen und neue Vorschläge nicht mehr anzeigen.
Execution Plans lesen und STATISTICS IO nutzen
Der Execution Plan zeigt, wie der Optimizer eine Abfrage tatsächlich ausführt. In SSMS aktivierst du ihn über Strg+M (Include Actual Execution Plan) vor der Ausführung, oder du holst ihn dir als Text/XML direkt aus T-SQL:
SET STATISTICS IO ON;
SET STATISTICS TIME ON;
SELECT o.OrderId, o.OrderDate, c.CustomerName
FROM dbo.Orders AS o
INNER JOIN dbo.Customers AS c ON o.CustomerId = c.CustomerId
WHERE o.OrderDate >= '2026-01-01';
SET STATISTICS IO OFF;
SET STATISTICS TIME OFF;
Die Ausgabe von STATISTICS IO ist Gold wert und wird von vielen Admins unterschätzt:
Table 'Orders'. Scan count 1, logical reads 48213, physical reads 2, read-ahead reads 47800.
Table 'Customers'. Scan count 1, logical reads 312, physical reads 0, read-ahead reads 0.
Logical reads ist die wichtigste Kennzahl: die Anzahl 8-KB-Seiten, die aus dem Buffer Pool gelesen wurden. Ein Wert von 48’213 Seiten (~377 MB) für eine simple Datumsfilterung deutet stark auf einen fehlenden Index auf OrderDate hin - ohne Index muss SQL Server praktisch die ganze Tabelle einlesen (Table/Index Scan statt Index Seek).
Die wichtigsten Operatoren im grafischen Plan
| Operator | Bedeutung | Wann problematisch |
|---|---|---|
| Index Seek | Gezielter Zugriff über Index-B-Baum | Praktisch nie - das Ziel |
| Index Scan | Kompletter Durchlauf durch alle Blattseiten eines Index | Bei grossen Tabellen und selektiven Filtern ein Warnsignal |
| Key Lookup / RID Lookup | Nachschlagen zusätzlicher Spalten in der Basistabelle nach einem Nonclustered-Seek | Bei hoher Ausführungsanzahl teuer - Kandidat für Covering Index |
| Hash Match | Join-Strategie über Hash-Tabelle im Speicher (oder Tempdb bei Spill) | Bei fehlenden Statistiken oder falscher Kardinalitätsschätzung teuer |
| Sort | Explizite Sortierung, z. B. für ORDER BY ohne passenden Index | Kostet Speicher/Tempdb, oft durch Index vermeidbar |
Achte im grafischen Plan zusätzlich auf gelbe Warndreiecke (z. B. “Type conversion may affect CardinalityEstimate”) und auf grosse Differenzen zwischen geschätzter und tatsächlicher Zeilenzahl - ein starkes Indiz für veraltete Statistiken:
-- Statistiken einer Tabelle manuell aktualisieren, volles Sample
UPDATE STATISTICS dbo.Orders WITH FULLSCAN;
-- Automatische Statistik-Updates prüfen (sollten für die meisten OLTP-DBs an sein)
SELECT name, is_auto_update_stats_on, is_auto_create_stats_on
FROM sys.databases WHERE name = 'DeineDatenbank';
Covering Index und Included Columns
Ein Covering Index enthält alle Spalten, die eine Abfrage benötigt - sowohl im WHERE/JOIN als auch im SELECT - sodass SQL Server nach dem Index Seek keinen zusätzlichen Key Lookup in der Basistabelle mehr braucht.
-- Ausgangslage: Query filtert auf KundeId, liest aber auch Betrag und Status
SELECT BestellDatum, Betrag, Status
FROM dbo.Bestellungen
WHERE KundeId = 4711;
-- Ohne INCLUDE: Index Seek auf KundeId + Key Lookup für Betrag/Status/BestellDatum
CREATE INDEX IX_Bestellungen_KundeId ON dbo.Bestellungen (KundeId);
-- Mit INCLUDE: Alles liegt auf Blattebene des Index -> kein Key Lookup mehr nötig
CREATE INDEX IX_Bestellungen_KundeId_Covering
ON dbo.Bestellungen (KundeId)
INCLUDE (BestellDatum, Betrag, Status);
Der Unterschied zwischen Schlüsselspalten und INCLUDE-Spalten: Schlüsselspalten stehen auf allen Ebenen des B-Baums und bestimmen die Sortierreihenfolge (nutzbar für Bereichsabfragen und Sortierung). INCLUDE-Spalten liegen ausschliesslich auf der Blattebene, kosten dadurch weniger Platz in den oberen Baum-Ebenen und dürfen auch Datentypen enthalten, die als Schlüssel nicht zulässig sind (z. B. NVARCHAR(MAX)).
Covering Indizes sind besonders wirksam bei Reporting-Queries, die regelmässig dieselben 4-6 Spalten aus einer grossen Transaktionstabelle ziehen (z. B. ein tägliches ERP-Dashboard) - dort lohnt sich der zusätzliche Schreibaufwand fast immer, weil die Lesehäufigkeit die Schreiblast klar überwiegt.
Praxis-Workflow: Von “Server ist langsam” zum fertigen Index
- Symptom eingrenzen: Welche konkrete Abfrage oder welcher Report ist betroffen? Zeitstempel und betroffene Datenbank notieren.
- Aktuellen Plan holen: Query mit
SET STATISTICS IO, TIME ONund Actual Execution Plan (Strg+M) ausführen. - Teure Operatoren identifizieren: Scan statt Seek? Key Lookup mit hoher Ausführungsanzahl? Sort-Operator mit hohem Speicherbedarf?
- Missing-Index-DMV gegenprüfen: Deckt sich der Vorschlag mit deiner manuellen Analyse aus dem Plan?
- Bestehende Indizes der Tabelle inventarisieren: Lässt sich ein bestehender Index um
INCLUDE-Spalten erweitern, statt einen neuen anzulegen? - Index in Testumgebung anlegen, Plan erneut prüfen: Wurde aus Scan tatsächlich ein Seek? Sind die
logical readsgesunken? - Schreiblast der Zieltabelle abschätzen: Rechtfertigt die Lesehäufigkeit den zusätzlichen Wartungsaufwand bei INSERT/UPDATE/DELETE?
- In Produktion ausrollen, idealerweise ausserhalb der Kernarbeitszeit bei grossen Tabellen, und Fragmentierungs-Job/Statistik-Job im Auge behalten.
Typische Fallstricke im KMU-Alltag
- Index-Wildwuchs durch ORM-Frameworks: Entity Framework, Hibernate & Co. legen bei automatischen Migrationen gerne pro Fremdschlüssel einen eigenen Index an, ohne Rücksicht auf bereits vorhandene, überlappende Indizes. Regelmässiges Aufräumen mit
sp_BlitzIndexoder einer eigenen “unused index”-Abfrage übersys.dm_db_index_usage_statslohnt sich. - Rebuild-Jobs zur falschen Zeit: Ein tägliches
REBUILDum Mitternacht auf einer 200-GB-Tabelle kann das nächtliche Backup-Fenster sprengen. Fragmentierungsschwellen (5 %/30 %) und Zeitfenster sauber aufeinander abstimmen. - Statistiken vergessen nach Bulk-Loads: Nach grossen
BULK INSERT- oder ETL-Läufen können veraltete Statistiken zu katastrophalen Plan-Fehleinschätzungen führen, auch wenn Fragmentierung kein Problem ist. Nach grossen Datenimporten immerUPDATE STATISTICSerwägen. - Fehlende Testumgebung: Ein Index, der in der Produktions-DB direkt angelegt wird, kann bei grossen Tabellen minutenlang sperren. Ohne Staging-Umgebung zumindest in einem Wartungsfenster mit vorheriger Grössenabschätzung (
sp_spaceused) arbeiten. - Tempdb-Kontention bei Rebuilds: Grosse Sort- und Rebuild-Operationen nutzen Tempdb intensiv. Ist Tempdb nicht auf mehrere Datendateien aufgeteilt (Faustregel: bis zu 8 Dateien gleicher Grösse bei Mehrkern-Systemen), verschärfen Index-Wartungsjobs bestehende PAGELATCH-Wartezustände zusätzlich.
Für tiefere Themen rund um Hochverfügbarkeit und Absicherung der Instanz lohnt sich der Blick in SQL Server Hochverfügbarkeit im KMU und SQL Server Sicherheit härten.
Weiterlernen
- Reorganize and Rebuild Indexes (Microsoft Learn)
- sys.dm_db_missing_index_details (Microsoft Learn)
- sys.dm_db_index_physical_stats (Microsoft Learn)
- Specify Fill Factor for an Index (Microsoft Learn)
- Execution Plans Overview (Microsoft Learn)
- Ola Hallengren SQL Server Maintenance Solution
Kommentare
Frage, Verbesserungsvorschlag oder eigene Erfahrung zu diesem Artikel? Schreib einen Kommentar. Neue Beiträge erscheinen nach kurzer Moderation.
- Lade Kommentare …