Datenbankmigration im laufenden Betrieb: Expand-Contract-Muster, die Downtime vermeiden

#Datenbankmigration ohne Downtime
Sandor Farkas - Founder & Lead Developer at Wolf-Tech

Sandor Farkas

Gründer & Lead Developer

Experte für Softwareentwicklung und Legacy-Code-Optimierung

Die meisten Teams haben ein Verfahren, um Codeänderungen ohne Downtime auszuliefern. Deutlich weniger haben eines für Schemaänderungen. Dabei ist die Datenbank genau der Ort, an dem Pläne für eine Datenbankmigration ohne Downtime zusammenbrechen. Ein ALTER TABLE im Hintergrund auf einer Tabelle mit 50 Millionen Zeilen kann einen Lock minutenlang halten. Eine Spalte umzubenennen zerlegt die laufende Anwendung in dem Moment, in dem die Migration committet. Eine Spalte zu löschen funktioniert prima, bis die alte Codeversion, die während eines Rolling Deployments noch Traffic bedient, in Panik gerät, weil die Spalte weg ist.

Das Expand-Contract-Muster löst das. Es ist kein einzelnes Kommando und keine Bibliothek, sondern eine Disziplin: Jede Schemaänderung wird in zwei oder mehr sichere, unabhängig deploybare Schritte zerlegt. Jeder Schritt hinterlässt die Datenbank in einem Zustand, der sowohl mit der aktuellen als auch mit der vorherigen Version deines Anwendungscodes funktioniert.

Warum Schemaänderungen anders sind als Codeänderungen

Codeänderungen lassen sich normalerweise in Sekunden zurückrollen. Du rollst ein Deployment zurück, und das alte Artefakt bedient wieder Requests. Bei Schemaänderungen geht das nicht: Ein committetes ALTER TABLE bleibt committet. Du kannst es mit einer weiteren Migration rückgängig machen, aber du kannst den Commit nicht ohne einen weiteren Durchlauf durch deine Deployment-Pipeline zurücknehmen.

Diese Asymmetrie bedeutet, dass eine Schemaänderung und der davon abhängige Code nicht atomar deployt werden können, jedenfalls nicht auf einem laufenden System, das durchgehend Requests bedienen soll. Irgendetwas muss nachgeben. Das Expand-Contract-Muster sagt: Lass die Datenbank vorübergehend beide Repräsentationen tragen, alt und neu, und lass den Code in einem separaten Deployment nachziehen.

Die drei Phasen

Expand heißt: dem Schema etwas hinzufügen, ohne etwas zu entfernen. Neue Spalten werden nullable oder mit Defaults angelegt. Neue Tabellen kommen hinzu. Neue Indizes werden nebenläufig gebaut (PostgreSQL unterstützt CREATE INDEX CONCURRENTLY, was keinen Tabellen-Lock hält). Nichts, was der aktuelle Anwendungscode nutzt, wird angefasst.

Migrate ist der Schritt der Datenbewegung. Du befüllst die neue Spalte mit Werten, die aus der alten abgeleitet sind. Du kopierst Zeilen zwischen Tabellen. Du füllst Lookup-Daten. In dieser Phase können alter und neuer Code gleichzeitig laufen, weil die alte Spalte noch existiert und die neue bereits befüllt ist. Ein Background Job oder ein gebatchtes SQL-Skript erledigt das; es muss nicht in einer einzigen Transaktion laufen.

Contract ist die Aufräumphase: die alte Spalte entfernen, die alte Tabelle löschen, die Kompatibilitäts-Codepfade herausnehmen. Das läuft erst, wenn du bestätigt hast, dass keine laufende Anwendungsinstanz mehr aus der alten Struktur liest oder in sie schreibt. Bei einem Rolling Deployment heißt das: warten, bis 100 Prozent der Instanzen auf die Version umgestellt sind, die die neue Struktur nutzt.

Ein konkretes Beispiel: eine Spalte umbenennen

Eine Spalte umzubenennen ist eine der einfachsten Schemaänderungen und eine der gefährlichsten, wenn man es naiv macht. So handhabt Expand-Contract das.

Angenommen, du hast eine users-Tabelle mit einer Spalte full_name. Du willst sie in display_name umbenennen.

