Zum Inhalt springen
sw
en

Tippe um zu suchen

Datenbanken & Data

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.

13 Min Lesezeit Expert Zuletzt aktualisiert:

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?

SzenarioIndex-TypBegründung
Primärschlüssel, häufige Bereichsabfragen (WHERE Datum BETWEEN ...)ClusteredPhysische Sortierung nutzt Bereichsscans optimal
Fremdschlüssel-Spalte für JOINsNonclusteredKein Grund, die Tabelle danach zu sortieren
Spalte in WHERE-Klausel mit hoher Selektivität (z. B. E-Mail-Adresse)NonclusteredSchneller Lookup ohne physische Umordnung
Spalte mit wenigen unterschiedlichen Werten (z. B. Status mit 3 Werten)Meist kein Index, oder Filtered IndexGeringe Selektivität - Optimizer bevorzugt oft Scan
Volltextsuche in langen TextenFull-Text-IndexB-Baum ungeeignet für Text-Matching
Reporting-Query mit vielen Aggregationen über grosse TabellenColumnstore-IndexSpaltenorientierte 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

OperatorBedeutungWann problematisch
Index SeekGezielter Zugriff über Index-B-BaumPraktisch nie - das Ziel
Index ScanKompletter Durchlauf durch alle Blattseiten eines IndexBei grossen Tabellen und selektiven Filtern ein Warnsignal
Key Lookup / RID LookupNachschlagen zusätzlicher Spalten in der Basistabelle nach einem Nonclustered-SeekBei hoher Ausführungsanzahl teuer - Kandidat für Covering Index
Hash MatchJoin-Strategie über Hash-Tabelle im Speicher (oder Tempdb bei Spill)Bei fehlenden Statistiken oder falscher Kardinalitätsschätzung teuer
SortExplizite Sortierung, z. B. für ORDER BY ohne passenden IndexKostet 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

  1. Symptom eingrenzen: Welche konkrete Abfrage oder welcher Report ist betroffen? Zeitstempel und betroffene Datenbank notieren.
  2. Aktuellen Plan holen: Query mit SET STATISTICS IO, TIME ON und Actual Execution Plan (Strg+M) ausführen.
  3. Teure Operatoren identifizieren: Scan statt Seek? Key Lookup mit hoher Ausführungsanzahl? Sort-Operator mit hohem Speicherbedarf?
  4. Missing-Index-DMV gegenprüfen: Deckt sich der Vorschlag mit deiner manuellen Analyse aus dem Plan?
  5. Bestehende Indizes der Tabelle inventarisieren: Lässt sich ein bestehender Index um INCLUDE-Spalten erweitern, statt einen neuen anzulegen?
  6. Index in Testumgebung anlegen, Plan erneut prüfen: Wurde aus Scan tatsächlich ein Seek? Sind die logical reads gesunken?
  7. Schreiblast der Zieltabelle abschätzen: Rechtfertigt die Lesehäufigkeit den zusätzlichen Wartungsaufwand bei INSERT/UPDATE/DELETE?
  8. 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_BlitzIndex oder einer eigenen “unused index”-Abfrage über sys.dm_db_index_usage_stats lohnt sich.
  • Rebuild-Jobs zur falschen Zeit: Ein tägliches REBUILD um 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 immer UPDATE STATISTICS erwä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

Kommentare

Frage, Verbesserungsvorschlag oder eigene Erfahrung zu diesem Artikel? Schreib einen Kommentar. Neue Beiträge erscheinen nach kurzer Moderation.

  • Lade Kommentare …
Kommentar schreiben