Transparenz & Reproduzierbarkeit

SQL-Labor: Messdaten & Versuchsaufbau

Keine geschönten Zahlen, kein Hörensagen: Auf dieser Seite stehen die Messwerte, Tabellen, Planoperatoren und die genaue Konstruktion des 10-Millionen-Zeilen-Versuchsaufbaus offengelegt. Damit Entwickler, Datenbankadministratoren und Entscheider die Zusammenhänge Schritt für Schritt nachvollziehen und direkt für die eigene Praxis lernen können.

1. Wie das Beispiel konstruiert wurde ↓ 2. Index-Messmatrix (Stufen 0–5) ↓ 3. Zehn Tiefen-Messreihen ↓
Didaktischer Leitfaden

Wie das Mess-Beispiel konstruiert wurde – und was man daraus lernt

Eine kleine Tabelle mit 10.000 Zeilen verzeiht fast jeden Architekturfehler: Sie passt komplett in den RAM-Cache, und selbst ein fehlender Index fällt mit wenigen Millisekunden kaum auf. Erst bei Millionen Zeilen trennt sich Zufall von echter Beherrschung der SQL-Server-Engine.

Konstruktionsprinzip 1

Warum 10 Millionen synthetische Zeilen?

In Kundenprojekten gilt strikte berufliche Diskretion: Echtdaten von Kunden verlassen niemals das Kundensystem. Um Kunden und Entwicklerteams trotzdem den empirischen Beweis zu liefern, habe ich eine typische Unternehmens-Bewegungstabelle (Bestellungen / Buchungen) synthetisch generiert.

10 Millionen Datensätze zwingen die SQL-Engine dazu, reale physische I/O-Pfade zu beschreiten. Der Clustered Index belegt rund 350.000 Datenseiten (ca. 2,8 GB). Hier lässt sich exakt nachweisen, wie der Query Optimizer arbeitet, wenn die Datenmenge das CPU-Cache-Limit übersteigt.

Konstruktionsprinzip 2

Die Testtabelle und das Schema

Die Struktur bildet ein klassisches operatives Geschäftsmodell ab:

CREATE TABLE dbo.KundenBewegung (
    Id             bigint IDENTITY(1,1) NOT NULL,
    Nachname       varchar(50)  NOT NULL,
    Vorname        varchar(50)  NOT NULL,
    Stadt          varchar(50)  NOT NULL,
    GeschaeftsJahr smallint     NOT NULL,
    Umsatz         decimal(12,2) NOT NULL,
    Bemerkung      varchar(200) NULL,
    CONSTRAINT PK_KundenBewegung PRIMARY KEY CLUSTERED (Id)
);

Die Verteilung: Häufige Nachnamen (z. B. „Müller“ ca. 270.000 Treffer), realistische Städte-Kardinalität und Geschäftsjahre von 2018 bis 2026.

Konstruktionsprinzip 3

Die Testabfrage: Warum genau diese Filter- und Spaltenkombination?

In der Praxis suchen Benutzer selten nur nach einer einzigen Spalte. Eine typische Abfrage aus ERP, CRM oder Reporting lautet:

SELECT Nachname, Vorname, Stadt, GeschaeftsJahr, Umsatz
FROM dbo.KundenBewegung
WHERE Nachname = @Nachname
  AND Stadt = @Stadt
  AND GeschaeftsJahr >= @GeschaeftsJahr;

Die architektonische Absicht hinter dieser Konstruktion:

Wichtiges Konzept

Der „Tipping Point“: Warum Stufe 1 ignoriert wird

In Stufe 1 legen wir einen Index auf nur eine Spalte an: CREATE INDEX IX_Nachname ON KundenBewegung(Nachname). Viele Entwickler erwarten, dass die Abfrage nun schnell wird. Doch SQL Server ignoriert den Index komplett und macht weiterhin einen vollen Table Scan!

Die mathematische Ursache: Der Name „Müller“ liefert 271.384 Zeilen. Ein Seek auf IX_Nachname fände zwar schnell die Treffer, müsste dann aber 271.384 Mal einzeln per wahlfreiem I/O in den Clustered Index springen, um Vorname, Stadt und Umsatz nachzulesen. Da die gesamte Tabelle nur 350.000 Seiten hat, errechnet der Optimizer: 271.000 wahlfreie Einzelsprünge kosten mehr Zeit als die 350.000 Seiten einfach sequenziell komplett durchzulesen. Das ist der Tipping Point.

