12k
All articles

Häufige Fehler beim Design relationaler Datenbanken

Vermeiden Sie typische Fehler im relationalen Datenbankdesign mit Postgres und MySQL: Constraints, Schlüssel, Indizes und korrekte Zeitstempel.

OpenReplay Team
OpenReplay Team
Häufige Fehler beim Design relationaler Datenbanken

Die meisten Fehler bei relationalen Schemata lassen sich auf einen einzigen Fehler zurückführen: darauf zu vertrauen, dass die Anwendung durchsetzt, was die Datenbank garantieren sollte. Ein Foreign Key, den man „im Service Layer behandelt”, eine Eindeutigkeitsregel, die man mit einem SELECT vor dem INSERT prüft, ein Statusfeld, das das ORM validiert, die Spalte selbst jedoch nicht — jede dieser Maßnahmen ist ein Constraint, den man aus der Engine herausverlagert hat, die dafür gebaut wurde, ihn durchzusetzen, und in Code verschoben hat, der nur dann ausgeführt wird, wenn man daran denkt, ihn aufzurufen. Das Ergebnis sind Daten, die das Schema niemals zugelassen hätte: verwaiste Zeilen, doppelte Accounts, negative Preise, Zeitstempel, die niemand interpretieren kann. Dieser Leitfaden behandelt die wiederkehrenden Design-Fehler, die Anwendungsentwickler bei Postgres und MySQL machen, warum jeder einzelne zum Problem wird und wie man ihn behebt — mit einem kurzen Vorher-Nachher-Snippet für jeden Fall. Die Beispiele verwenden, wo relevant, die moderne Postgres-18-Syntax, aber die Prinzipien gelten für jede relationale Engine.

Wichtigste Erkenntnisse

  • Integrität in der Datenbank mit FOREIGN KEY-, NOT NULL-, CHECK- und UNIQUE-Constraints durchsetzen; eine ausschließlich auf Anwendungsebene stattfindende Validierung greift nur dann, wenn der Code daran denkt, sie aufzurufen, und ein ORM, das eine Relation ohne datenbankinternen Foreign Key deklarieren lässt, verschiebt einen Integritätsfehler in die Produktion.
  • Einen Surrogatprimärschlüssel (bigint Identity oder eine uuidv7()-UUID) verwenden und einen separaten UNIQUE-Constraint auf den natürlichen Schlüssel setzen, sodass ein Rebranding oder eine E-Mail-Änderung ein UPDATE ist und keine Migration.
  • Ab PostgreSQL 18 (veröffentlicht am 25. September 2025) wird keine Extension mehr für zeitgeordnete Bezeichner benötigt: id uuid PRIMARY KEY DEFAULT uuidv7() bietet eine Indexlokalität nahe an bigint, bleibt aber global eindeutig.
  • Zeitstempel als timestamptz statt als timestamp speichern und die Foreign-Key- sowie Filterspalten indizieren, auf denen tatsächlich gejoined wird — Postgres indiziert die referenzierende Seite eines Foreign Keys nicht automatisch.
  • Das Schema als lebendigen Code behandeln: es mit Migrationen versionieren, Soft Deletes (deleted_at timestamptz) bevorzugen, wenn andere Zeilen auf einen Datensatz verweisen, und Spalten mit COMMENT ON COLUMN dokumentieren.

Die Wurzel der meisten Fehler beim Design relationaler Datenbanken: Regeln im Anwendungscode statt im Schema

Der folgenreichste Fehler ist, Datenregeln ausschließlich im Anwendungscode durchzusetzen. Ein Constraint im Service Layer schützt genau einen Code-Pfad; ein Constraint im Schema schützt jeden Pfad — die API, einen Hintergrundprozess, eine manuelle psql-Session, eine fehlerhafte Datenmigration und den nächsten Service, den jemand gegen dieselbe Datenbank schreibt. Wenn die Regel nur in der Anwendung existiert, korrumpiert der erste Schreiber, der sie umgeht, die Tabelle dauerhaft.

Das ist die moderne ORM-Falle. Tools wie Prisma und Drizzle modellieren eine Relation problemlos im Anwendungscode, ohne jemals einen datenbankinternen Foreign Key zu erzeugen, und ein @default oder ein Zod-Schema fühlt sich wie eine Validierung an. Das ist es nicht. Ein ORM, der es erlaubt, eine Relation ohne datenbankinternen Foreign Key zu deklarieren, spart keine Arbeit — er verschiebt einen Datenintegritätsfehler in die Produktion, wo Session Replay — und nicht die Testsuite — ihn schließlich aufdeckt, weil der Fehler kein Stack Trace ist. Die korrumpierten Daten tauchen stattdessen als Bewertung auf, die einem nicht mehr existierenden Nutzer zugeordnet ist, oder als doppelte Bestellung — ein verwirrender Bildschirm, den der Nutzer tatsächlich zu sehen bekommt.

