Lerne ausfallfreie Schema-Änderungen mit dem Expand/Contract-Muster: sicher Spalten hinzufügen, Daten in Batches backfillen, kompatiblen Code ausrollen und alte Pfade erst danach entfernen.

Ausfälle durch eine Datenbankänderung sind nicht immer ein sauberer, offensichtlicher Ausfall. Für Nutzer kann es so aussehen, als würde eine Seite endlos laden, ein Checkout fehlschlägt oder die App plötzlich „etwas ist schiefgelaufen“ anzeigt. Für Teams zeigt es sich in Alerts, steigenden Fehlerraten und einem Berg fehlgeschlagener Schreibvorgänge, die bereinigt werden müssen.
Schema-Änderungen sind riskant, weil die Datenbank von allen laufenden Versionen deiner App geteilt wird. Während eines Releases laufen oft alte und neue Versionen gleichzeitig (Rolling Deploys, mehrere Instanzen, Background Jobs). Eine Migration, die korrekt aussieht, kann trotzdem eine dieser Versionen kaputtmachen.
Gängige Fehlerfälle sind:
Selbst wenn der Code korrekt ist, werden Releases blockiert, weil das eigentliche Problem Timing und Kompatibilität zwischen Versionen ist.
Ausfallfreie Schema-Änderungen folgen einer einfachen Regel: jeder Zwischenzustand muss für alten und neuen Code sicher sein. Du änderst die Datenbank, ohne bestehende Lese- und Schreibvorgänge zu zerstören, rollst Code aus, der mit beiden Formen umgehen kann, und entfernst den alten Pfad erst, wenn nichts mehr darauf angewiesen ist.
Dieser Mehraufwand zahlt sich aus, wenn du echten Traffic, strenge SLAs oder viele App-Instanzen und Worker hast. Für ein kleines internes Tool mit ruhiger Datenbank kann ein geplantes Wartungsfenster einfacher sein.
Die meisten Zwischenfälle bei Datenbankarbeit entstehen, weil die App erwartet, dass die DB sofort umgestellt ist, während die Änderung Zeit braucht. Das Expand/Contract-Muster vermeidet das, indem es eine riskante Änderung in kleinere, sichere Schritte aufteilt.
Für eine kurze Zeit unterstützt dein System zwei „Dialekte“ gleichzeitig. Du führst die neue Struktur zuerst ein, lässt die alte weiter bestehen, verschiebst Daten graduell und räumst dann auf.
Das Muster ist einfach:
Das passt gut zu Rolling Deploys. Wenn du 10 Server nacheinander aktualisierst, laufen alte und neue Versionen kurz parallel. Expand/Contract sorgt dafür, dass beide Versionen während dieser Überschneidung mit derselben Datenbank kompatibel bleiben.
Es macht Rollbacks ebenfalls weniger beängstigend. Wenn ein neues Release einen Fehler hat, kannst du die App zurücksetzen, ohne die Datenbank zu rollbacken, weil die alten Strukturen während des Expand-Fensters noch vorhanden sind.
Beispiel: Du willst eine PostgreSQL-Spalte full_name in first_name und last_name aufteilen. Du fügst die neuen Spalten hinzu (expand), rollst Code, der beide Formen lesen und schreiben kann, füllst alte Zeilen nach und entfernst full_name, sobald nichts mehr davon abhängt (contract).
Die Expand-Phase bedeutet, neue Optionen hinzuzufügen, nicht alte zu entfernen.
Ein häufiger erster Schritt ist eine neue Spalte. In PostgreSQL ist es meist am sichersten, sie nullable und ohne Default hinzuzufügen. Eine nicht-nullbare Spalte mit Default kann je nach Postgres-Version und Änderung einen Table-Rewrite oder stärkere Locks auslösen. Eine sicherere Reihenfolge ist: nullable hinzufügen, toleranten Code deployen, backfill durchführen und erst später NOT NULL erzwingen.
Indizes brauchen ebenfalls Aufmerksamkeit. Das Erstellen eines normalen Index kann Schreibvorgänge länger blockieren als erwartet. Nutze, wenn möglich, concurrent Index-Erstellung, damit Lesen und Schreiben weiterlaufen. Das dauert länger, verhindert aber freigabestoppende Locks.
Expand kann auch das Hinzufügen neuer Tabellen bedeuten. Wenn du von einer einzelnen Spalte zu einer Many-to-Many-Beziehung wechselst, fügst du vielleicht eine Join-Tabelle hinzu und lässt die alte Spalte noch bestehen. Der alte Pfad bleibt funktionsfähig, während die neue Struktur Daten sammelt.
In der Praxis umfasst Expand oft:
Nach Expand sollten alte und neue App-Versionen gleichzeitig laufen können, ohne Überraschungen.
Der meiste Schmerz entsteht in der Mitte: Einige Server laufen mit neuem Code, andere noch mit altem, während die Datenbank sich bereits ändert. Dein Ziel ist einfach: Jede Version im Rollout muss mit altem und erweitertem Schema funktionieren.
Ein gängiger Ansatz ist Dual-Write. Wenn du eine neue Spalte hinzufügst, schreibt die neue App sowohl in die alte als auch in die neue Spalte. Alte App-Versionen schreiben weiter nur in die alte Spalte, was in Ordnung ist, weil sie noch existiert. Halte die neue Spalte zunächst optional und verschiebe strikte Constraints, bis alle Writer aktualisiert sind.
Lesende Zugriffe werden oft vorsichtiger umgestellt als Schreibvorgänge. Lass eine Zeit lang die alten Spalten lesen (die vollständig gefüllt sind). Nach Backfill und Verifikation stellst du die Leselogik so um, dass sie das neue Feld bevorzugt und bei fehlendem Wert auf das alte zurückfällt.
Halte auch die API-Ausgabe stabil, während sich die Datenbank darunter ändert. Selbst wenn du ein neues internes Feld einführst, vermeide Änderungen an Response-Formaten, bis alle Konsumenten (Web, Mobile, Integrationen) bereit sind.
Ein rollback-freundlicher Ablauf könnte so aussehen:
Die zentrale Idee ist, dass der erste irreversible Schritt das Löschen der alten Struktur ist — den verschiebst du ans Ende.
Backfilling ist der Punkt, an dem viele „ausfallfreie" Schema-Änderungen schiefgehen. Du willst die neue Spalte für bestehende Zeilen füllen, ohne lange Locks, langsame Queries oder unerwartete Lastspitzen.
Batching ist entscheidend. Ziel sind Batches, die schnell fertig werden (Sekunden, nicht Minuten). Sind die Batches klein, kannst du pausieren, fortsetzen und den Job anpassen, ohne Releases zu blockieren.
Zur Fortschrittsverfolgung nutze einen stabilen Cursor — in PostgreSQL oft der Primärschlüssel. Verarbeite Zeilen in Reihenfolge und speichere die zuletzt verarbeitete id oder arbeite in id-Bereichen. So vermeidest du teure Full-Table-Scans beim Neustart des Jobs.
Ein einfaches Muster ist:
UPDATE my_table
SET new_col = ...
WHERE new_col IS NULL
AND id > $last_id
ORDER BY id
LIMIT 1000;
Mache das Update bedingt (z. B. WHERE new_col IS NULL), sodass der Job idempotent ist. Wiederholte Läufe berühren nur Zeilen, die noch Arbeit brauchen, und reduzieren unnötige Writes.
Plane für neue Daten, die während des Backfills ankommen. Übliche Reihenfolge ist:
Ein guter Backfill ist langweilig: stetig, messbar und einfach zu pausieren, falls die DB heiß läuft.
Der riskanteste Moment ist nicht das Hinzufügen der neuen Spalte — es ist die Entscheidung, ihr zu vertrauen.
Bevor du zu Contract übergehst, beweise zwei Dinge: die neuen Daten sind vollständig, und Produktion liest sie sicher.
Beginne mit schnellen, wiederholbaren Vollständigkeitsprüfungen:
Wenn du dual-schreibst, füge eine Konsistenzprüfung hinzu, um stille Fehler zu finden. Zum Beispiel eine stündliche Abfrage, die Zeilen findet, in denen old_value \u003c\u003e new_value, und alarmiere, wenn das Resultat nicht Null ist. Das ist oft der schnellste Weg, zu entdecken, dass ein Writer nur das alte Feld aktualisiert.
Beobachte grundlegende Produktionssignale, während die Migration läuft. Wenn Query-Zeiten oder Lock-Waits ansteigen, können selbst deine „sicheren" Verifizierungsabfragen Last hinzufügen. Überwache Fehlerraten für Codepfade, die das neue Feld lesen, besonders direkt nach Deploys.
Wie lange beide Pfade laufen sollten? Länger als ein Release-Zyklus und mindestens solange, bis ein Backfill-Neustart durchgelaufen ist. Viele Teams halten 1–2 Wochen oder bis sie sicher sind, dass keine alte App-Version mehr läuft.
Contract ist der Moment, vor dem Teams oft Nervosität haben, weil es sich wie der point of no return anfühlt. Wenn Expand korrekt gemacht wurde, ist Contract größtenteils Aufräumen und lässt sich in kleinen, risikoarmen Schritten durchführen.
Wähle den Zeitpunkt sorgfältig. Lösche nichts sofort nach einem Backfill-Finish. Warte mindestens einen Release-Zyklus, damit verzögerte Jobs und Randfälle sich zeigen können.
Eine sichere Contract-Reihenfolge sieht meist so aus:
Wenn möglich, teile Contract in zwei Releases: eins, das Code-Referenzen entfernt (mit zusätzlichem Logging), und ein späteres, das Datenbankobjekte löscht. Diese Trennung macht Rollback und Troubleshooting erheblich leichter.
PostgreSQL-spezifika sind hier wichtig. Das Löschen einer Spalte ist meist eine Metadaten-Änderung, kann aber kurzzeitig einen ACCESS EXCLUSIVE-Lock benötigen. Plane ein ruhiges Fenster und halte die Migration kurz. Falls du zusätzliche Indizes erstellt hast, ziehe in Betracht, sie mit DROP INDEX CONCURRENTLY zu entfernen, um Schreibvorgänge nicht zu blockieren (dies kann nicht in einem Transaktionsblock laufen, also muss dein Migrationstool das unterstützen).
Ausfallfreie Migrationen scheitern, wenn Datenbank und App aufhören, sich einig zu sein, was erlaubt ist. Das Muster funktioniert nur, wenn jeder Zwischenzustand für alten und neuen Code sicher ist.
Diese Fehler treten oft auf:
Ein realistisches Szenario: Du beginnst, full_name über die API zu setzen, aber ein Hintergrundjob, der Nutzer anlegt, setzt weiterhin nur first_name und last_name. Er läuft nachts, legt Zeilen mit full_name = NULL an, und späterer Code geht davon aus, dass full_name immer vorhanden ist.
Behandle jeden Schritt wie ein Release, das Tage laufen kann:
Eine wiederholbare Checkliste verhindert, dass du Code auslieferst, der nur in einem Datenbankzustand funktioniert.
Vor dem Deploy bestätige, dass die Datenbank bereits die erweiterten Teile hat (neue Spalten/Tabellen, Indizes low-lock angelegt). Dann stelle sicher, dass die App tolerant ist: sie muss mit altem, erweitertem und halb-backfilltem Zustand funktionieren.
Halte die Checkliste kurz:
Eine Migration ist erst dann erledigt, wenn Reads die neuen Daten nutzen, Writes das Alte nicht mehr pflegen und du den Backfill mit mindestens einer einfachen Prüfung (Counts oder Sampling) verifiziert hast.
Angenommen, du hast eine PostgreSQL-Tabelle customers mit einer Spalte phone, die uneinheitliche Werte speichert. Du willst sie durch phone_e164 ersetzen, kannst aber Releases nicht blockieren oder die App herunterfahren.
Eine saubere Expand/Contract-Abfolge könnte so aussehen:
phone_e164 nullable und ohne Default hinzufügen, noch keine strengen Constraints.phone als auch phone_e164 schreibt, aber Lesungen weiterhin phone nutzen, damit sich für Nutzer nichts ändert.phone_e164 liest und bei NULL auf phone zurückfällt.phone_e164 nutzt, den Fallback entfernen, phone löschen und bei Bedarf strengere Constraints hinzufügen.Rollbacks bleiben einfach, wenn jeder Schritt rückwärtskompatibel ist. Führt der Read-Switch zu Problemen, rollst du die App zurück und die DB hat weiterhin beide Spalten. Verursacht der Backfill Lastspitzen, pausierst du den Job, verringerst die Batch-Größe und setzt später fort.
Wenn das Team auf Kurs bleiben soll, dokumentiere den Plan an einem Ort: das genaue SQL, welches Release die Reads flippt, wie du Fertigstellung misst (z. B. Prozent non-NULL phone_e164) und wer welche Schritte verantwortet.
Expand/Contract funktioniert am besten, wenn es Routine ist. Schreibe ein kurzes Runbook, das dein Team für jede Schema-Änderung wiederverwenden kann — idealerweise eine Seite und spezifisch genug, dass auch ein neues Teammitglied folgen kann.
Eine praktische Vorlage umfasst:
Bestimme Verantwortlichkeiten im Voraus. „Jeder dachte, jemand anderes würde Contract machen“ ist der Grund, warum alte Spalten und Feature-Flags monatelang leben.
Auch bei online laufendem Backfill plane ihn während geringerer Last. Dann sind Batches einfacher klein zu halten, DB-Last leichter zu beobachten und ein Stopp rasch möglich.
Wenn du mit Koder.ai (koder.ai) buildest und deployst, kann Planning Mode nützlich sein, um Phasen und Checkpoints zu skizzieren, bevor du Produktion anfasst. Dieselben Kompatibilitätsregeln gelten, aber niedergeschriebene Schritte machen es schwerer, die langweiligen Teile zu überspringen, die Ausfälle verhindern.
Weil die Datenbank von allen laufenden Versionen deiner App geteilt wird. Während Rolling Deploys und durch Hintergrundjobs laufen alte und neue Versionen gleichzeitig, und eine Migration, die Namen ändert, Spalten löscht oder Constraints hinzufügt, kann die Version brechen, die nicht für genau diesen Schema-Zustand geschrieben wurde.
Es bedeutet, die Migration so zu gestalten, dass jeder Zwischenzustand sowohl für alten als auch für neuen Code funktioniert. Du fügst neue Strukturen zuerst hinzu, betreibst beide Wege eine Weile parallel und entfernst die alten Strukturen erst, wenn nichts mehr von ihnen abhängig ist.
Expand fügt neue Spalten, Tabellen oder Indizes hinzu, ohne etwas zu entfernen, das die aktuelle App benötigt. Contract ist die Aufräumphase, in der du alte Spalten, alte Lese-/Schreibpfade und temporäre Sync-Logik entfernst, nachdem der neue Pfad bewiesen funktionsfähig ist.
Als Ausgangspunkt meist eine nullable Spalte ohne Default — das vermeidet schwere Locks und hält alten Code funktionsfähig. Dann deployst du Code, der mit fehlender oder NULL-Spalte umgehen kann, füllst die Daten schrittweise nach und verschärfst Constraints wie NOT NULL erst später.
Dual-Write benutzt du während der Transition, wenn die neue Version in beide Felder schreibt (alt und neu). So bleiben Daten konsistent, während noch ältere App-Instanzen und Jobs existieren, die nur das alte Feld kennen.
Backfill in kleinen Batches, die schnell fertig werden, und mache jede Charge idempotent, so dass Wiederholungen nur noch benötigte Zeilen berühren. Überwache Query-Latenz, Lock-Waits und Replikationsverzögerung und sei bereit, den Job zu pausieren oder die Batch-Größe zu reduzieren, wenn die DB heiß läuft.
Prüfe zuerst die Vollständigkeit, z. B. wie viele Zeilen noch NULL in der neuen Spalte haben. Führe dann Konsistenzprüfungen durch — stichprobenartig oder kontinuierlich — und beobachte Produktionsfehler nach Deploys, um Pfade zu finden, die noch das falsche Schema verwenden.
NOT NULL zu früh setzen, ein riesiges Backfill in einer einzigen Transaktion, die Annahme, dass Defaults kostenlos sind (manche Defaults fordern Table-Rewrites in Postgres), das Lesen auf das neue Feld umstellen, bevor Schreiben es zuverlässig füllt, und andere Writer/Reader (Cron-Jobs, Worker, Exporte) zu übersehen.
Erst wenn du aufgehört hast, das alte Feld zu schreiben, die Lesepfade auf das neue Feld umgestellt sind ohne Fallback und du lange genug gewartet hast, um sicherzugehen, dass keine alten App-Versionen oder Worker mehr laufen. Viele Teams machen das als separaten Release, damit ein Rollback einfach bleibt.
Wenn du ein Wartungsfenster tolerieren kannst und wenig Traffic hast, ist eine Einmal-Migration oft ausreichend. Bei echten Nutzern, vielen Instanzen, Background-Workern oder SLAs ist das Expand/Contract-Verfahren meist den Mehraufwand wert, weil es Rollouts und Rollbacks sicherer macht; im Koder.ai Planning Mode hilft das Aufschreiben der Phasen, die langweiligen, aber wichtigen Schritte nicht zu überspringen.