Die saubere Währung

Warum „Logical Reads“ die ehrlichste Messzahl sind

Laufzeiten in Millisekunden schwanken: Sie hängen von CPU-Takt, Thermal Throttling, Virtualisierung und Hintergrundaktivitäten des Betriebssystems ab.

Die einzig verlässliche, deterministische Größe in SQL Server sind die Logical Reads (logische Lesevorgänge von 8-KB-Seiten). Ob ein System 350.000 Seiten aus dem RAM oder von einer NVMe liest: Die Arbeit der CPU, die Anzahl der Speicherzugriffe und das Verdrängen anderer Daten aus dem Buffer Cache sind identisch. Ein Tuning, das die Reads von 350.000 auf 11 senkt, war im Labor um Größenordnungen schneller. Weniger Lesevorgänge bedeuten auf jedem System weniger Arbeit; der genaue Faktor hängt von der Hardware ab.

Laborergebnisse · Rohdaten

Die vollständige Messmatrix (Stufen 0 bis 5)

Vollautomatisiert per PowerShell und T-SQL gemessen. Kaltstart vor jedem Durchlauf über CHECKPOINT; DBCC DROPCLEANBUFFERS;. Ausführungsplan vorab kompiliert. Alle Messwerte sind Mittelwerte aus wiederholten Durchläufen (Stand: 15.10.2026).

Ausgangszustand (Stufe 0 · Table Scan) 1.906 ms · 349.726 Reads
Optimiert (Stufe 4 · Covering Index) 5,3 ms · 11 Reads
Effizienz-Faktor ×358 schneller (×31.792 weniger I/O)
Vollständige Labormessung je Indexstufe bei 1 Million und 10 Millionen Datensätzen
Stufe & Definition Laufzeit
1 Mio.
Laufzeit
10 Mio.
Logical Reads
10 Mio. Zeilen
Zusätzliche Indizes
(10 Mio.)
Operatoren im Ausführungsplan
Stufe 0: Kein Index
Nur PK auf Id
192 ms 1.906 ms 349.716 Reads – Clustered Index Scan, Parallelism (Gather Streams)
Stufe 1: Index auf 1 Spalte
IX(Nachname)
178 ms (×1,1) 1.843 ms (×1,0) 349.667 Reads (×1,0) 409 MB Clustered Index Scan (Optimizer verwirft Index wegen Tipping Point)
Stufe 2: Zwei Einzelindexe
IX(Nachname), IX(Stadt)
60 ms (×3,2) 126 ms (×15,1) 3.866 Reads (×90,5) 855 MB 2× Index Seek + Merge Join + Key Lookup (teure Teilschritte)
Stufe 3: Zusammengesetzter Index
IX(Nachname, Stadt, Jahr)
32 ms (×6,0) 71 ms (×26,8) 2.686 Reads (×130) 625 MB Index Seek + Key Lookup (Nested Loops für fehlende Spalten)
Stufe 4: Covering Index (INCLUDE)
IX(...) INCLUDE(Vorname, Umsatz)
5,1 ms (×37,5) 5,3 ms (×358) 11 Reads (×31.792) 1.159 MB Reiner Index Seek (0 Key Lookups, kein Parallelismus nötig)
Stufe 5: Gefilterter Covering Index
WHERE GeschaeftsJahr >= 2024
3,2 ms (×59,4) 5,4 ms (×354) 5 Reads (×69.943) 86 MB Reiner Index Seek (Index belegt nur 7,4 % des Speicherplatzes)
Wichtige Praxis-Warnung zu Stufe 5 (Gefilterte Indexe & Parameter Sniffing):

Der gefilterte Index spart gigantisch viel Speicher (86 MB statt 1.159 MB). Aber: Wird die Abfrage aus C#/.NET parametrisiert aufgerufen (z. B. WHERE GeschaeftsJahr >= @jahr), darf SQL Server den gefilterten Index zur Kompilierzeit nicht verwenden, weil der Parameterwert zur Laufzeit außerhalb des Filters liegen könnte! Ohne OPTION (RECOMPILE) fällt die Abfrage sofort auf einen Full Table Scan mit 349.538 Reads (1.878 ms) zurück.

