Datenbankdesign & Datentypen:
Performance fängt beim Schema an — nicht beim Index
Schlechtes Design ist kein Index-Problem
Es gibt eine verbreitete Vorstellung in der SQL Server-Welt: Performance-Probleme löst man mit Indizes. Index fehlt? Index hinzufügen. Immer noch langsam? Noch ein Index. Query Store zeigt hohe CPU? Index optimieren. Das ist nicht falsch — aber es ist nur die halbe Wahrheit.
Die andere Hälfte ist das, was du nicht mehr ändern kannst, wenn die Anwendung erst einmal in Produktion ist: Das Schema. Falsche Datentypen, ungünstige Primary Keys, verfehlte Normalisierungsentscheidungen — das sind strukturelle Schulden, die kein Index der Welt vollständig zurückzahlen kann. In diesem Kapitel schauen wir auf die Designentscheidungen, die Performance-Probleme verursachen, bevor der erste Index gesetzt wird.
Das ist Teil IV — wir reden jetzt als Entwickler, nicht als DBA. Du hast Einfluss auf das Schema. Nutze ihn.
|
Hinweis: Querverweise |
|---|
|
Implicit Conversions durch falsche Datentypen zerstören Index Seeks — wir zeigen das im Detail in Kapitel 21 (SARGability). Die Konsequenzen von GUID als Clustering Key für die Index-Strategie behandelt Kapitel 17. Trendforge Digital in Kapitel 34 ist das Lehrstück für applikationsseitige Designfehler auf einem sonst tadellos konfigurierten Server. |
Datentypen: Kleine Entscheidungen mit großer Wirkung
|
Datentyp |
Speicher |
Performance-Aspekte |
Empfehlung |
|---|---|---|---|
|
INT |
4 Byte |
Ideal für FK/PK — schnelle Joins, kompakte Indizes |
Standard für Integer-IDs |
|
BIGINT |
8 Byte |
Doppelter Speicher vs. INT — nur, wenn > 2,1 Mrd. Werte |
Nur, wenn INT zu klein |
|
SMALLINT |
2 Byte |
Gut für Status-Spalten, Lookup-Codes |
Bei bekannt kleinen Werten |
|
TINYINT |
1 Byte |
0-255 — ideal für Flags, Ratings, Status |
Für kleine Domänen |
|
VARCHAR(n) |
1 Byte/Zeichen |
Kein Unicode-Overhead — bevorzugen, wenn nur ASCII |
Standard für Text ohne Unicode |
|
NVARCHAR(n) |
2 Byte/Zeichen |
Doppelter Speicher — nur, wenn Unicode wirklich nötig |
Nur bei echtem Unicode-Bedarf |
|
DATETIME |
8 Byte |
3 ms Präzision — veraltet, Konversionsprobleme |
DATETIME2 bevorzugen |
|
DATETIME2 |
6-8 Byte |
100 ns Präzision, ANSI-kompatibel, besser für Indizes |
Standard für Zeitstempel |
|
DATE |
3 Byte |
Nur Datum ohne Zeit — sehr kompakt |
Wenn Zeit irrelevant |
|
DECIMAL(p,s) |
5-17 Byte |
Präzise, kein Rundungsfehler — langsamer als INT |
Für Geldbeträge, Messwerte |
|
FLOAT/REAL |
4-8 Byte |
Schnell, aber Rundungsfehler — nie für Geld! |
Nur für wissenschaftliche Werte |
|
UNIQUEIDENTIFIER |
16 Byte |
Viermal größer als INT — Fragmentierung bei NEWID() |
Nur mit NEWSEQUENTIALID() |
Tabelle 20.1: SQL Server Datentypen und ihre Performance-Eigenschaften
VARCHAR vs. NVARCHAR: Die Unicode-Frage
NVARCHAR speichert jeden Zeichen in zwei Bytes — auch, wenn es nur ein ASCII-"A" ist. Bei einer Spalte Name VARCHAR(100) vs. NVARCHAR(100) mit einer Million Zeilen bedeutet das 100 MB vs. 200 MB Index-Größe. Größere Indizes = weniger Einträge pro Page = mehr IO = schlechtere PLE.
-- Prüfen ob NVARCHAR-Spalten Unicode wirklich benötigen
-- Wenn alle Werte ins ASCII-Alphabet passen: VARCHAR reicht
SELECT COLUMN_NAME, DATA_TYPE, CHARACTER_MAXIMUM_LENGTH,
COLLATION_NAME
FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_NAME = 'Kunden'
AND DATA_TYPE IN ('nvarchar', 'nchar');
-- Prüfen ob tatsächlich Unicode-Werte vorhanden sind:
SELECT COUNT(*) AS zeilen_mit_unicode
FROM dbo.Kunden
WHERE Name != CAST(Name AS VARCHAR(MAX));
-- Ergebnis 0: Unicode war unnötig, VARCHAR würde reichen
Die Empfehlung: Wenn die Anwendung internationalisiert sein soll oder könnte, nimm von Anfang an NVARCHAR. Nachträgliche Migration von VARCHAR zu NVARCHAR auf großen Tabellen ist aufwändig. Aber, wenn du weißt, dass es sich um interne Codes, technische IDs oder ASCII-konforme Bezeichner handelt: VARCHAR spart Platz und IO.
|
Warnung: Die tückischste VARCHAR/NVARCHAR-Falle |
|---|
|
Wenn deine Tabellenspalte VARCHAR ist und der ORM oder die Applikation einen NVARCHAR-Parameter schickt (was .NET standardmäßig tut), passiert eine Implicit Conversion: SQL Server konvertiert die gesamte Spalte auf NVARCHAR für den Vergleich — und ignoriert dabei den Index. Das ist kein Edge Case, das passiert täglich. Kapitel 21 zeigt genau dieses Muster. |
DATETIME vs. DATETIME2: Nimm DATETIME2
DATETIME hat eine Präzision von 3,33 ms und speichert Zeiten intern als "Millisekunden seit 1900-01-01". DATETIME2 hat eine Präzision von 100 Nanosekunden, ist ANSI/ISO-konform, kompatibel mit allen Spracheinstellungen und speichert effizient in 6-8 Byte abhängig von der gewählten Präzision. Es gibt keinen einzigen Grund im Jahr 2024 noch DATETIME zu verwenden.
-- DATETIME2 mit variabler Präzision:
DATETIME2(0) -- Sekunden-Präzision, 6 Byte
DATETIME2(3) -- Millisekunden, 7 Byte (wie DATETIME, aber ANSI)
DATETIME2(7) -- 100ns-Präzision, 8 Byte (Standard)
-- Konversion von DATETIME zu DATETIME2 im Vergleich:
SELECT CAST(GETDATE() AS DATETIME2(3)) AS moderne_zeit,
GETDATE() AS alte_zeit;
GUID vs. INT als Primary Key: Die Performance-Falle der Verteilung
GUIDs (UNIQUEIDENTIFIER) als Primary Keys sind in vielen modernen Applikationen der Standard — weil sie global eindeutig sind, ohne Koordination mit der Datenbank generiert werden können und gut für verteilte Systeme passen. All das stimmt. Aber der Performance-Preis ist real.
Warum NEWID() den Clustered Index fragmentiert
Ein Clustered Index ordnet die physischen Daten nach dem Index-Key. Wenn der Primary Key ein INT ist, werden neue Zeilen immer am Ende eingefügt — sequentiell, effizient, keine Fragmentierung. Wenn der Primary Key ein GUID mit NEWID() ist, ist jeder neue Wert zufällig über den gesamten Key-Space verteilt. Das bedeutet: Jede neue Zeile wird irgendwo mitten in den Clustered Index eingefügt — Page Split, Fragmentierung, erhöhter IO.
-- Schlechter Ansatz: NEWID() = vollständig zufällige GUIDs
-- Führt zu massiver Fragmentierung des Clustered Index
CREATE TABLE dbo.Bestellungen_Schlecht (
BestellID UNIQUEIDENTIFIER DEFAULT NEWID() PRIMARY KEY,
KundeID INT,
Betrag DECIMAL(10,2)
);
-- Besser: NEWSEQUENTIALID() = sequentielle GUIDs
-- Neue Werte werden am Ende eingefügt — wie INT, aber global eindeutig
-- Einschränkung: Nur als DEFAULT, nicht manuell generierbar
CREATE TABLE dbo.Bestellungen_Besser (
BestellID UNIQUEIDENTIFIER DEFAULT NEWSEQUENTIALID() PRIMARY KEY,
KundeID INT,
Betrag DECIMAL(10,2)
);
-- Noch besser: INT mit IDENTITY als Clustered Index,
-- GUID als UNIQUE-Spalte für externe Referenzen
CREATE TABLE dbo.Bestellungen_Optimal (
BestellID INT IDENTITY(1,1) PRIMARY KEY CLUSTERED,
BestellGUID UNIQUEIDENTIFIER DEFAULT NEWID() UNIQUE NONCLUSTERED,
KundeID INT,
Betrag DECIMAL(10,2)
);
-- Der Clustered Index ist sequentiell (INT)
-- Der GUID ist verfügbar für externe APIs, aber nicht der Clustering Key
|
Praxisbeispiel: Trendforge Digital: GUIDs als Clustering Keys |
|---|
|
Trendforge Digital (TFSQL01) verwendete in ihrem Entity Framework-basierten Datenmodell GUIDs mit NEWID() als Primary Keys auf allen 47 Tabellen. Fragmentierungsgrad nach 3 Monaten: durchschnittlich 78%. Die täglichen Index-Rebuilds dauerten 4,5 Stunden — und zerstörten die Nacht-Backups. Nach Migration auf NEWSEQUENTIALID() sank die Fragmentierung auf unter 10% und die Rebuild-Zeit auf 40 Minuten. Kapitel 34 beschreibt den Migrationspfad. |
Normalisierung vs. Denormalisierung: Das READ/WRITE-Verhältnis entscheidet
Normalisierung reduziert Redundanz und schützt Datenintegrität. Denormalisierung erhöht Redundanz, um Abfragen zu vereinfachen. Beide haben ihren Platz — aber die falsche Entscheidung für das falsche Workload-Profil kostet.
Die Faustregel: Je mehr Reads vs. Writes, desto eher lohnt Denormalisierung. Ein Report der 100.000 Mal am Tag läuft und immer dieselben JOINs über 5 Tabellen macht? Candidate für eine denormalisierte Reporting-Tabelle. Eine Transaktionstabelle die 50.000 INSERTs pro Minute bekommt? Bleib normalisiert.
|
Szenario |
Empfehlung |
Begründung |
|---|---|---|
|
OLTP: viele kleine Writes |
Normalisiert (3NF) |
Weniger Seiten bei INSERTs, Locking auf kleinen Zeilen |
|
Reports über viele Joins |
Denormalisiert / Materialized |
Joins kosten bei großen Datenmengen IO und CPU |
|
Read-Heavy, seltene Updates |
Denormalisiert vertretbar |
Redundanz ist akzeptabel, wenn Updates selten |
|
Hohe Schreiblast, Konsistenz kritisch |
Normalisiert |
Redundante Daten = mehr Locks bei Updates |
|
Data Warehouse / Analytics |
Star Schema / Columnar |
Andere Denkweise: Fakten + Dimensionen, kein 3NF |
Tabelle 20.2: Normalisierung vs. Denormalisierung nach Workload-Profil
NULLs: Unterschätzte Performance-Kosten
NULL ist kein Wert, sondern das Fehlen eines Wertes. SQL Server muss das extra speichern: In der Zeile gibt es ein Null-Bitmap das für jede nullable Spalte ein Bit reserviert. Das ist effizient, aber es hat Konsequenzen.
-- Spalten mit hohem NULL-Anteil finden
-- Viele NULLs = möglicherweise schlechtes Design (Sparse Columns als Alternative)
SELECT
COLUMN_NAME,
IS_NULLABLE,
-- Null-Anteil schätzen (dynamisches SQL nötig für echten Check)
DATA_TYPE
FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_NAME = 'MeineTabelle'
AND IS_NULLABLE = 'YES'
ORDER BY COLUMN_NAME;
-- Bei sehr vielen NULLs: Sparse Column erwägen
-- Sparse Columns sparen Speicher, wenn >80% der Werte NULL sind
ALTER TABLE dbo.Kunden
ADD BesonderePraeferenzen NVARCHAR(500) SPARSE NULL;
Tabellen-Kompression: Weniger IO, mehr CPU — der Trade-off
SQL Server bietet zwei Kompressionsstufen für Tabellen und Indizes: Row Compression und Page Compression. Beide reduzieren die Datenmenge auf der Disk und im Buffer Pool — was IO spart und PLE verbessert. Dafür kostet das Komprimieren und Dekomprimieren CPU.
|
Kompressionstyp |
Ersparnis |
CPU-Overhead |
Wann sinnvoll |
|---|---|---|---|
|
ROW |
10-40% |
Gering (~5%) |
Immer, wenn Speicherersparnis erwünscht |
|
PAGE |
30-80% |
Mittel (~10-20%) |
Read-Heavy, große Tabellen, freie CPU |
|
COLUMNSTORE |
80-95% |
Hoch beim Schreiben |
Analytics, Data Warehouse, Read-Only |
|
Keine |
0% |
0% |
Write-Heavy OLTP mit CPU-Engpass |
Tabelle 20.3: Kompressionsstufen im Vergleich
-- Row Compression auf eine große Tabelle anwenden
-- ONLINE = keine Sperrzeit, aber länger dauernder Rebuild
ALTER TABLE dbo.Bestellungen
REBUILD PARTITION = ALL
WITH (DATA_COMPRESSION = ROW, ONLINE = ON);
-- Kompressionsersparnis vorab schätzen (kein echter Umbau):
EXEC sp_estimate_data_compression_savings
@schema_name = 'dbo',
@object_name = 'Bestellungen',
@index_id = NULL, -- alle Indizes
@partition_number = NULL, -- alle Partitionen
@data_compression = 'ROW';
|
Tipp: Kompression — zuerst messen, dann entscheiden |
|---|
|
sp_estimate_data_compression_savings ist kostenlos und braucht keine Änderungen. Führe ihn zuerst aus und prüfe: Wenn PAGE-Kompression nur 8% spart aber 20% CPU-Overhead kostet — lass es. Wenn sie 65% spart und du CPU-Headroom hast — tu es. Ohne Messung ist es Raten. |
Zusammenfassung
Datenbankdesign ist keine akademische Übung — es ist der Fundament über dem alle Performance-Optimierungen aufgebaut werden. Ein solides Schema macht Indizes effektiver, Abfragen schneller und den DBA glücklicher.
Die wichtigsten Entscheidungen:
Implicit Conversions — das stille Gift das entsteht, wenn Applikation und Datenbankschema verschiedene Datentypen verwenden — sind das Thema von Kapitel 21. Dort werden wir sehen wie ein einziger falscher Datentyp einen perfekten Index komplett nutzlos macht.
|
Hinweis: Ausblick auf Kapitel 21 |
|---|
|
Kapitel 21 behandelt SARGability — die Eigenschaft einer Abfragebedingung, einen Index nutzen zu können. Du wirst sehen, dass SARGability-Killer (Funktionen auf Spalten, LIKE mit führendem Wildcard, Implicit Conversions) die häufigste Ursache für "Index vorhanden, aber ignoriert" sind. Trendforge macht das bei 12 von 23 Abfragen systematisch falsch. |

Abb. 1: Implicit Conversion: Typkonflikte und ihre Kosten
Kapitel 21