Die Lösung besteht darin, die Regel dorthin zu verlagern, wo sie nicht übergangen werden kann. PostgreSQLs Check-Constraints beispielsweise lehnen fehlerhafte Daten unabhängig davon ab, welcher Client sie geschrieben hat: Um positive Produktpreise zu erzwingen, kann man einen CHECK (price > 0)-Constraint in der Tabellendefinition verwenden.

-- Vorher: nichts verhindert eine negative Zahlung oder eine NULL-E-Mail
CREATE TABLE payments (
  id      bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  user_id bigint,                 -- kein FK: verwaiste Zeilen vorprogrammiert
  amount  numeric,                -- kein CHECK: negative Werte erlaubt
  email   text                    -- kein NOT NULL, kein UNIQUE
);

-- Nachher: die Engine garantiert die Invarianten
CREATE TABLE payments (
  id      bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  user_id bigint NOT NULL REFERENCES users (id) ON DELETE RESTRICT,
  amount  numeric NOT NULL CHECK (amount > 0),
  email   text NOT NULL UNIQUE
);

Ein Foreign Key ist keine Performance-Steuer — er ist das Einzige, was zwischen einem und verwaisten Zeilen steht, die als Bewertung eines nicht mehr existierenden Nutzers gerendert werden. Ab Postgres 18 lassen sich Constraints auf stark frequentierten Tabellen auch stufenweise einführen: ALTER TABLE kann nun das NOT VALID-Attribut von NOT NULL-Constraints setzen, sodass der Constraint ohne einen sofortigen vollständigen Tabellen-Scan hinzugefügt und später unter einer schwächeren Sperre validiert werden kann.

Normalisierung überspringen — oder zu weit treiben

Normalisierung ist die Praxis, jede Information genau einmal zu speichern. Die erste Normalform (1NF) bedeutet keine Wiederholungsgruppen und keine Mehrfachwertfelder; die dritte Normalform (3NF) bedeutet, dass jede Nicht-Schlüsselspalte vom Schlüssel und von nichts anderem abhängt. Für Anwendungsschemata ist 3NF der sinnvolle Standard — die Mechanismen sind in der Postgres-Dokumentation zur Datendefinition beschrieben. Die zwei klassischen Verstöße sind kommagetrennte Werte in einer Spalte und Wiederholungsspalten wie payment_1 … payment_12.

Kommagetrennte Werte in einer einzelnen Spalte sind eine Denormalisierung, die man spätestens beim ersten WHERE … LIKE '%,42,%' bereut; die Lösung ist eine untergeordnete Tabelle oder eine Verknüpfungstabelle, keine ausgefeiltere String-Abfrage.

-- Vorher: Tags als getrennter String, Telefonnummern als Spalten
CREATE TABLE users (
  id     bigint PRIMARY KEY,
  tags   text,           -- 'admin,beta,vip' — nicht abfragbar, unkontrolliert
  phone1 text, phone2 text, phone3 text   -- Wiederholungsgruppe, läuft irgendwann voll
);

-- Nachher: eine Verknüpfungstabelle für Tags, eine untergeordnete Tabelle für Telefonnummern
CREATE TABLE user_tags (
  user_id bigint NOT NULL REFERENCES users (id) ON DELETE CASCADE,
  tag     text   NOT NULL,
  PRIMARY KEY (user_id, tag)
);
CREATE TABLE user_phones (
  id      bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  user_id bigint NOT NULL REFERENCES users (id) ON DELETE CASCADE,
  phone   text   NOT NULL
);

Der umgekehrte Fehler ist Über-Normalisierung: eine einzelne address in sechs gejointen Tabellen aufzuteilen oder eine Eins-zu-Eins-Beziehung ohne triftigen Grund als zwei Tabellen zu modellieren. Jeder zusätzliche Join ist ein Abfrage- und Kognitionsaufwand. Normalisieren, bis jede Information genau einmal vorkommt — dann aufhören.

Geschäftsfelder als Primärschlüssel verwenden