Detail-Messreihen

Zehn tiefgehende Architektur-Engpässe im Labor vermessen

Ein fehlender Index ist nur eine von vielen Ursachen. Hier sind die exakten technischen Messergebnisse für typische Entwicklungs- und Modellierungsfallen:

Messreihe 1 · 10 Mio. Zeilen

Collation & Implizite Typkonvertierung

Wenn .NET / Entity Framework einen Parameter als Unicode (nvarchar) schickt, die Spalte in der Datenbank jedoch varchar ist:

Passend
3 Reads · 0 ms CPU
nvarchar
27.469 Reads · 3.011 ms CPU

Die Ursache: Gemäß SQL Server Data Type Precedence hat nvarchar einen höheren Vorrang als varchar. SQL Server muss daher nicht den Parameter umwandeln, sondern jede einzelne Tabellenzeile per interner Funktion konvertieren (Plan-Warnung: CONVERT_IMPLICIT). Ein Seek ist unmöglich; die CPU brennt 2,4 Sekunden lang.

Messreihe 2 · 10 Mio. Zeilen

Mengenübergabe (IN-Listen) & Plan-Cache-Bloat

Wie übergibt man 2.000 IDs aus C# an die Datenbank? Der Vergleich von dynamischen Strings vs. Table-Valued Parameters (TVP):

TVP
222 ms · 0 ms Compile
IN-Liste
589 ms · 185 ms Compile

Die Ursache: Dynamisch zusammengebaute IN (1, 2, 3...)-Strings fluten den Plan-Cache mit Einmal-Plänen. Bei 50.000 Aufrufen belegte die Ad-hoc-Variante 781 MB Arbeitsspeicher im Plan-Cache, während die parametrisierte TVP-Lösung mit 0,1 MB auskam.

Messreihe 3 · 1 Mio. Kind- / 50.000 Elternzeilen

Text-Schlüssel vs. Technischer int-Key (Surrogate)

Ein natürlicher 3-teiliger Textschlüssel (56 Zeichen) gegenüber einem schlanken int-Surrogate-Key:

int-Key
65 MB · 471 ms Verbund
Text-Key
287 MB · 1.142 ms Verbund

Die Ursache: Der Text-Primärschlüssel wiederholt sich in jedem Fremdschlüssel und in jedem Nonclustered Index der Kindtabellen. Tabellen und Indizes werden 4,4-mal größer und belasten den Buffer Pool dauerhaft.

Messreihe 4 · 10 Mio. Zeilen

SARGability: Führender Platzhalter (LIKE '%berg')

Endungssuche auf Firmennamen. Standard-Scan vs. berechnete Spalte mit REVERSE():

REVERSE
43 Reads · 6,3 ms
LIKE '%...'
49.022 Reads · 3.890 ms

Die Ursache: LIKE '%berg' kann keinen B-Tree nutzen. Mit einer berechneten Spalte FirmaRev AS REVERSE(Firma) und einem Index darauf wird aus der Endungssuche eine blitzschnelle Präfixsuche LIKE 'greb%': ×617 schneller.

Messreihe 5 · 1 Mio. Zeilen

Datenbankgrenzen: Collation-Falle

Abfragen über Datenbankgrenzen hinweg:

Gleiche Collation
66 Reads · Index Seek
Collation-Mismatch
7.882 Reads (Faktor ×119)

Die Ursache: Weicht die Sortierung der Remote-Datenbank ab, muss SQL Server alle Daten lokal ziehen und Zeile für Zeile vergleichen. Index-Seeks auf der Gegenseite werden unmöglich.

Messreihe 6 · 1 Mio. Zeilen

Normalisierung (3NF) & Datenqualität

Klartext-Spalten in der Haupttabelle vs. normalisierte Nachschlagetabelle (z. B. StadtId):

Normalisiert
33 ms · Widerspruch: 4,3 ms
Klartext
1.518 ms · Widerspruch: 521 ms

Die Ursache: Eine Suche auf einer kleinen Nachschlagetabelle (5.000 Städte) kostet fast nichts, bevor über die kompakte int-Spalte gejoint wird – 46,4-mal schneller, und das Modell verhindert abweichende Schreibweisen derselben Stadt.