Schritt 1 - Expand. Neue Spalte hinzufügen:

ALTER TABLE users ADD COLUMN display_name VARCHAR(255);

An diesem Punkt ist display_name für alle Zeilen null. Die aktuelle Anwendung liest und schreibt weiterhin full_name und merkt nichts.

Schritt 2 - Den Dual-Write-Code ausliefern. Bringe eine Version deiner Anwendung live, die bei jedem Schreibvorgang sowohl in full_name als auch in display_name schreibt und bei jedem Lesevorgang aus full_name liest (der maßgeblichen Quelle). Diese Version ist abwärtskompatibel: Sie funktioniert auch, wenn daneben noch eine ältere Instanz läuft.

Schritt 3 - Backfill. Kopiere die Daten für bestehende Zeilen von alt nach neu:

UPDATE users SET display_name = full_name WHERE display_name IS NULL;

Führe das auf einer großen Tabelle in Batches statt in einer einzigen Transaktion aus, um lange Lock-Konkurrenz zu vermeiden. In Symfony ist ein Console Command, der Zeilen in Blöcken von einigen Tausend verarbeitet und zwischen den Batches kurz pausiert, ein praktikabler Ansatz. Auf PostgreSQL kannst du zusätzlich pg_advisory_lock nutzen, um bei seltenen Schreibvorgängen nicht mit Anwendungs-Writes zu konkurrieren.

Schritt 4 - Lesevorgänge auf die neue Spalte umstellen. Deploye eine Version, die aus display_name liest und weiterhin in beide Spalten schreibt. Verifiziere die Korrektheit unter Produktions-Traffic. Deine alte Version läuft zu diesem Zeitpunkt nicht mehr.

Schritt 5 - Schreibvorgänge auf die alte Spalte einstellen. Deploye eine Version, die ausschließlich display_name liest und schreibt. Die Spalte full_name ist jetzt verwaist.

Schritt 6 - Contract. Sobald du sicher bist, dass kein aktiver Prozess full_name mehr anfasst, lösche sie:

ALTER TABLE users DROP COLUMN full_name;

Auf PostgreSQL ist das Löschen einer Spalte typischerweise schnell, weil Postgres sie als gelöscht markiert, statt die Tabelle sofort neu zu schreiben. Der tatsächliche Speicherplatz wird verzögert per VACUUM zurückgewonnen oder sofort mit VACUUM FULL (das allerdings sperrt).

Umgang mit NOT-NULL-Constraints

Eine NOT NULL-Spalte zu einer großen Tabelle hinzuzufügen erforderte in PostgreSQL früher ein komplettes Neuschreiben der Tabelle, weil Postgres das Constraint für jede Zeile prüfen musste. Seit PostgreSQL 11 kannst du eine NOT NULL-Spalte mit einem nicht-volatilen Default ohne Rewrite hinzufügen, weil Postgres den Default-Wert als Metadatum speichert, statt ihn in jede Zeile zu schreiben.

Für Versionen vor 11, oder wenn der Default berechnet statt konstant ist, ist der sichere Weg:

  1. Die Spalte als nullable hinzufügen.
  2. Alle Zeilen backfillen.
  3. Ein DEFAULT ergänzen, damit neue Zeilen abgedeckt sind.
  4. Das Constraint als NOT VALID hinzufügen. Das überspringt die Prüfung bestehender Zeilen.
  5. VALIDATE CONSTRAINT separat ausführen. Das nimmt nur einen SHARE UPDATE EXCLUSIVE-Lock, der weder Lese- noch Schreibvorgänge blockiert.

Das sind mehr Schritte, aber jeder einzelne blockiert nicht.

Index-Erstellung ohne Locking

Ein reguläres CREATE INDEX hält in PostgreSQL einen SHARE-Lock und blockiert für die gesamte Dauer alle Schreibvorgänge. CREATE INDEX CONCURRENTLY baut den Index in mehreren Durchläufen, während die Tabelle für Lesen und Schreiben voll verfügbar bleibt. Der Kompromiss: Es dauert länger und braucht mehr CPU. Auf einem Produktivsystem im laufenden Betrieb ist das der richtige Kompromiss.