Kein Geschäftsfeld wie email, username oder company_name als Primärschlüssel verwenden; stattdessen einen Surrogatschlüssel nutzen und einen separaten UNIQUE-Constraint auf den natürlichen Wert setzen, sodass ein Rebranding oder eine Adressänderung ein UPDATE ist und keine Migration. Geschäftswerte ändern sich, und wenn sich ein Primärschlüssel ändert, müssen alle darauf verweisenden Foreign Keys kaskadieren — ein Wert, der niemals eine Identität hätte sein sollen, wird zu einer schemaweiten Migration.

Der Ratschlag aus dem Jahr 2007, Surrogatschlüssel zu vermeiden, ist für Anwendungsentwickler überholt. Der moderne Standard ist ein Surrogatprimärschlüssel kombiniert mit einem echten UNIQUE-Constraint auf dem natürlichen Schlüssel — so erhält man eine stabile Identität und die Eindeutigkeitsgarantie.

CREATE TABLE companies (
  id   uuid PRIMARY KEY DEFAULT uuidv7(),   -- stabile Surrogat-Identität
  name text NOT NULL UNIQUE                 -- natürlicher Schlüssel weiterhin erzwungen
);

UUID vs. bigint — eine Entscheidung

Ab Postgres 18 wird keine Extension mehr für zeitgeordnete Bezeichner benötigt: Postgres 18 fügt die UUID-Generierungsfunktion uuidv7() hinzu, deren Wert zeitlich sortierbar ist, sowie einen uuidv4()-Alias zur expliziten Generierung von UUIDs der Version 4. UUIDv7 — definiert in RFC 9562 (Mai 2024, der RFC 4122 ablöst) — enthält ein Zeitstempel-Präfix, sodass neue Zeilen rechts im Index angehängt werden, anstatt wie zufällige v4-UUIDs zu streuen. Hinweis: Für älteres Postgres ist gen_random_uuid() (v4) seit Version 13 im Core enthalten; die pgcrypto-Extension wird nicht mehr benötigt.

SchlüsseltypSpeicherIndexlokalitätGlobal eindeutigAnmerkungen
bigint Identity8 ByteAusgezeichnet (sequenziell)NeinKleinste, schnellste Option; gibt Zeilenanzahl preis; erfordert zentrale Zuweisung
uuidv4()16 ByteSchlecht (zufällige Einfügungen)JaVerteilungsfreundlich; Indexfragmentierung bei hohem Schreibaufkommen
uuidv7()16 ByteGut (zeitgeordnet)JaZeitlich sortierbar; gibt Erstellungszeit preis; der empfohlene Standard in PG18

bigint als Standard verwenden, wenn IDs innerhalb einer einzelnen Datenbank verbleiben, und uuidv7(), wenn IDs clientseitig oder dienstübergreifend generiert werden.

Keine durchdachte Indexierungsstrategie

Die Spalten indizieren, auf denen tatsächlich gefiltert und gejoined wird — insbesondere Foreign Keys, die Postgres nicht automatisch indiziert —, aber nicht jede Spalte indizieren, da jeder Index bei jedem Insert und Update einen Schreiboverhead verursacht. Das ist die häufigste Performance-Regression in Anwendungsschemata, und es ist leicht, in beide Richtungen falsch zu liegen.

Die Falle ist Postgres-spezifisch: Es indiziert automatisch die referenzierte (Eltern-)Seite eines Foreign Keys, nicht jedoch die referenzierende (Kind-)Spalte. Da die Indizierung der referenzierenden Spalten nicht immer notwendig ist und es viele Möglichkeiten gibt, sie zu indizieren, erstellt die Deklaration eines Foreign-Key-Constraints keinen automatischen Index auf den referenzierenden Spalten. (MySQLs InnoDB erstellt einen solchen automatisch — dieses Problem ist also Postgres-spezifisch.)

-- payments.user_id ist ein FK, aber nicht indiziert: jeder Join und jedes
-- DELETE auf users führt zu einem Sequential Scan auf payments
CREATE INDEX ON payments (user_id);

FK- und Filterspalten indizieren; Indizes auf Spalten mit geringer Selektivität und auf Tabellen, die selten nach diesem Feld abgefragt werden, weglassen.

Eine Tabelle für viele Aufgaben