Messreihe 7 · 10 Mio. Kunden, 40 Mio. Positionen

Prozedur gegen View: wo gefiltert wird

Gesucht ist der Umsatz der 100 Kunden einer Stadt, mit passenden Indizes. Die Logik steckt einmal in einer View, einmal in einer Prozedur ohne Parameter, deren Ergebnis erst außen gefiltert wird (INSERT … EXEC):

View, WHERE außen
52 ms · 3.408 Reads
Mehranweisungs-Funktion
53.095 ms (Faktor ×1.029)
Prozedur, außen gefiltert
134.172 ms · 44.110.987 Reads (Faktor ×2.600)

Die Ursache: Die View wird in die Abfrage eingesetzt, der Filter auf die Stadt wandert nach innen und nutzt den Index. Die Prozedur berechnet dagegen alle 10 Mio. Kunden, erst danach wird gefiltert. Das liegt nicht an der Prozedur an sich: Mit Parameter braucht sie 54 ms, so viel wie die View. Gemessen sind Client-Zeiten bei kaltem Cache.

Messreihe 8 · 100 Mio. Buchungen

Einen Monat entfernen: DELETE gegen Partition leeren

Dieselben Buchungen über 120 Monate, einmal nach Buchungsnummer gespeichert, einmal nach Datum, einmal nach Monat partitioniert. Entfernt wird ein Monat (833.333 Zeilen):

Partition leeren
8,5 ms (TRUNCATE … WITH (PARTITIONS))
DELETE, nach Datum
14.082 ms (Faktor ×1.657)
DELETE, ohne Datumsindex
67.298 ms (Faktor ×7.917)

Die Ursache: DELETE schreibt jede Zeile ins Protokoll und pflegt jeden Index mit. Das Leeren einer Partition gibt nur deren Seiten frei. Voraussetzung: Der Monat liegt genau in einer Partition, und alle Indizes sind gleich partitioniert. Gemessen wurde nur die Löschanweisung, in einer Transaktion, die danach zurückgerollt wurde.

Messreihe 9 · 100 Mio. Buchungen

Partitionierung macht Abfragen nicht automatisch schneller

Dieselben drei Tabellen wie in Messreihe 8, diesmal zwei Abfragen: die Summe eines Monats und die Summe eines Kontos über alle Jahre:

Monat, nach Datum
173 ms
Monat, partitioniert
244 ms (ohne Datumsindex: 11.077 ms)
Konto, nicht partitioniert
45 ms
Konto, partitioniert
285 ms

Die Ursache: Für die Monatssumme genügt ein Index nach Datum; die Partitionierung war in dieser Messung sogar etwas langsamer. Die Kontoabfrage kennt den Monat nicht und muss in allen 120 Partitionen suchen. Partitionierung ist ein Werkzeug für die Datenpflege (Messreihe 8), kein Ersatz für den passenden Index.

Messreihe 10 · 100 Mio. Kunden, 400 Mio. Positionen

Join über drei Tabellen bei 100 Millionen Kunden

Kunde, Bestellung und Position; gesucht sind die 400 Positionen der Kunden einer Stadt. Ohne Index auf den Fremdschlüsseln gegen Indizes auf Stadt und Fremdschlüsseln (mit INCLUDE):

Mit Indizes
66 ms · 3.144 Reads
Nur Primärschlüssel
41.982 ms · 6.843.764 Reads (Faktor ×632)

Die Ursache: Ohne Index muss SQL Server alle 400 Mio. Positionen lesen, um 400 zu finden – 2.177-mal so viele Seiten. Drei Unterabfragen je Kunde statt des Joins: 50.737 ms gegen 232 ms (Faktor ×219). Erzwungene Nested Loops ohne Index wurden bei dieser Größe nicht gemessen.

Möchten Sie diese Messmethodik an Ihrer eigenen Datenbank anwenden?

In einem unverbindlichen Erstgespräch besprechen wir Ihre aktuellen Engpässe. Ich zeige Ihnen, wie wir mit minimalinvasiven Messungen und Skripten die tatsächliche Ursache aufdecken – mit möglichst geringer Last auf Ihrem Produktivbetrieb.

Erstgespräch vereinbaren →