Wissen

Praxis-Artikel und Buchkapitel zu SQL-Performance, Sicherheit und Hochverfügbarkeit – alle frei verfügbar.

Beratung

Festpreis-Analyse mit Bericht und Handlungsempfehlung – oder strategische Begleitung bei Architektur, Migration und Hochverfügbarkeit.

Fachbücher

Die fünfbändige Reihe „Ulis SQL-Bibliothek“ – Band 1 verfügbar. Leseprobe herunterladen!

Tools

UB.SimSQL: SQL-Server-Lastsimulator mit regelbasierten Konfigurationsempfehlungen. Lokal, ohne Cloud, ohne Abo.

Schulungen

Online-Workshops zu Performance, Sicherheit und Entwicklung – kompakt, hands-on, ohne MOC-Folienschlacht.

Index-Strategie & Wartung: – SQL Server Performance

von

Dieser Artikel ist ein Kapitel aus:
SQL Server Performance & Troubleshooting
Praxisleitfaden, ca. 600 Seiten

[ Hier bei Amazon bestellen ]
[ Mehr zum Buch ]

Index-Strategie & Wartung:

Der richtige Index zur richtigen Zeit — und warum Fragmentierung kein Schicksal ist

Ein schlecht gewarteter Index ist wie ein Telefonbuch, bei dem jemand die Hälfte der Seiten herausgerissen und in zufälliger Reihenfolge wieder eingeklebt hat. SQL Server findet die Information irgendwann schon — nur nicht mehr in 0,3 ms, sondern in 4 Sekunden. Und 4 Sekunden pro Abfrage, 800 Mal pro Minute, ergibt einen Server, bei dem der Monitoring-Alarm öfter losgeht als dein Wecker.

Indizes sind das mächtigste Werkzeug zur Query-Optimierung, das ein DBA hat. Sie sind aber kein Allheilmittel: Zu wenige Indizes bedeuten Full Table Scans und miserable Lesezeiten. Zu viele Indizes bedeuten, dass jedes INSERT, UPDATE und DELETE eine halbe Sekunde damit verbringt, zwanzig Hilfsstrukturen aktuell zu halten. Dieses Kapitel zeigt, wie du die Balance findest, wie du Fragmentierung richtig bewertest — und warum ein nächtlicher REBUILD auf einem SSD-System oft mehr schadet als nützt.

Querverweise für Eilige: Was ein Execution Plan mit Key Lookup Warnings dir über fehlende Index-Spalten sagt, haben wir in Kapitel 15 ausführlich behandelt. Dieses Kapitel baut darauf auf und liefert die Strategie dahinter. Wie Statistiken und Indizes zusammenwirken, erfährst du in Kapitel 16. Und in Kapitel 21 zeigen wir, warum der schönste Index wertlos ist, wenn die WHERE-Klausel ihn nicht nutzen kann (Stichwort: SARGability).

Clustered Index: Die Grundlage von allem

Ein Clustered Index ist kein Index im klassischen Sinne — er ist die Tabelle. Die Datenseiten der Tabelle sind die Leaf-Level-Seiten des Clustered Index. Das ist ein fundamentaler Unterschied zu einem Non-Clustered Index, und wer diesen Unterschied nicht versteht, entwirft zwangsläufig schlechte Schemas.

Die B-Baum-Struktur

SQL Server organisiert einen Clustered Index als B-Baum (Balanced Tree) mit drei Ebenen: Root Page → Intermediate Level → Leaf Level. Die Root Page enthält Zeiger auf die Intermediate-Seiten, diese enthalten Zeiger auf die Leaf-Level-Seiten. Bei einem Clustered Index befinden sich auf den Leaf-Level-Seiten die eigentlichen Datenzeilen — physisch sortiert nach dem Clustered Key.

Eine typische Query mit einer WHERE-Klausel auf dem Clustered Key traversiert den Baum von der Root bis zu den Leaf Pages: Das kostet bei einer Tabelle mit 50 Millionen Zeilen gerade mal 3–4 logische Reads, um die relevante Seite zu finden. Ein Full Table Scan auf derselben Tabelle kostet möglicherweise 500.000 logische Reads. Das ist der Unterschied zwischen "sofort" und "Zeit für einen Kaffee".

 

[Abbildung 17.1: B-Baum-Struktur: Root → Intermediate Level → Leaf Level (= Datenseiten beim Clustered Index)]

 

Was einen guten Clustered Key ausmacht