Eine einzelne Tabelle, die gezwungen wird, viele Domänen abzubilden — die generische Entity-Attribute-Value-Tabelle (EAV) oder eine notifications-Tabelle, die Benachrichtigungen, Audit-Events und Systemlogs enthält — tauscht Schema-Klarheit gegen eine trügerische Flexibilität. Typensicherheit geht verloren, sinnvolle Constraints können nicht angewendet werden, und jede Abfrage wird zu einem gefilterten, selbst-gejointen Durcheinander. Separate Tabellen pro Domäne verwenden, damit jede ihre eigenen Spalten, Typen und Foreign Keys erhält.

-- Vorher: ein Sammelbecken für alles, typisiert als text, keine echten Constraints
CREATE TABLE entity_attributes (
  entity_id bigint, attr_name text, attr_value text
);

-- Nachher: echte Tabellen mit echten Spalten und Constraints
CREATE TABLE user_alerts  (id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
                           user_id bigint NOT NULL REFERENCES users(id),
                           level text NOT NULL, body text);
CREATE TABLE audit_events (id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
                           actor_id bigint REFERENCES users(id),
                           action text NOT NULL,
                           occurred_at timestamptz NOT NULL DEFAULT now());

Wenn tatsächlich flexible Attribute benötigt werden, eine typisierte jsonb-Spalte auf der echten Tabelle verwenden — keine string-typisierte EAV-Tabelle, die jeden Constraint der Engine aushebelt.

Inkonsistente Benennung

Eine Namenskonvention wählen und konsequent überall anwenden. Für Postgres und die meisten ORMs bedeutet das:

  • snake_case (nicht in Anführungszeichen gesetzte Bezeichner werden zu Kleinbuchstaben umgewandelt);
  • Tabellennamen einheitlich im Plural oder Singular;
  • created_at / updated_at für Zeitstempel;
  • keine Metadaten in Namen (tbl_users, col_varchar_address);
  • keine Leerzeichen oder Anführungszeichen; und
  • keine reservierten Wörter wie user, order oder group als ungeschützte Bezeichner.

Benennung ist das Einfachste, was man beim Erstellen einer Tabelle richtig machen kann, und das Teuerste, was man ändern muss, sobald Daten und Abfragen davon abhängen.

Das Schema als unveränderlich betrachten

Ein Schema ist lebendiger Code, kein einmaliges Artefakt. Jede Änderung über versionierte, überprüfte und rückgängig machbare Migrationen verwalten — mit dem Migrationswerkzeug des ORMs (Prisma Migrate, Drizzle Kit) oder einem eigenständigen Runner —, damit die Schema-Historie umgebungsübergreifend reproduzierbar ist.

Die Historie bestimmt auch, wie gelöscht wird. Ein Soft Delete (ein deleted_at timestamptz) ist einem harten DELETE vorzuziehen, wenn andere Zeilen auf den Datensatz verweisen, da ein hartes Delete entweder Kinder verwaisen lässt oder mit ON DELETE CASCADE stillschweigend eine Historie löscht, die man später vorlegen soll.

ALTER TABLE orders ADD COLUMN deleted_at timestamptz;
-- "Aktive Bestellungen" wird zu einem Filter, keine destruktive Operation
CREATE VIEW active_orders AS SELECT * FROM orders WHERE deleted_at IS NULL;

Datumsangaben ohne Zeitzonen speichern

Zeitstempel als timestamptz statt als timestamp speichern — eine Spalte ohne Zeitzone zeichnet stillschweigend auf, was der Server gerade als „jetzt” betrachtete, und diese Mehrdeutigkeit wird unwiderruflich, sobald die Server mehrere Regionen umspannen. timestamptz speichert einen absoluten Zeitpunkt (normalisiert auf UTC) und gibt ihn in der Zeitzone der Session aus; timestamp speichert eine Wanduhrzeit ohne Referenzpunkt. Siehe die Postgres-Dokumentation zu Datum und Zeit. Für Zeitpläne und Buchungen hat Postgres 18 außerdem temporale Constraints eingeführt: Es unterstützt nicht-überlappende PRIMARY KEY-, UNIQUE- und Foreign-Key-Constraints, die mit WITHOUT OVERLAPS und PERIOD angegeben werden. (Temporale Foreign Keys unterstützen keine ON DELETE/ON UPDATE-Kaskadenaktionen, daher dort nicht auf Kaskaden verlassen.)

Keine Dokumentation oder Schema-Tests

