Grundsätze des
Datenbankdesigns
Entscheidungen beim Datenbankdesign wirken Jahre oder Jahrzehnte. Dieser Artikel beschreibt die wichtigsten Überlegungen – von Normalisierung über Schlüsselstrategie bis zu SQL-Server-spezifischen Besonderheiten.
Ein Datenbankschema ist kein Entwurf, der sich später leicht ändern lässt. Tabellennamen, Schlüsselstrategien und Datentypen, die in der ersten Produktivversion gesetzt werden, begleiten ein System oft über viele Jahre. Migrationsskripte, Anwendungscode und Berichte hängen an diesen frühen Entscheidungen.
Die folgenden Abschnitte behandeln die grundlegenden Überlegungen, die vor dem Anlegen der ersten Tabelle abgeschlossen sein sollten – ergänzt um SQL-Server-spezifische Empfehlungen und konkrete T-SQL-Beispiele.
Normalisierung – und wann man sie bricht
Normalisierung ist das systematische Verfahren, Redundanzen aus einem Datenbankschema zu entfernen und Abhängigkeiten klar zu strukturieren. Das Ziel: Jede Information wird an genau einer Stelle gespeichert. Das verhindert Inkonsistenzen bei Änderungen und vereinfacht die Pflege.
Für operative Datenbanken (OLTP) ist die dritte Normalform (3NF) in der Regel das sinnvolle Ziel. BCNF und höhere Normalformen sind theoretisch elegant, erzwingen aber oft so viele Tabellen, dass die Abfragestruktur darunter leidet.
Typischer Fehler: mehrwertige Attribute in einer Spalte
-- Telefonnummern als komma-separierter String CREATE TABLE Kunden ( KundeID INT, Name NVARCHAR(100), Telefon NVARCHAR(200) -- '030-123,089-456' );
CREATE TABLE Kunden ( KundeID INT NOT NULL, Name NVARCHAR(100) NOT NULL ); CREATE TABLE KundenTelefon ( TelefonID INT NOT NULL, KundeID INT NOT NULL, Nummer NVARCHAR(30) NOT NULL, Typ NVARCHAR(20) -- 'Mobil', 'Büro' );
Kontrollierte Denormalisierung
Für analytische Abfragen (OLAP) oder Reporting-Datenbanken ist Denormalisierung oft bewusst sinnvoll: Vorberechnete Aggregatspalten, redundante Schlüsselspalten oder flache Wide-Tables reduzieren die Anzahl der JOINs und beschleunigen Lesezugriffe erheblich.
Schlüsselstrategie
Der Primärschlüssel ist die Grundlage jeder Tabelle. Er identifiziert jede Zeile eindeutig, bestimmt die physische Speicherreihenfolge (Clustered Index) und dient als Ankerpunkt für alle Fremdschlüsselbeziehungen. Die Wahl der Schlüsselstrategie beeinflusst Performance, Wartbarkeit und Integrationsfähigkeit nachhaltig.
Natürliche vs. Ersatzschlüssel
| Ansatz | Vorteile | Nachteile | Empfehlung |
|---|---|---|---|
| Natürlicher Schlüssel | Fachlich verständlich, kein Join für Anzeige nötig | Ändert sich mit der Realität; oft breit (Text); externe Abhängigkeit | Mit Bedacht |
| INT IDENTITY | Schmal (4 Byte), sequential, schnelle Joins, kein externes System | Kein fachlicher Aussagewert; nicht geeignet für verteilte Systeme | Standard für OLTP |
| BIGINT IDENTITY | Wie INT, aber für sehr große Tabellen (> 2 Mrd. Zeilen) | 8 Byte – größerer Index-Footprint | Bei Bedarf |
| GUID (NEWID) | Weltweit eindeutig; ideal für Replikation und verteilte Systeme | 16 Byte; zufällig → starke Index-Fragmentierung | Nicht als Clustered Key |
| GUID (NEWSEQUENTIALID) | Wie GUID, aber sequenziell → deutlich weniger Fragmentierung | 16 Byte; nur als DEFAULT nutzbar, nicht manuell setzbar | Wenn GUID nötig |
NEWSEQUENTIALID() oder ein separater Clustered Key aus INT IDENTITY sind die Lösung.
Zusammengesetzte Primärschlüssel
Für reine Verknüpfungstabellen (Many-to-Many) ist ein zusammengesetzter PK aus den beiden Fremdschlüsseln oft die richtige Wahl – er erzwingt die Eindeutigkeit der Kombination ohne zusätzliche Spalte und der Index ist bereits der Clustered Index.
-- Many-to-Many: Bestellung ↔ Produkt CREATE TABLE BestellungProdukt ( BestellungID INT NOT NULL, ProduktID INT NOT NULL, Menge INT NOT NULL, Einzelpreis DECIMAL(10,2) NOT NULL, CONSTRAINT PK_BestellungProdukt PRIMARY KEY (BestellungID, ProduktID) );
Datentypen richtig wählen
Der Datentyp einer Spalte beeinflusst Speicherverbrauch, Indexgröße, Sortierverhalten und die Möglichkeit impliziter Typkonvertierungen bei Abfragen. Falsch gewählte Typen sind eine häufige Ursache für ungenutzte Indizes.
Zahlen
| Typ | Speicher | Wertebereich | Verwendung |
|---|---|---|---|
| TINYINT | 1 Byte | 0 – 255 | Status-Codes, kleine Lookup-Werte |
| SMALLINT | 2 Byte | –32.768 – 32.767 | Selten; wenn INT zu groß wäre |
| INT | 4 Byte | ± 2,1 Mrd. | Standardtyp für IDs und Mengen |
| BIGINT | 8 Byte | ± 9,2 × 10¹⁸ | Große Sequenzen, Log-Tabellen |
| DECIMAL(p,s) | 5–17 Byte | Exakt | Geldbeträge, Gewichte – nie FLOAT für Geld |
| FLOAT / REAL | 4–8 Byte | Näherungswert | Nicht für Geldbeträge – Rundungsfehler möglich |
Zeichenketten
nvarchar speichert Unicode (2 Byte pro Zeichen) und ist nötig, wenn mehrsprachige Inhalte oder Sonderzeichen erwartet werden. varchar speichert 1 Byte pro Zeichen (Codepage-abhängig) und reicht für rein ASCII-basierte Daten. Ein Mix beider Typen in JOINs oder Vergleichen erzwingt eine implizite Konvertierung, die Indizes unbrauchbar macht.
| Typ | Encoding | Max. Länge | Wann verwenden |
|---|---|---|---|
| CHAR(n) | ASCII | 8.000 Byte | Feste Länge (z.B. Ländercodes, IBAN-Prüfziffern) |
| VARCHAR(n) | ASCII | 8.000 Byte | Variable ASCII-Texte; eng begrenzen (nicht pauschal 255) |
| NCHAR(n) | Unicode | 4.000 Zeichen | Feste Unicode-Felder (selten) |
| NVARCHAR(n) | Unicode | 4.000 Zeichen | Namen, Freitexte, mehrsprachige Inhalte |
| NVARCHAR(MAX) | Unicode | 2 GB | Sparsam einsetzen – verhindert Covering Indexes, hoher I/O |
Datum und Uhrzeit
| Typ | Speicher | Genauigkeit | Empfehlung |
|---|---|---|---|
| DATE | 3 Byte | Tag | Wenn Uhrzeit irrelevant (Geburtsdaten, Fälligkeiten) |
| TIME(n) | 3–5 Byte | 100 ns | Wenn nur Uhrzeit benötigt wird |
| DATETIME2(n) | 6–8 Byte | 100 ns | Standard-Empfehlung – löst DATETIME ab |
| DATETIMEOFFSET | 10 Byte | 100 ns + Zeitzone | Zeitzonenbewusste Systeme |
| DATETIME | 8 Byte | 3,33 ms | Legacy – in neuen Designs durch DATETIME2 ersetzen |
| SMALLDATETIME | 4 Byte | 1 Minute | Legacy – zu grob für die meisten Anwendungsfälle |
DATETIME2 hat eine höhere Genauigkeit, einen größeren Wertebereich (0001–9999), ist ISO 8601-konform und benötigt bei niedriger Präzision (DATETIME2(0)) nur 6 Byte statt 8. Es gibt keinen guten Grund, in neuen Tabellen noch DATETIME zu verwenden.
Referentielle Integrität und Constraints
Referentielle Integrität stellt sicher, dass Beziehungen zwischen Tabellen konsistent bleiben: Ein Fremdschlüsselwert muss immer auf einen existierenden Primärschlüsselwert verweisen. Die Durchsetzung dieser Regel in der Datenbank selbst – nicht nur im Anwendungscode – ist ein zentrales Designprinzip.
Fremdschlüssel mit CASCADE-Optionen
CREATE TABLE Bestellungen ( BestellungID INT NOT NULL IDENTITY(1,1), KundeID INT NOT NULL, Bestelldatum DATETIME2(0) NOT NULL DEFAULT SYSDATETIME(), Status TINYINT NOT NULL DEFAULT 1, CONSTRAINT PK_Bestellungen PRIMARY KEY (BestellungID), CONSTRAINT FK_Bestellungen_Kunden FOREIGN KEY (KundeID) REFERENCES Kunden (KundeID) ON DELETE NO ACTION -- Löschen verhindern, wenn Bestellungen existieren ON UPDATE CASCADE -- KundeID-Änderung wird weitergegeben );
| CASCADE-Option | Verhalten bei DELETE/UPDATE der Elterntabelle | Empfehlung |
|---|---|---|
| NO ACTION | Fehler, wenn abhängige Zeilen existieren | Standard |
| CASCADE | Abhängige Zeilen werden automatisch gelöscht/aktualisiert | Mit Bedacht – Kettenreaktionen möglich |
| SET NULL | FK-Spalte wird auf NULL gesetzt | Nur wenn NULL fachlich sinnvoll ist |
| SET DEFAULT | FK-Spalte erhält ihren DEFAULT-Wert | Selten sinnvoll |
CHECK Constraints
CHECK Constraints erzwingen fachliche Regeln direkt in der Datenbank und sind zuverlässiger als Validierungslogik in der Anwendung.
ALTER TABLE Bestellungen ADD CONSTRAINT CK_Bestellungen_Status CHECK (Status IN (1, 2, 3, 4)); -- 1=Offen, 2=Bestätigt, 3=Versendet, 4=Storniert ALTER TABLE Produkte ADD CONSTRAINT CK_Produkte_Preis CHECK (Preis >= 0); ALTER TABLE Bestellungen ADD CONSTRAINT CK_Bestellungen_Datum CHECK (Lieferdatum > Bestelldatum);
Naming Conventions
Konsistente Namensgebung ist keine ästhetische Frage, sondern eine Frage der Wartbarkeit. In einer Datenbank mit hunderten Tabellen und tausenden Spalten entscheiden Namenskonventionen darüber, ob ein neuer Entwickler in zehn Minuten oder in zwei Tagen produktiv wird.
Empfohlene Konventionen für SQL Server
| Objekt | Konvention | Beispiel |
|---|---|---|
| Tabellen | PascalCase, Singular | Kunde, Bestellung, Produkt |
| Spalten | PascalCase | KundeID, Bestelldatum, Gesamtbetrag |
| Primärschlüssel | PK_Tabellenname | PK_Bestellung |
| Fremdschlüssel | FK_Kindtabelle_Elterntabelle | FK_Bestellung_Kunde |
| Indizes | IX_Tabelle_Spalte(n) | IX_Bestellung_Datum |
| Unique Constraints | UQ_Tabelle_Spalte | UQ_Kunde_Email |
| CHECK Constraints | CK_Tabelle_Regel | CK_Bestellung_Status |
| Stored Procedures | usp_Verb_Objekt | usp_Get_KundeByID |
| Views | v_Beschreibung | v_BestellungAktiv |
| Trigger | tr_Tabelle_Ereignis | tr_Bestellung_AfterInsert |
Order, Name, Date, User oder Status sind reservierte Schlüsselwörter in T-SQL. Sie erzwingen bei jedem Zugriff eckige Klammern ([Order]) und sind fehleranfällig. Eindeutige, fachlich sprechende Namen wie Bestellung oder Auftragsstatus sind immer vorzuziehen.
Schemas sinnvoll nutzen
SQL Server unterstützt Schemas als Namensraum-Konzept unterhalb der Datenbank. Statt alles in dbo zu legen, können Schemas funktionale Bereiche klar trennen – und separate Berechtigungen auf Schemaebene vergeben werden.
-- Schemas für funktionale Bereiche CREATE SCHEMA Sales; CREATE SCHEMA Inventory; CREATE SCHEMA HR; GO -- Tabellen im jeweiligen Schema CREATE TABLE Sales.Bestellung ( ... ); CREATE TABLE Sales.Bestellposition ( ... ); CREATE TABLE Inventory.Produkt ( ... ); CREATE TABLE HR.Mitarbeiter ( ... ); -- Berechtigungen auf Schemaebene GRANT SELECT ON SCHEMA::Sales TO ReportingUser;
NULL – bewusst einsetzen
NULL bedeutet in SQL nicht „leer" oder „null", sondern „unbekannt". Das hat weitreichende Konsequenzen: Vergleiche mit NULL ergeben weder TRUE noch FALSE, sondern UNKNOWN – was dazu führt, dass Zeilen aus Ergebnismengen verschwinden, die intuitiv erwartet werden.
-- Findet KEINE Zeilen, auch wenn Lieferdatum NULL ist! SELECT * FROM Bestellungen WHERE Lieferdatum = NULL; -- Auch diese Bedingung greift nicht: SELECT * FROM Bestellungen WHERE Lieferdatum != NULL;
-- Findet alle Bestellungen ohne Lieferdatum SELECT * FROM Bestellungen WHERE Lieferdatum IS NULL; -- COALESCE für Standardwert bei NULL SELECT BestellungID, COALESCE(Lieferdatum, 'noch nicht geliefert') AS LieferstatusText FROM Bestellungen;
Designentscheidung: NOT NULL als Standard
Spalten sollten standardmäßig NOT NULL definiert werden. NULL sollte nur dann erlaubt sein, wenn das Fehlen eines Wertes fachlich bedeutsam ist – zum Beispiel ein optionales Lieferdatum oder ein noch nicht zugewiesener Sachbearbeiter. Jede nullable Spalte erhöht die Komplexität von Abfragen und die Fehleranfälligkeit der Anwendungslogik.
SET ANSI_NULLS ON, was dem ISO-Standard entspricht. In veralteten Datenbanken findet man gelegentlich ANSI_NULLS OFF, das = NULL als Vergleich zulässt. Diese Einstellung ist deprecated und sollte nicht verwendet werden.
Designentscheidungen mit Performance-Auswirkung
Einige Designentscheidungen, die auf den ersten Blick neutral oder rein strukturell erscheinen, haben direkte Auswirkungen auf die Abfrageperformance. Sie lassen sich im Nachhinein oft nur mit erheblichem Aufwand korrigieren.
Schmale Primärschlüssel – schmale Indizes
Der Clustered Index Key wird in jedem Non-Clustered Index als Zeiger mitgeführt. Ein breiter PK (z.B. NVARCHAR(50) oder ein zusammengesetzter Schlüssel aus drei Spalten) vergrößert daher jeden weiteren Index der Tabelle proportional. Ein schmaler INT oder BIGINT als PK minimiert den Index-Footprint der gesamten Tabelle.
Tabellen von Anfang an mit Indizes planen
Indizes sollten nicht nachträglich „bei Bedarf" ergänzt werden, sondern beim Schemaentwurf mitgeplant werden. Spalten, die regelmäßig in WHERE-Klauseln, JOIN ON-Bedingungen oder ORDER BY vorkommen, sind primäre Indexkandidaten. Jeder nicht vorhandene Index bei einer häufigen Abfrage kostet permanent Laufzeit – nicht nur beim ersten Auftreten.
CREATE TABLE Sales.Bestellung ( BestellungID INT NOT NULL IDENTITY(1,1), KundeID INT NOT NULL, Bestelldatum DATETIME2(0) NOT NULL DEFAULT SYSDATETIME(), Status TINYINT NOT NULL DEFAULT 1, Gesamtbetrag DECIMAL(12,2) NOT NULL, CONSTRAINT PK_Bestellung PRIMARY KEY (BestellungID), CONSTRAINT FK_Bestellung_Kunde FOREIGN KEY (KundeID) REFERENCES Sales.Kunde (KundeID) ); GO -- Häufige Filterung nach KundeID → Index CREATE INDEX IX_Bestellung_KundeID ON Sales.Bestellung (KundeID); -- Häufige Filterung nach Datum + Status → zusammengesetzter Index CREATE INDEX IX_Bestellung_Datum_Status ON Sales.Bestellung (Bestelldatum, Status) INCLUDE (KundeID, Gesamtbetrag); -- Covering: kein Key Lookup für diese Abfrage
Tabellengröße und Wachstum einschätzen
Eine Tabelle, die heute 10.000 Zeilen hat, kann in fünf Jahren 50 Millionen Zeilen haben. Designentscheidungen sollten dieses Wachstum berücksichtigen: Ist INT für den PK groß genug? Werden Archivierungsstrategien (Partitionierung, historische Tabellen) benötigt? Wird eine Löschstrategie für veraltete Datensätze gebraucht?
Temporale Tabellen für Historisierung
Ab SQL Server 2016 unterstützt SQL Server System-Versioned Temporal Tables – Tabellen, die automatisch jede Änderung mit Zeitstempeln historisieren. Das ist eine sauberere Alternative zu selbst gebauten Audit-Triggern.
CREATE TABLE Sales.Produkt ( ProduktID INT NOT NULL, Bezeichnung NVARCHAR(200) NOT NULL, Preis DECIMAL(10,2) NOT NULL, -- Systemzeitstempel – werden automatisch gepflegt GueltigVon DATETIME2(7) GENERATED ALWAYS AS ROW START NOT NULL, GueltigBis DATETIME2(7) GENERATED ALWAYS AS ROW END NOT NULL, PERIOD FOR SYSTEM_TIME (GueltigVon, GueltigBis), CONSTRAINT PK_Produkt PRIMARY KEY (ProduktID) ) WITH (SYSTEM_VERSIONING = ON (HISTORY_TABLE = Sales.Produkt_History)); -- Historischen Preis zu einem bestimmten Zeitpunkt abfragen SELECT Bezeichnung, Preis FROM Sales.Produkt FOR SYSTEM_TIME AS OF '2024-06-01 12:00:00' WHERE ProduktID = 42;
CREATE TABLE.