Die Wahl des Clustered Key ist eine der wichtigsten Designentscheidungen überhaupt — und leider auch eine der am häufigsten unterschätzten. Der Clustered Key wird in jeden Non-Clustered Index als "Bookmark" kopiert, damit SQL Server von einem NC-Index direkt zur Datenzeile springen kann. Das bedeutet: ein schlechter Clustered Key multipliziert seine Kosten mit der Anzahl deiner NC-Indizes. Ein guter Clustered Key erfüllt vier Kriterien:

  • Schmal: Ein INT (4 Bytes) als Clustered Key ist ideal. Ein NVARCHAR(100) kostet bis zu 200 Bytes pro NC-Index-Eintrag — und das multipliziert sich mit jeder Zeile und jedem NC-Index.
  • Monoton steigend: IDENTITY-Spalten oder Sequenzen erzeugen neue Einträge immer am rechten Ende des B-Baums. Das vermeidet Page Splits und hält die Fragmentierung niedrig.
  • Eindeutig: SQL Server erzwingt keine Eindeutigkeit beim Clustered Key, fügt aber bei doppelten Werten intern einen 4-Byte-Uniquifier hinzu — was den Key effektiv breiter macht.
  • Stabil: Ein Clustered Key, der sich ändert, löst einen physischen Zeilenumzug aus. Alle NC-Indizes müssen aktualisiert werden. Das ist teuer und führt zu Fragmentierung.
  • Der klassisch schlechte Clustered Key ist die GUID (UNIQUEIDENTIFIER mit NEWID()). GUIDs sind zufällig verteilt — ein neuer Wert landet an einer beliebigen Position im B-Baum. Wenn die Zielseite voll ist, führt SQL Server einen Page Split durch: Die volle Seite wird in zwei halb-gefüllte Seiten aufgeteilt, neue Einträge landen auf der richtigen der beiden Seiten. Ein Page Split kostet etwa 10–15 ms IO auf einem HDD-System und hinterlässt dauerhaft zwei halb-gefüllte Seiten. Bei einer Tabelle mit 1.000 Inserts pro Minute entstehen täglich tausende Page Splits — und die Fragmentierung klettert zuverlässig Richtung 80%.

     

    Tipp: NEWSEQUENTIALID() als GUID-Kompromiss

    Wer aus Applikationsgründen eine GUID als Primary Key braucht, sollte NEWSEQUENTIALID() statt NEWID() verwenden. NEWSEQUENTIALID() erzeugt monoton steigende GUIDs, die wie IDENTITY-Werte immer ans Ende des B-Baums wandern — keine Page Splits, deutlich weniger Fragmentierung.

     

    Der Haken: Die Werte sind nicht wirklich zufällig, können nicht clientseitig generiert werden und sind nur für DEFAULT-Constraints verfügbar. Für Systeme, die GUIDs extern erzeugen müssen (z.B. verteilte Applikationen), bleibt oft nur NEWID() — dann sollte der Clustered Index auf einer separaten IDENTITY-Spalte liegen.

     

    Heap: Die Tabelle ohne Clustered Index

    Eine Tabelle ohne Clustered Index heißt Heap. SQL Server speichert die Zeilen in beliebiger Reihenfolge auf den Datenseiten — neue Zeilen landen einfach auf der nächsten freien Seite. Ein Heap hat keine B-Baum-Struktur, also auch keine Fragmentierung im klassischen Sinne, dafür aber Forwarded Records: Wenn eine Zeile durch ein UPDATE wächst und auf ihrer Seite nicht mehr passt, wird sie verschoben und an der alten Stelle ein Forwarding-Pointer hinterlassen. Non-Clustered Indizes auf Heaps zeigen auf die ursprüngliche RID (Row Identifier), also auf den Forwarding-Pointer — was bei vielen Updates zu einer Lesekette führt.

    Heaps sind sinnvoll für Insert-Only-Tabellen: Staging, Event-Logs, Queuing-Tabellen, bei denen die Daten einmal geschrieben und dann entweder komplett sequenziell gelesen oder gelöscht werden. Für alles andere gilt: Clustered Index anlegen.

    Non-Clustered Index: Der Zeiger im Zeiger

    Ein Non-Clustered Index hat eine eigene B-Baum-Struktur, die separat von den Datenseiten der Tabelle lebt. Auf dem Leaf Level des NC-Index befinden sich: die indizierten Key-Spalten plus — als "Lesezeichen" — entweder der Clustered Key der Tabelle (bei Tabellen mit Clustered Index) oder die RID (Row Identifier, bei Heaps). SQL Server nutzt dieses Lesezeichen, um nach einem Index Seek auf dem NC-Index die eigentliche Datenzeile in der Tabelle nachzuschlagen.

    Der Key Lookup und warum er teuer wird

    Enthält eine Query Spalten, die nicht im NC-Index vorhanden sind, muss SQL Server für jede gefundene Zeile einen Key Lookup (oder RID Lookup beim Heap) durchführen: einmal den NC-Index lesen, dann nochmal den Clustered Index mit dem gefundenen Key traversieren. Für 10 Zeilen ist das harmlos. Für 50.000 Zeilen pro Sekunde ist es ein IO-Desaster. Wir haben diesen Mechanismus in Kapitel 15 am Ausführungsplan gezeigt. Die Lösung ist ein Covering Index.

    Covering Index mit INCLUDE

    Ein Covering Index enthält alle Spalten, die eine Query braucht — sowohl für die Suche (WHERE-Klausel) als auch für die Ausgabe (SELECT-Liste). Die Suchspalten landen im Key-Teil des Index (beeinflussen die Sortierung im B-Baum), die zusätzlichen Ausgabespalten in der INCLUDE-Klausel (nur auf dem Leaf Level, kein Einfluss auf die Sortierung im Baum, kein Overhead für Intermediate und Root Pages).

    -- Praxisbeispiel: Abfrage auf Bestellungen nach KundenID und Datum.
    -- Gesucht wird nach KundenID + Datum (WHERE), ausgegeben werden Betrag und Status.

     

    -- Schlechter Index — nur die Suchspalte, Key Lookup unvermeidlich:
    CREATE NONCLUSTERED INDEX IX_Bestellungen_KundenID
        ON dbo.Bestellungen (KundenID);

     

    -- Besser: Covering Index mit INCLUDE für die Ausgabespalten.
    CREATE NONCLUSTERED INDEX IX_Bestellungen_KundenID_Datum
        ON dbo.Bestellungen (KundenID, Bestelldatum)   -- Suchspalten im Key
        INCLUDE (Gesamtbetrag, Auftragsstatus);         -- Ausgabespalten in INCLUDE

     

    -- SQL Server kann diese Query jetzt komplett aus dem Index beantworten,
    -- ohne einen einzigen Key Lookup auf die Datenseiten durchzuführen.
    SELECT Gesamtbetrag, Auftragsstatus
    FROM   dbo.Bestellungen
    WHERE  KundenID    = 12345
      AND  Bestelldatum >= '2024-01-01';

    Die maximale Key-Breite für einen NC-Index beträgt 900 Bytes. INCLUDE-Spalten sind von dieser Beschränkung ausgenommen — sie dürfen auch breite Datentypen aufnehmen. Klassischer Stolperstein: NVARCHAR(450) belegt exakt 900 Bytes (2 Bytes pro Unicode-Zeichen × 450 Zeichen). Wer zwei solcher Spalten in den Key packt, läuft geradewegs gegen das Limit.

     

    [Abbildung 17.2: NC-Index-Struktur: Leaf Level enthält Key-Spalten + Clustered Key als Bookmark — ohne INCLUDE entsteht ein Key Lookup]

     

    Filtered Index: Weniger ist mehr

    Ein Filtered Index ist ein partieller Index — er enthält nur die Zeilen, die eine WHERE-Bedingung erfüllen. Das klingt nach einer Nischenlösung, ist aber in vielen Szenarien ein enormer Effizienzgewinn.

    Typische Anwendungsfälle: Tabellen mit einem Status-Feld, bei dem 95% der Zeilen den Wert "Archiv" haben und nur 5% aktiv sind. Ein herkömmlicher Index auf der Statusspalte enthält alle Zeilen — der Optimizer bevorzugt ihn oft nicht, weil die Selektivität schlecht ist. Ein Filtered Index mit WHERE Status = 'Aktiv' enthält nur die 5% relevanten Zeilen, ist entsprechend kleiner, und seine Statistiken sind präzise auf genau diese Teilmenge kalibriert. Wie sich das auf den Cardinality Estimator auswirkt, haben wir in Kapitel 16 gezeigt.

    -- Filtered Index für Soft-Delete-Muster (IsDeleted = 0 = aktive Datensätze)
    CREATE NONCLUSTERED INDEX IX_Kunden_AktiveNamen
        ON dbo.Kunden (Nachname, Vorname)
        INCLUDE (EMail, Telefon)
        WHERE IsDeleted = 0;   -- nur ~10% der Zeilen — Index ist sehr kompakt

     

    -- Weiteres Beispiel: offene Bestellungen (enger Wertebereich)
    CREATE NONCLUSTERED INDEX IX_Bestellungen_Offen
        ON dbo.Bestellungen (Bestelldatum)
        INCLUDE (KundenID, Gesamtbetrag)
        WHERE Auftragsstatus IN ('Neu', 'InBearbeitung');

     

    Warnung: Filtered Indexes und Parameter Sniffing

    Filtered Indexes können mit Parameter Sniffing in Konflikt geraten (ausführlich in Kapitel 18). SQL Server nutzt einen Filtered Index nur, wenn der Optimizer zur Compile-Zeit sicher weiß, dass die Query tatsächlich in den Filter-Bereich fällt.

     

    Bei parametrisierten Abfragen kann es passieren, dass ein gesniffer Plan den Filtered Index ignoriert, weil der ursprüngliche Parameter-Wert außerhalb des Filter-Bereichs lag. Symptom: Der Plan funktioniert für manche Parameterwerte perfekt und für andere ist er katastrophal langsam — und niemand versteht warum.

     

    Columnstore Index: Die Analytik-Waffe

    Alle bisherigen Index-Typen speichern Daten zeilenweise — alle Spalten einer Zeile liegen physisch zusammen auf der Seite. Columnstore Indexes drehen das um: Sie speichern Daten spaltenweise. Alle Werte von Spalte A liegen zusammen, alle Werte von Spalte B liegen zusammen. Das klingt zunächst seltsam, hat aber für analytische Workloads dramatische Vorteile.

    Wie Columnstore-Speicherung funktioniert

    Columnar Storage ermöglicht zwei Dinge, die zusammen einen Speedup von Faktor 10–100× gegenüber Row-Store ergeben können: Erstens exzellente Kompression — gleichartige Werte in einer Spalte komprimieren viel besser als gemischte Zeilen. Typisch sind 5–10× Kompression gegenüber Row-Store. Zweitens Batch Mode Processing: SQL Server verarbeitet bei Columnstore-Zugriffen bis zu 900 Zeilen in einem einzigen CPU-Batch statt eine Zeile nach der anderen. Das ist der entscheidende Performance-Multiplikator, den wir in Kapitel 15 bei den Ausführungsplan-Operatoren bereits erwähnt haben.

     

    Eigenschaft

    Clustered Columnstore

    Non-Clustered Columnstore

    Enthält alle Tabellenspalten?

    Ja — der Columnstore ist die Tabelle

    Nein — Teilmenge der Spalten

    Datenänderungen

    Über Delta Store (Row-Store-Puffer)

    Tabelle bleibt Row-Store, NC-Columnstore liest mit

    Typische Nutzung

    Data Warehouse, reine Lesetabellen

    Dual-Use: OLTP + Reporting auf einer Tabelle

    B-Baum-Zugriff noch möglich?

    Nein (kein Row-Store mehr vorhanden)

    Ja — Row-Store-Tabelle bleibt bestehen

    Clustered vs. Non-Clustered Columnstore im Vergleich

     

    Delta Store und Tuple Mover

    Columnstore-Segmente sind komprimiert und damit nicht direkt änderbar. Neue oder geänderte Zeilen landen zunächst im Delta Store — einem normalen B-Baum-Index, der als Puffer dient. Der Tuple Mover ist ein Hintergrundprozess, der den Delta Store regelmäßig leert und die Zeilen in komprimierte Columnstore-Segmente überführt. Während eine Zeile im Delta Store liegt, wird sie bei einem Columnstore-Scan zusätzlich berücksichtigt — das ist transparent, kostet aber minimal Performance. Bei sehr vielen kleinen Inserts kann der Delta Store zum Engpass werden, weil er die Kompressionsvorteile des Columnstore nicht ausschöpft.

     

    Hinweis: Wann Columnstore sinnvoll ist — und wann nicht

    Sinnvoll: Reporting-Abfragen über Millionen von Zeilen, Aggregationen (SUM, COUNT, AVG) über breite Tabellen, Data Warehouse, historische Daten, Tabellen mit > 1 Mio. Zeilen bei selten ändernden Daten. Batch Mode Processing allein kann hier den Unterschied zwischen 30 Sekunden und 2 Sekunden ausmachen.

     

    Problematisch: OLTP mit häufigen Einzelzeilen-Updates (jeder Delta-Store-Eintrag ist ein kleiner Row-Store-Index — Overhead steigt mit der Update-Frequenz). Point Queries (suche Zeile mit ID = 42): Columnstore muss alle Segmente prüfen, ein B-Baum findet das in 3 Reads. Kleine Tabellen unter 100.000 Zeilen: Der Overhead überwiegt den Kompressionsgewinn.

     

    Index-Design: Vom Query-Muster zum richtigen Index

    Ein Index entsteht nicht aus dem Gefühl heraus, dass eine Tabelle "irgendwie mehr Indizes braucht". Er entsteht aus der Analyse konkreter Query-Muster. Wer Indizes nach Bauchgefühl anlegt, endet mit dem Trendforge-Phänomen: 23 Indizes auf einer Tabelle, von denen 11 nie genutzt werden. Wer Indizes aus Query-Mustern ableitet, bekommt ein schlankes, effizientes Index-Set.

    Schritt 1: Query-Muster analysieren

    Bevor ein neuer Index angelegt wird, stellen sich drei Fragen: Welche Spalten stehen in der WHERE-Klausel? Welche Spalten werden in der SELECT-Liste benötigt? Welche Sortierreihenfolge braucht die Query (ORDER BY)? Die Antwort auf diese drei Fragen ergibt direkt den Index-Entwurf: WHERE-Spalten mit Gleichheitsfiltern kommen zuerst in den Key, dann Ungleichheitsfilter (BETWEEN, >, <), dann kommt die ORDER BY-Spalte falls sie noch nicht im Key ist. SELECT-Spalten, die nicht im Key landen sollen, kommen in INCLUDE.

    Diese Reihenfolge ist keine Willkür — sie folgt aus der B-Baum-Logik. SQL Server kann einen Index nur dann für einen Seek nutzen, wenn die führenden Schlüsselspalten durch die WHERE-Klausel eingeschränkt werden. Ein Index auf (Datum, KundenID) hilft einer Query mit WHERE KundenID = 123 nicht — SQL Server müsste den gesamten Index scannen, weil die führende Spalte (Datum) nicht gefiltert wird. Wir haben dieses Prinzip im Kontext von SARGability in Kapitel 21 vertieft.

    -- Analyse-Query: Welche Queries laufen auf einer bestimmten Tabelle am häufigsten,
    -- und welche Spalten nutzen sie? Ausgangspunkt für den Index-Design-Prozess.

     

    SELECT TOP 20
        qs.execution_count                      AS Ausfuehrungen,
        qs.total_logical_reads / qs.execution_count AS AvgLogicalReads,
        qs.total_elapsed_time / qs.execution_count  AS AvgDauerMicrosek,
        SUBSTRING(st.text, 1, 200)              AS QueryText
    FROM sys.dm_exec_query_stats AS qs
    CROSS APPLY sys.dm_exec_sql_text(qs.sql_handle) AS st
    -- Nur Queries die die Zieltabelle betreffen:
    WHERE st.text LIKE '%Bestellungen%'
      AND qs.execution_count > 100
    ORDER BY qs.total_logical_reads DESC;

     

    -- Die TOP-Queries nach Logical Reads zeigen, wo Indizes am meisten helfen würden.
    -- Logical Reads = direkte Korrelation zu Buffer Pool-Bedarf und IO-Last.

    Schritt 2: Den richtigen Index-Typ wählen

    Nicht jeder Query-Typ profitiert vom gleichen Index-Typ. Die folgende Tabelle hilft bei der Entscheidung:

     

    Query-Muster

    Empfohlener Index-Typ

    Begründung

    Punktsuche (WHERE ID = x)

    NC-Index auf Suchspalte

    Minimaler Lookup-Pfad durch B-Baum

    Bereichssuche (WHERE Datum BETWEEN)

    NC-Index, Datumsspalte als letzter Key

    Bereichsfilter als letzte Key-Spalte, davor Gleichheitsfilter

    SELECT mit vielen Ausgabespalten

    Covering Index mit INCLUDE

    Key Lookup vermeiden

    Soft Delete / NULL-Mehrheit

    Filtered Index

    Kompakter Index, bessere Statistiken

    Reporting über Millionen Zeilen

    Columnstore Index

    Batch Mode, Kompression

    Top-N Queries mit Sortierung

    NC-Index mit passender Spaltenreihenfolge

    Index kann Order by ohne Sort-Operator erfüllen

    Index-Typ nach Query-Muster

     

    Schritt 3: Index testen, bevor er produktiv geht

    Einen neuen Index anlegen und blind auf Performance-Verbesserung hoffen ist keine Methode. Die richtige Vorgehensweise: Vor dem Anlegen die Query mit SET STATISTICS IO ON und SET STATISTICS TIME ON ausführen und die Baseline-Werte notieren. Dann den Index anlegen. Dann erneut ausführen und die Werte vergleichen. Logical Reads sollten deutlich gesunken sein — wenn sie gleich geblieben sind, nutzt der Optimizer den neuen Index nicht. Das kann passieren, wenn der Cardinality Estimator die Selektivität falsch einschätzt oder, wenn der Index durch eine SARGability-verletzende WHERE-Klausel nicht nutzbar ist (Kapitel 21).

    -- Baseline messen vor dem Index-Anlegen:
    SET STATISTICS IO ON;
    SET STATISTICS TIME ON;

     

    -- Die Ziel-Query ausführen:
    SELECT Gesamtbetrag, Auftragsstatus
    FROM   dbo.Bestellungen
    WHERE  KundenID = 12345
      AND  Bestelldatum >= '2024-01-01';

     

    -- Output lesen: "logical reads: 4821" = 4821 × 8 KB = 38 MB Lesezugriff.
    -- Nach dem Index-Anlegen sollte dieser Wert drastisch sinken.
    -- Wenn nicht: WITH (INDEX = neuer_index_name) erzwingen und Plan prüfen.

     

    SET STATISTICS IO OFF;
    SET STATISTICS TIME OFF;

    Index-Fragmentierung: Wenn der Baum unordentlich wird

    Index-Fragmentierung entsteht, wenn die logische Reihenfolge der Seiten im Index nicht mehr mit der physischen Reihenfolge auf dem Datenträger übereinstimmt, oder, wenn Seiten nicht vollständig gefüllt sind. Es gibt zwei Typen:

  • Externe Fragmentierung (Logical Scan Fragmentation): Seiten sind in logisch falscher Reihenfolge auf dem Datenträger verteilt. SQL Server muss beim Scan "springen" statt sequenziell zu lesen. Auf HDD-Systemen kostet jeder Seek 4–12 ms. Bei fragmentierten Indizes auf mechanischen Platten summiert sich das dramatisch — eine sequenzielle Leseoperation wird zur Slalom-Fahrt durch die Plattengeometrie.
  • Interne Fragmentierung (niedrige Page Fullness): Seiten sind nur halb voll. Eine Tabelle, die 1.000 Seiten belegen könnte, belegt tatsächlich 2.000, weil jede Seite nur zu 50% gefüllt ist. Das verdoppelt den IO-Aufwand für Table Scans und erhöht den Buffer Pool Bedarf.
  • Fragmentierung messen mit sys.dm_db_index_physical_stats

    -- Fragmentierungsanalyse für alle Indizes der aktuellen Datenbank.
    -- avg_fragmentation_in_percent = externe Fragmentierung (Seitenreihenfolge falsch)
    -- avg_page_space_used_in_percent = Seitenfüllgrad (niedrig = interne Fragmentierung)
    -- page_count < 100: für kleine Objekte ist Fragmentierung meist irrelevant

     

    SELECT
        OBJECT_NAME(ips.object_id)          AS Tabellenname,
        i.name                              AS Indexname,
        ips.index_type_desc                 AS Indextyp,
        ips.avg_fragmentation_in_percent    AS FragmentierungProzent,
        ips.page_count                      AS Seitenanzahl,
        ips.avg_page_space_used_in_percent  AS SeitenfuellungProzent
    FROM sys.dm_db_index_physical_stats(
        DB_ID(),        -- aktuelle Datenbank
        NULL,           -- alle Tabellen
        NULL,           -- alle Indizes
        NULL,           -- alle Partitionen
        'LIMITED'       -- LIMITED ist schnell; DETAILED präziser aber langsamer
    ) AS ips
    JOIN sys.indexes AS i
        ON  ips.object_id = i.object_id
        AND ips.index_id  = i.index_id
    WHERE ips.page_count > 100
      AND ips.avg_fragmentation_in_percent > 5
    ORDER BY ips.avg_fragmentation_in_percent DESC;

    Die Faustregel — und ihre wichtigen Ausnahmen

    Die klassische Faustregel für Index-Wartung lautet: unter 5% Fragmentierung nichts tun, 5–30% REORGANIZE, über 30% REBUILD. Diese Regel ist nicht falsch, aber sie hat einen blinden Fleck: den Datenträger-Typ.

     

    Fragmentierung

    HDD (mechanisch)

    SSD/NVMe

    < 5%

    Ok — kein Handlungsbedarf

    Ok

    5–30%

    REORGANIZE empfehlenswert

    Meist vernachlässigbar

    30–80%

    REBUILD — IO-Penalty messbar

    Nur bei extremen Werten sinnvoll

    > 80%

    Sofortiger REBUILD — IO-Performance massiv beeinträchtigt

    REORGANIZE ausreichend

    Handlungsempfehlung Fragmentierung nach Datenträger-Typ

     

    Auf NVMe-Storage gibt es keine nennenswerte Seek-Zeit: Das Laufwerk greift auf beliebige Seiten in ~0,03 ms zu, egal wo sie physisch liegen. Externe Fragmentierung kostet auf NVMe kaum etwas. Ein nächtlicher REBUILD-Job, der einen 200-GB-Index vollständig umschreibt, erzeugt IO-Last, Locking-Risiken und braucht temporären Speicherplatz für die Kopie — das ist auf einem NVMe-System oft mehr Schaden als Nutzen.

     

    Praxisbeispiel: Sparfuchs & Partner: 97% Fragmentierung — der perfekte Sturm

    Bei Sparfuchs (Instanz BUCHSQL01) haben wir Index-Fragmentierungswerte von 97% auf den Kerntabellen gemessen — ausführlich analysiert in Kapitel 33. Das allein wäre noch handhabbar. Aber Sparfuchs betreibt alle Daten auf einer einzigen HDD-Spindel: OS, TempDB, Daten, Log und Backup teilen sich denselben mechanischen Kopf.

     

    Eine fragmentierte Tabelle auf NVMe: kein echtes Problem. Dieselbe Tabelle auf einer HDD mit IO-Latenz p95 = 312 ms Write: Jeder Scan durch den fragmentierten Index bedeutet hunderte von Seek-Operationen à 8–12 ms. Das ergibt bei einem normalen Steuerberatungs-Report mehrere Sekunden reiner Seek-Zeit — bevor SQL Server auch nur die erste Nutzdaten-Seite gelesen hat.

     

    Fragmentierung 97% auf einer HDD-Spindel mit 312 ms p95-Latenz ist keine Performance-Auffälligkeit. Das ist ein systemischer Defekt.

     

    Index-Wartungsstrategie: Klug statt fleißig

    Das Ziel einer guten Index-Wartungsstrategie ist nicht, jeden Morgen alle Indizes auf 0% Fragmentierung zu bringen. Das Ziel ist, die IO-Performance auf dem tatsächlichen System im akzeptablen Bereich zu halten — mit möglichst wenig Wartungsaufwand, Sperren und Ressourcenverbrauch. Dazu muss man erst verstehen, was REORGANIZE und REBUILD tatsächlich unterscheidet.

    REORGANIZE vs. REBUILD

     

    Kriterium

    REORGANIZE

    REBUILD

    Fragmentierungsziel

    5–30%

    > 30%

    Sperren

    Online, keine exklusive Sperre

    Offline: exklusive Tabellensperre (ohne ONLINE-Option)

    Enterprise-Feature

    Nein

    ONLINE=ON benötigt Enterprise Edition

    Statistiken aktualisiert?

    Nein — separater Job nötig

    Ja — automatisch mit FULLSCAN

    Temporärer Speicherbedarf

    Minimal (in-place)

    Ca. 1,2× Index-Größe als temporäre Kopie

    Unterbrechbar?

    Ja, jederzeit

    Ab SQL 2017 mit RESUMABLE=ON, sonst nein

    REORGANIZE vs. REBUILD im Vergleich

     

    Ein wichtiger Punkt, der in der Praxis oft vergessen wird: Nach einem REORGANIZE werden Statistiken nicht aktualisiert. Wenn du nachts REORGANIZE läufst, musst du danach separat UPDATE STATISTICS ausführen — sonst hat der Optimizer weiterhin veraltete Histogramme und erzeugt suboptimale Pläne. Das ist der häufigste Fehler bei selbstgebauten Wartungsjobs. Die Auswirkungen veralteter Statistiken auf den Cardinality Estimator haben wir in Kapitel 16 ausführlich behandelt.

    Ola Hallengrens IndexOptimize — der Community-Standard

    Den besten Ausgangspunkt für adaptive Index-Wartung liefert das freie Script IndexOptimize von Ola Hallengren (ola.hallengren.com). Es ist der inoffizielle Community-Standard, wird aktiv gepflegt und läuft auf SQL Server 2008 bis 2022 sowie auf Azure SQL. Was es besser macht als ein simpler "REBUILD alles nachts"-Job:

  • Adaptiv: Fragmentierungsgrad wird pro Index gemessen, dann automatisch REORGANIZE oder REBUILD entschieden (konfigurierbare Schwellwerte)
  • Granular: Page Count-Filter einstellbar — kleine Indizes werden ignoriert
  • Sicher: Unterstützt ONLINE REBUILD, respektiert Maintenance Windows, protokolliert alles
  • Statistiken-Integration: Kann nach REORGANIZE optional UPDATE STATISTICS ausführen — das Vergessene bei handgestrickten Jobs
  • Fill Factor: Puffer für zukünftige Inserts

    Der Fill Factor legt fest, wie voll eine Indexseite beim REBUILD befüllt wird. Standard für OLTP-Tabellen mit häufigen Updates und Inserts ist 80%: Jede Seite wird zu 80% befüllt, 20% bleiben als Puffer für neue Einträge. Das reduziert Page Splits, erhöht aber den Speicherbedarf — bei 80% Fill Factor braucht ein Index rechnerisch 25% mehr Seiten als bei 100%.

    Für Read-Only-Tabellen oder Archivdaten: Fill Factor 100%. Für OLTP-Tabellen mit wenigen Inserts und Updates: 90%. Für Tabellen mit intensivem Insert-Betrieb (Logging, Time-Series): 70–80%. Es gibt keine universell richtige Zahl — beobachte Page Splits über sys.dm_db_index_operational_stats.leaf_allocation_count und passe den Fill Factor entsprechend an.

    -- Page Split Überwachung: Wie oft werden pro Index neue Seiten alloziiert?
    -- leaf_allocation_count = Anzahl der Page Splits seit letztem Neustart.
    -- Hoher Wert deutet auf zu hohen Fill Factor oder schlechten Clustered Key hin.

     

    SELECT
        OBJECT_NAME(ios.object_id)      AS Tabellenname,
        i.name                          AS Indexname,
        ios.leaf_allocation_count       AS PageSplits,
        ios.leaf_insert_count           AS Inserts,
        ios.leaf_update_count           AS Updates,
        -- Verhältnis: Wie oft führt ein Insert/Update zu einem Page Split?
        CASE WHEN ios.leaf_insert_count + ios.leaf_update_count > 0
             THEN ROUND(100.0 * ios.leaf_allocation_count /
                  (ios.leaf_insert_count + ios.leaf_update_count), 2)
             ELSE 0
        END                             AS SplitQuoteProzent
    FROM sys.dm_db_index_operational_stats(DB_ID(), NULL, NULL, NULL) AS ios
    JOIN sys.indexes AS i
        ON  ios.object_id = i.object_id
        AND ios.index_id  = i.index_id
    WHERE ios.leaf_allocation_count > 1000   -- nur auffällige Kandidaten
      AND i.type_desc = 'NONCLUSTERED'      -- Clustered-Index hat eigene Dynamik
    ORDER BY ios.leaf_allocation_count DESC;

     

    Praxisbeispiel: Musterwerk GmbH: Fill Factor-Tuning spart 40% Buffer Pool

    Bei Musterwerk (Instanz MWSQL01) haben wir nach Kapitel 9 (Wait Statistics) festgestellt, dass PAGEIOLATCH_SH für 28% der gesamten Wartezeit verantwortlich war. Die Ursache: Der Standard-Fill-Factor von 100% auf der Bestellungstabelle (hohe Insert-Rate) führte zu massiven Page Splits — leaf_allocation_count zeigte 180.000 Splits pro Stunde.

     

    Maßnahme: Fill Factor auf 80% gesetzt, anschließend REBUILD. Ergebnis nach 24 Stunden: Page Splits auf 8.000 pro Stunde gesunken (-96%), PAGEIOLATCH_SH-Wartezeit von 28% auf 6% gefallen. Der Buffer Pool-Bedarf stieg durch die größeren Indizes um 15%, was bei den 64 GB RAM auf MWSQL01 kein Problem war. Das vollständige Musterwerk-Profil folgt in Kapitel 32.

     

    Missing Index DMVs: Tipps vom Optimizer — mit Vorsicht genießen

    SQL Server protokolliert während der Query-Ausführung, welche Indizes dem Optimizer gefehlt haben. Diese Information landet in drei DMVs, die zusammen einen "Impact-Score" berechnen — eine Schätzung, wie viel Verbesserung ein fehlender Index bringen würde.

    Der Impact-Score errechnet sich aus: user_seeks × avg_total_user_cost × avg_user_impact. user_seeks ist die Anzahl der Zugriffe seit dem letzten Neustart. avg_total_user_cost ist die durchschnittliche geschätzte Query-Kosten ohne den fehlenden Index. avg_user_impact ist die geschätzte prozentuale Verbesserung. Ein Score von 1.000.000 bedeutet: diese Query wurde oft ausgeführt, war teuer, und ein passender Index würde sie stark beschleunigen.

    -- Top-10 fehlende Indizes nach geschätztem Impact-Score.
    -- WICHTIG: Werte werden bei jedem SQL-Server-Neustart zurückgesetzt!

     

    SELECT TOP 10
        ROUND(
            s.avg_total_user_cost *
            s.avg_user_impact *
            (s.user_seeks + s.user_scans),
            0
        )                                          AS ImpactScore,
        s.user_seeks                               AS AnzahlSeeks,
        s.user_scans                               AS AnzahlScans,
        s.avg_user_impact                          AS GeschImprovementProzent,
        d.equality_columns                         AS GleichheitsFilter,
        d.inequality_columns                       AS UngleichheitsFilter,
        d.included_columns                         AS VorgeschlageneIncludes,
        OBJECT_NAME(d.object_id)                   AS Tabellenname,
        'CREATE NONCLUSTERED INDEX IX_missing' +
        CAST(d.index_handle AS VARCHAR(10)) +
        ' ON ' + OBJECT_NAME(d.object_id) +
        ' (' + ISNULL(d.equality_columns, '') +
        CASE WHEN d.inequality_columns IS NOT NULL
             THEN ', ' + d.inequality_columns ELSE '' END +
        ')' +
        CASE WHEN d.included_columns IS NOT NULL
             THEN ' INCLUDE (' + d.included_columns + ')' ELSE '' END +
        ';'                                        AS CreateStatement
    FROM sys.dm_db_missing_index_group_stats   AS s
    JOIN sys.dm_db_missing_index_groups        AS g
        ON  s.group_handle = g.index_group_handle
    JOIN sys.dm_db_missing_index_details       AS d
        ON  g.index_handle = d.index_handle
    WHERE d.database_id = DB_ID()
    ORDER BY ImpactScore DESC;

     

    Warnung: Missing Index-Empfehlungen nicht blind umsetzen

    Die Missing Index DMVs liefern Hinweise, keine Befehle. Vor jedem neuen Index prüfen:

     

    1. Duplikate: Gibt es bereits einen ähnlichen Index? Overlapping Indexes sind Speicher- und Schreibverschwendung. Prüfe sys.dm_db_index_usage_stats für bestehende Indizes.

     

    2. Zu breite Indizes: Der Optimizer empfiehlt manchmal Indizes mit 8 Include-Spalten. Ein solcher Index ist fast so teuer wie ein zweiter Clustered Index — und erhöht die Schreiblast entsprechend.

     

    3. Einmalige Reporting-Jobs erzeugen hohe Impact-Scores: Ein teurer Report, der einmal täglich läuft, hat einen hohen Score. Ein neuer Index für diesen Report kostet aber 23 Stunden täglich durch erhöhte Write-Last — für eine Stunde bessere Laufzeit.

     

    4. Timing: DMV-Daten seit letztem Neustart. Nach einem Neustart vor 3 Tagen sind Low-Impact-Scores möglicherweise unterschätzt.

     

    Index-Redundanz: Was du nicht brauchst, schadet trotzdem

    Jeder Index kostet. Nicht nur Speicherplatz, sondern vor allem Schreiblast: Jedes INSERT muss alle Indizes der Tabelle aktualisieren. Jedes UPDATE aktualisiert alle Indizes, deren Schlüsselspalten betroffen sind. Jedes DELETE ebenfalls. Eine Tabelle mit 23 Indizes — wie wir sie bei Trendforge auf der Produkttabelle vorgefunden haben — löst bei jedem INSERT intern 23 B-Baum-Updates aus. Das ist keine Index-Strategie, das ist Index-Chaos.

    Doppelte und überlappende Indizes finden

    -- Alle Indizes einer Tabelle mit ihren Key- und Include-Spalten anzeigen.
    -- Gleiche oder nahezu identische Key-Kombinationen sind Kandidaten zum Entfernen.

     

    SELECT
        i.name                                          AS Indexname,
        i.type_desc                                     AS Typ,
        STRING_AGG(CASE WHEN ic.is_included_column = 0
                   THEN c.name END, ', ')
            WITHIN GROUP (ORDER BY ic.key_ordinal)      AS KeySpalten,
        STRING_AGG(CASE WHEN ic.is_included_column = 1
                   THEN c.name END, ', ')
            WITHIN GROUP (ORDER BY ic.key_ordinal)      AS IncludeSpalten
    FROM sys.indexes       AS i
    JOIN sys.index_columns AS ic
        ON  i.object_id = ic.object_id
        AND i.index_id  = ic.index_id
    JOIN sys.columns       AS c
        ON  ic.object_id = c.object_id
        AND ic.column_id = c.column_id
    WHERE i.object_id = OBJECT_ID('dbo.Produkte')  -- Tabellenname anpassen
      AND i.type > 0                                -- Heap (type=0) ausblenden
    GROUP BY i.name, i.type_desc
    ORDER BY KeySpalten;

    Unbenutzte Indizes aufspüren

    -- Indizes mit null oder sehr wenigen Seeks/Scans seit dem letzten Neustart.
    -- user_updates = wie oft wurde der Index durch Schreiboperationen gepflegt?
    -- Hohe user_updates bei user_seeks = 0: der Index wird nie für Reads genutzt,
    -- kostet aber ständig Schreib-Overhead. Guter Kandidat zum Entfernen.

     

    SELECT
        OBJECT_NAME(i.object_id)    AS Tabellenname,
        i.name                      AS Indexname,
        i.type_desc                 AS Typ,
        ISNULL(u.user_seeks,   0)   AS Seeks,
        ISNULL(u.user_scans,   0)   AS Scans,
        ISNULL(u.user_lookups, 0)   AS Lookups,
        ISNULL(u.user_updates, 0)   AS Schreibupdates
    FROM sys.indexes AS i
    LEFT JOIN sys.dm_db_index_usage_stats AS u
        ON  i.object_id  = u.object_id
        AND i.index_id   = u.index_id
        AND u.database_id = DB_ID()
    WHERE i.object_id        = OBJECT_ID('dbo.Produkte')
      AND i.index_id        > 1        -- Clustered Index behalten
      AND i.is_primary_key   = 0       -- Primary Key nicht entfernen
      AND i.is_unique_constraint = 0
    ORDER BY Schreibupdates DESC;

     

    Warnung: DMV-Daten werden nach jedem Neustart zurückgesetzt

    sys.dm_db_index_usage_stats zeigt Nutzungsdaten nur seit dem letzten SQL Server-Neustart. Drei Tage nach einem Neustart sind Indizes mit user_seeks = 0 möglicherweise gar nicht unbenutzt — die Abfragen, die sie nutzen, liefen einfach noch nicht.

     

    Faustregel: Mindestens 4 Wochen beobachten (inklusive Monatsabschluss-Jobs und periodischer Batch-Reports), bevor ein Index als "unbenutzt" markiert und zum Löschen freigegeben wird.

     

    Bei Trendforge haben wir auf der Produkttabelle 23 Indizes gefunden, von denen 11 seit mindestens 30 Tagen keinerlei Seeks oder Scans verzeichnet hatten — aber jeweils mehrere Millionen user_updates. Das bedeutet: Diese Indizes wurden täglich tausende Male durch Schreiboperationen aktualisiert, ohne jemals für eine einzige Leseabfrage genutzt zu werden. Nach dem Entfernen sank die INSERT-Latenz auf dieser Tabelle von durchschnittlich 28 ms auf 4 ms. Der vollständige Befund folgt in Kapitel 34.

    Index-Monitoring: Der Blick ins laufende System

    Index-Strategie ist kein Projekt mit Anfang und Ende — es ist ein Betriebsprozess. Ein Index, der heute perfekt passt, kann in sechs Monaten durch veränderte Query-Muster, Datenwachstum oder neue Features nutzlos werden. Regelmäßiges Monitoring verhindert, dass du im Jahresrhythmus eine Index-Inventur machen musst, bei der du dann 40 unbenutzte Indizes auf einmal entfernst — und jedesmal zitterst, ob du einen wichtigen übersehen hast.

    Was du regelmäßig überwachen solltest

  • Monatlich: sys.dm_db_index_usage_stats für alle Indizes mit user_seeks = 0 und user_updates > 10.000. Das sind die neuen Streichkandidaten. Über vier Wochen sammeln, dann entscheiden.
  • Wöchentlich: sys.dm_db_missing_index_details nach ImpactScore. Wenn ein neuer Eintrag mit Score > 500.000 auftaucht, sofort prüfen — nicht bis zum nächsten Wartungsfenster warten.
  • Täglich (oder nach Batch-Jobs): sys.dm_db_index_physical_stats im LIMITED-Modus auf die Top-20-Tabellen nach Page Count. Auf HDD-Systemen sofort handeln bei > 30%.
  • Einmalig nach Schema-Änderungen: Neue Tabellen und neue Stored Procedures sofort auf Index-Bedarf prüfen. Viele Index-Probleme entstehen bei neuen Features, die ohne Performance-Analyse deployed werden.
  • Ein pragmatischer Ansatz: Richte einen SQL Agent Job ein, der täglich die sys.dm_db_missing_index_details-Top-5 per E-Mail verschickt und einmal wöchentlich einen Report über Indizes mit user_seeks = 0 und user_updates > 50.000. Das kostet 30 Minuten Einrichtung und spart bei jedem neuen Performance-Problem eine Stunde Diagnose.

    -- Kompakte Monitoring-Query: Indizes die schreiben aber nie gelesen werden.
    -- Diese Query täglich per SQL Agent Job ausführen und Ergebnis loggen.

     

    SELECT
        DB_NAME()                           AS Datenbankname,
        OBJECT_NAME(i.object_id)            AS Tabellenname,
        i.name                              AS Indexname,
        ISNULL(u.user_seeks + u.user_scans + u.user_lookups, 0) AS Lesezugriffe,
        ISNULL(u.user_updates, 0)           AS Schreibzugriffe,
        GETDATE()                           AS Messzeitpunkt
    FROM sys.indexes AS i
    LEFT JOIN sys.dm_db_index_usage_stats AS u
        ON  i.object_id   = u.object_id
        AND i.index_id    = u.index_id
        AND u.database_id = DB_ID()
    WHERE i.index_id > 1                      -- Clustered Index immer behalten
      AND i.is_primary_key        = 0
      AND i.is_unique_constraint  = 0
      AND ISNULL(u.user_seeks + u.user_scans + u.user_lookups, 0) = 0
      AND ISNULL(u.user_updates, 0) > 10000   -- schreibt, wird aber nie gelesen
    ORDER BY ISNULL(u.user_updates, 0) DESC;

     

    Hinweis: Index-Monitoring in die Baseline-Erhebung integrieren

    Wer in Kapitel 9 die Baseline-Erhebung mit Collect-SqlPerf.ps1 eingerichtet hat (ausführlich in Kapitel 31), kann die Index-Monitoring-Query dort direkt integrieren. Das Script sammelt die Daten täglich und legt sie historisiert ab — so siehst du nicht nur den aktuellen Stand, sondern auch Trends: Welcher Index wurde letzte Woche noch genutzt und diese Woche nicht mehr? Das ist wertvoller als ein Snapshot.

     

    Partitionierung und Indizes: Partition Elimination

    Tabellen-Partitionierung unterteilt große Tabellen physisch in mehrere kleinere Einheiten — typischerweise nach einem Datumswert. Der Performance-Gewinn entsteht durch Partition Elimination: Wenn eine Query eine WHERE-Klausel auf der Partitionsspalte enthält, liest SQL Server nur die relevante(n) Partition(en) statt die gesamte Tabelle. Auf einer Faktentabelle mit 5 Jahren Transaktionsdaten kann das bedeuten, dass für eine Tagesabfrage statt 365 Mio. Zeilen nur 1 Mio. Zeilen geprüft werden.

    Indizes auf partitionierten Tabellen sollten partition-aligned sein — der Index hat dieselbe Partitionierungsstruktur wie die Tabelle. Nur dann kann SQL Server Partition Elimination auch beim Index-Zugriff anwenden. Ein nicht-alignierter Index wird beim Switch-Partition-Vorgang (Daten zwischen Partitionen verschieben) zum Problem: Der Vorgang muss dann eine exklusive Tabellensperre halten statt nur die betroffene Partition zu sperren — was auf einer Tabelle mit Produktions-Workload ein ernstes Verfügbarkeitsproblem ist.

    Partitionierung ist kein Allheilmittel und wird in diesem Buch nicht in voller Tiefe behandelt. Hier ist der Kernpunkt: Wenn du Partitionierung einsetzt, denke von Anfang an an partition-aligned Indizes. Nachträglich umbauen ist aufwendig und erfordert in der Regel Table Rebuilds.

    Ein weiterer Aspekt: Index-Wartung auf partitionierten Tabellen kann partitionsweise erfolgen. Statt die gesamte Tabelle zu rebuilden, kannst du gezielt nur die aktuelle Partition (die am häufigsten geändert wird) rebuilden und ältere Partitionen unberührt lassen. Auf einer 500-GB-Faktentabelle, bei der 490 GB historische Daten sind und 10 GB aktuelle, spart das erheblich Zeit und Ressourcen.

    Diagnose-Kästen: Index-Probleme erkennen und beheben

     

    Definition: Symptome — Woran erkennst du Index-Probleme?

    • Execution Plans zeigen Key Lookup oder RID Lookup Operatoren mit hoher Zeilenanzahl — das Signal für einen fehlenden Covering Index (Kapitel 15)

    • Hohe Logical Reads in sys.dm_exec_query_stats — viele tausend Seiten für eine Query, die eigentlich wenige Zeilen zurückgibt

    • Langsame INSERT/UPDATE/DELETE-Laufzeiten auf Tabellen mit vielen Indizes — Schreiblast durch Index-Maintenance dominiert die Antwortzeit

    • sys.dm_db_missing_index_details hat Einträge mit Impact-Score > 1.000.000

    • Wait-Typ PAGEIOLATCH_SH dominiert die Wait Statistics (Kapitel 9) — SQL Server wartet auf IO, das ein besserer Index vermeiden würde

    • Sporadisch langsame Abfragen, die manchmal 30 ms und manchmal 4 Sekunden dauern — klassisches Zeichen für einen Plan, der je nach Fragmentierungsgrad zwischen Index Seek und Table Scan wechselt

     

     

    Tipp: So misst du das — Fragmentierung und fehlende Indizes kombiniert

    Schritt 1: Fragmentierungs-Scan

    sys.dm_db_index_physical_stats im LIMITED-Modus, nur Objekte mit > 100 Seiten und > 5% Fragmentierung. Auf HDD-Systemen: alles > 30% sofort als Kandidaten markieren.

     

    Schritt 2: Missing Index Impact

    sys.dm_db_missing_index_group_stats JOIN dm_db_missing_index_groups JOIN dm_db_missing_index_details, sortiert nach ImpactScore DESC. Top-5 identifizieren und vor dem Anlegen auf Duplikate prüfen.

     

    Schritt 3: Bestehende Index-Nutzung

    sys.dm_db_index_usage_stats: user_seeks = 0 bei hohem user_updates über 4 Wochen = Kandidat zum Entfernen. Speicherbedarf und Schreiblast einsparen ohne Leseperformance zu verlieren.

     

     

    Hintergrund: Typische Fehlinterpretationen bei Index-Wartung

    "Index REBUILD jede Nacht auf alle Tabellen" — auf SSDs und NVMe ist das selten sinnvoll. Fragmentierung kostet auf Flash-Storage kaum etwas. Ein REBUILD kostet aber IO-Last, Zeit, temporären Speicherplatz und erhöhtes Locking-Risiko. Adaptive Wartung mit Fragmentierungsprüfung vorher ist der richtige Weg.

     

    "Jede Missing-Index-Empfehlung umsetzen" — führt zuverlässig zu Index-Proliferation. Mehr Indizes verlangsamen Schreiboperationen. Priorität nach Impact und Nutzungsfrequenz setzen, Duplikate vermeiden.

     

    "Mehr Indizes = bessere Performance" — stimmt nur für Leseworkloads. Eine Tabelle mit 23 Indizes und hoher Schreiblast ist langsamer als dieselbe Tabelle mit 5 gut gewählten Indizes.

     

    "Fragmentierung > 30% muss sofort behoben werden" — nein. Bewerte den Datenträger-Typ zuerst. Auf NVMe ist 30% Fragmentierung oft irrelevant. Auf einer HDD-Spindel dagegen ein echtes IO-Problem.

     

     

    Tipp: Erste Gegenmaßnahmen — Index-Triage

    1. Top-3 Missing Indexes nach Impact anlegen: Die Queries mit dem höchsten Impact-Score sind sofort identifizierbar. Vorher prüfen: Überlappen sie mit bestehenden Indizes? Wenn ja, bestehenden Index erweitern statt neuen anlegen.

     

    2. Unbenutzte Indizes mit hohem user_updates identifizieren und entfernen: Das ist die seltene Situation, wo eine Maßnahme gleichzeitig Lese- und Schreibperformance verbessert — oder zumindest Schreibperformance verbessert ohne Leseperformance zu verschlechtern.

     

    3. Fragmentierung > 30% auf HDD-Systemen sofort rebuilden: Auf Flash-Storage ist REORGANIZE bei > 30% ausreichend, falls überhaupt Handlungsbedarf besteht.

     

    4. Ola Hallengrens IndexOptimize einrichten: Das ist die dauerhaft wartungsarme Lösung für adaptive Index-Pflege — konfigurieren, als SQL Agent Job planen, fertig.

     

    Zusammenfassung

    Index-Strategie ist keine einmalige Aufgabe, sondern ein kontinuierlicher Prozess. Die wichtigsten Erkenntnisse dieses Kapitels auf einen Blick:

  • Der Clustered Key definiert die physische Tabellenstruktur. Eine schlechte Wahl (zufällige GUIDs, breite Spalten, volatile Felder) kostet dauerhaft Page Splits und Fragmentierung — und multipliziert sich mit jedem NC-Index.
  • Covering Indexes mit INCLUDE eliminieren Key Lookups vollständig. Suchspalten in den Key, Ausgabespalten in INCLUDE. Key Lookup im Plan = Covering Index fehlt.
  • Filtered Indexes sind für Tabellen mit schiefer Werteverteilung — NULL-Mehrheit, Soft Delete, Status-Spalten — oft effizienter als vollständige Indizes. Aber: Vorsicht bei Parameter Sniffing (Kapitel 18).
  • Columnstore Indexes sind für analytische Workloads ein Faktor 10–100× schneller — aber keine gute Wahl für frequente OLTP-Einzelzeilen-Updates.
  • Fragmentierung ist auf NVMe/SSD-Storage weit weniger kritisch als auf HDD. Adaptive Wartung (Ola Hallengren) statt blindem "REBUILD alles nachts".
  • Jeder Index kostet Schreiblast. Missing-Index-Empfehlungen nicht blind übernehmen, unbenutzte Indizes regelmäßig aufräumen. Trendforge-Lektion: 11 unbenutzte Indizes entfernen, INSERT-Latenz von 28 ms auf 4 ms — ohne eine Zeile Code zu ändern.
  • sys.dm_db_index_usage_stats wird bei jedem Neustart zurückgesetzt. Mindestens 4 Wochen beobachten, bevor Indizes als "unbenutzt" klassifiziert werden.
  •  

    Index-Wartung ist eine der wenigen Maßnahmen, die sowohl die Leseperformance verbessern können (durch bessere Covering Indexes und weniger Fragmentierung) als auch die Schreibperformance (durch Entfernen redundanter Indizes). Wer beide Seiten im Blick hat, bekommt ein System, das nicht nur schnell liest, sondern auch schnell schreibt — und bei dem der Wartungsjob tatsächlich die richtige Arbeit erledigt statt nur IO-Last zu erzeugen.

    Die drei Fallstudien dieses Buchs zeigen alle drei Facetten des Problems: Sparfuchs hat Fragmentierung und schlechte Hardware (Kapitel 33). Musterwerk hat solide Hardware, aber suboptimale Fill-Factor-Konfiguration (Kapitel 32). Trendforge hat hervorragende Hardware, aber Index-Proliferation durch unkontrollierten Index-Aufbau ohne Strategie (Kapitel 34). Alle drei Szenarien sind typisch — und alle drei hätten mit einer vernünftigen Index-Strategie von Anfang an vermieden werden können.

    Ausblick auf Kapitel 18

    Kapitel 18 widmet sich einem der tückischsten Performance-Phänomene überhaupt: Parameter Sniffing und Plan Cache. Du wirst sehen, wie SQL Server beim ersten Aufruf einer Stored Procedure einen Ausführungsplan "einfriert" — und wie dieser Plan für bestimmte Parameterkombinationen perfekt ist und für andere katastrophal. Direkter Bezug zu diesem Kapitel: Filtered Indexes reagieren besonders empfindlich auf falsch gesnifte Pläne, und die Qualität der Index-Statistiken (Kapitel 16) ist die Grundlage dafür, ob SQL Server beim Kompilieren eines Plans überhaupt den richtigen Index auswählt. Wer Kapitel 17 und 18 zusammen liest, versteht, warum manche Systeme auf demselben Server für manche Nutzer flott sind und für andere quälend langsam — obwohl dieselben Indizes existieren.

     

    Abb. 1: B-Tree-Struktur: Clustered vs. Non-Clustered

     

    Abb. 2: Index-Fragmentierung und Rebuild-Strategie

     

    Kapitel 18