Ein undokumentiertes, ungetestetes Schema ist ein Fehler, der sich still und leise aufschaukelt. Magic Values sind das klassische Symptom: ein status_code von 1–3, der in dem Moment bricht, in dem jemand eine 4 hinzufügt, ohne dass irgendwo festgehalten ist, was die einzelnen Zahlen bedeuten. Die Abhilfemaßnahmen sind konkret:

  • Absicht direkt in der Datenbank mit COMMENT ON COLUMN dokumentieren;
  • ein ER-Diagramm im Repository pflegen; und
  • Migrationen so testen wie Code — anwenden, zurückrollen und gegen Grenz- und Ungültigkeitseingaben prüfen.
COMMENT ON COLUMN orders.status IS
  'enum: pending|paid|shipped|cancelled — siehe app/orders/status.ts';

Ein CHECK (status IN (...))-Constraint kombiniert mit einem Kommentar verwandelt einen fragilen Integer in eine selbstbeschreibende, selbst-durchsetzende Spalte.

Fazit

Jeder hier beschriebene Fehler lässt sich auf dieselbe Lösung reduzieren: Die Datenbank — nicht die Anwendung und nicht das ORM — zur Autorität darüber machen, welche Daten gültig sind. Den Foreign Key, das NOT NULL, den CHECK und den UNIQUE-Constraint hinzufügen, bevor die Abfrage geschrieben wird, die sie voraussetzt. Mit der am häufigsten beschriebenen Tabelle beginnen: die Invarianten auflisten, die derzeit im Code durchgesetzt werden, und jede einzelne ins Schema verlagern, wo sie nicht übergangen werden kann.

FAQs

Wann sollte ich einen UUID-Primärschlüssel statt einer bigint-Identity-Spalte verwenden?

bigint Identity verwenden, wenn IDs innerhalb einer einzelnen Datenbank generiert werden und nie von Clients erstellt werden müssen, da er 8 Byte groß ist, sequenziell indiziert und die schnellste Option darstellt. Eine UUID verwenden — konkret uuidv7() in Postgres 18 —, wenn IDs clientseitig oder über mehrere Services hinweg generiert werden und globale Eindeutigkeit erforderlich ist. UUIDv7 behält eine nahezu sequenzielle Indexlokalität bei, da es ein Zeitstempel-Präfix einbettet, anders als die zufällige UUIDv4.

Erstellt die Deklaration eines Foreign Keys in meinem ORM auch einen datenbankinternen Constraint?

Nicht unbedingt. Tools wie Prisma und Drizzle können eine Relation im Anwendungscode modellieren, ohne einen datenbankinternen FOREIGN KEY-Constraint zu erzeugen, sodass die Beziehung nur im Verständnis des ORMs existiert und nicht in der Engine. Ohne den Datenbank-Constraint kann ein Hintergrundprozess, eine manuelle Abfrage oder ein anderer Service verwaiste Zeilen erzeugen, die das ORM nie vorhergesehen hat. Sicherstellen, dass die Migration tatsächlich eine REFERENCES-Klausel generiert, anstatt allein der Relationsdeklaration zu vertrauen.

Was ist der Unterschied zwischen timestamp und timestamptz in PostgreSQL?

timestamptz speichert einen absoluten Zeitpunkt, normalisiert auf UTC, und gibt ihn in der Zeitzone der Session aus, während timestamp eine Wanduhrzeit ohne Zeitzonenreferenz speichert. Ein einfaches timestamp zeichnet stillschweigend auf, was der Server als lokale Zeit betrachtete, und diese Mehrdeutigkeit wird unwiderruflich, sobald Server mehrere Regionen umspannen. timestamptz für Anwendungs-Datetimes verwenden, damit ein Wert immer auf einen eindeutigen Zeitpunkt verweist, unabhängig davon, wo er gelesen wird.

Benötige ich noch die pgcrypto- oder uuid-ossp-Extension, um UUIDs in Postgres zu generieren?

Nein. Die Funktion gen_random_uuid(), die eine UUID der Version 4 erzeugt, ist seit PostgreSQL Version 13 Teil des Cores, sodass keine Extension erforderlich ist. Die PG18-pgcrypto-Dokumentation kennzeichnet die eigene gen_random_uuid()-Funktion nun als veraltet, da sie die Core-Funktion aufruft. Ab Postgres 18 stehen außerdem die eingebauten Funktionen uuidv7() für zeitgeordnete Bezeichner und ein uuidv4()-Alias zur Verfügung — alles ohne Installation einer Extension.

Understand every bug

Uncover frustrations, understand bugs and fix slowdowns like never before with OpenReplay — self-hosted, with full data ownership.

Star on GitHub

We use cookies to improve your experience. By using our site, you accept cookies.