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:
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:
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:
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
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:
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