Die Expand-Phase jeder Schemamigration, die einen Index hinzufügt, sollte immer CONCURRENTLY verwenden. In Doctrine Migrations oder in rohen SQL-Migrationsskripten heißt das: eine separate Migrationsdatei für die Index-Erstellung, damit sie unabhängig von der eigentlichen Schemaänderung laufen kann.

Beachte, dass CREATE INDEX CONCURRENTLY nicht innerhalb einer Transaktion laufen kann. Wenn dein Migrations-Framework standardmäßig alle Statements in eine Transaktion packt (Doctrine tut das), musst du das für die Index-Migration entweder deaktivieren oder das CREATE INDEX CONCURRENTLY-Statement in einem separaten manuellen Schritt ausführen.

Wo das in ein Legacy-System-Upgrade passt

Wenn du an einem größeren Vorhaben zur Legacy-Code-Optimierung arbeitest, sind Datenbank-Schemaänderungen oft der riskanteste Teil. Legacy-Systeme sammeln breite Tabellen mit mehrdeutigen Spaltennamen an, implizite Constraints, die nur im Anwendungscode durchgesetzt werden, und fehlende Indizes auf Fremdschlüsseln. Das aufzuräumen erfordert genau die Expand-Contract-Disziplin: Zehn Jahre Schema-Drift lassen sich nicht in einer einzigen Migration korrigieren, ohne das System herunterzufahren.

Dasselbe gilt für Teams in der individuellen Softwareentwicklung, bei der Daten von einem alten in ein neues System migriert werden. Häufig müssen altes und neues Schema während einer Übergangsphase parallel laufen, während Daten verifiziert und migriert werden. Expand-Contract gibt dir eine strukturierte Möglichkeit, diese Phase ohne harten Cutover zu steuern.

Überlegungen zum Tooling

Die meisten Migrationstools (Doctrine Migrations, Flyway, Liquibase) sind um lineare, sequenzielle Migrationen herum gebaut. Sie erzwingen Expand-Contract nicht als Policy; das ist die Verantwortung deines Teams. Was sie liefern, ist Versionierung und Nachverfolgung, damit du nachvollziehen kannst, welche Migrationsschritte in welcher Umgebung gelaufen sind.

Ein paar Praktiken helfen:

Halte Migrationsschritte klein und benenne sie nach ihrer Absicht. Eine Datei namens 1_add_display_name_column.sql ist leichter zu erfassen als eine große Migration, die fünf Dinge gleichzeitig tut.

Mische Expand, Migrate und Contract nie in einer Datei. Sind die Schritte getrennt, kannst du den Expand-Schritt deployen, laufen lassen, verifizieren, die Codeänderung ausliefern und den Contract-Schritt für ein späteres Release einplanen. Stecken sie in einer Datei, verlierst du diese Flexibilität.

Teste den Nullzustand. Verifiziere vor dem Contract-Schritt im Staging, dass kein Anwendungscode mehr die alte Spalte liest. Ein einfaches Grep über die Codebasis plus eine kurze Beobachtungsphase in Produktion mit Query-Logging reicht in der Regel.

Die Disziplin ist der Punkt

Das Expand-Contract-Muster ist technisch nicht schwierig. Das SQL ist unkompliziert. Die Herausforderung ist organisatorisch: Teams stehen unter Auslieferungsdruck, und eine zweistufige Migration über zwei Deployment-Zyklen fühlt sich langsamer an als eine einzelne Migration, die es in einem Rutsch erledigt.

Aber die einstufige Migration ist nur schneller, wenn nichts schiefgeht. Wenn eine gesperrte Tabelle um 2 Uhr nachts eine Kaskade von Timeouts auslöst, wirkt der Expand-Contract-Ansatz plötzlich wie der schnellere Weg.

Wenn dein Team einen Rückstau an Schemaänderungen auf einem laufenden System vor sich hat und unsicher ist, wie sich diese sicher sequenzieren lassen, ist das genau die Art von Problem, die Wolf-Tech regelmäßig bearbeitet. Melde dich unter hello@wolf-tech.io oder besuche wolf-tech.io, um über deine konkrete Situation zu sprechen.