Wenn Datenbankmigrationen laufende Anwendungen zerstören

Ihr Team hat gerade ein neues Feature deployed. Der Rollout sieht sauber aus. Doch fünf Minuten später schickt der Bereitschaftsingenieur einen Screenshot der Fehlerlogs. Alte Anwendungsinstanzen stürzen mit Datenbankfehlern ab. Die Abfrage SELECT * FROM users WHERE status = 'active' schlägt plötzlich fehl. Was ist passiert?

Sie haben die Spalte status in der Migration von VARCHAR auf INT geändert. Der neue Anwendungscode verarbeitet Integers problemlos. Aber während eines Rolling Updates laufen alte und neue Anwendungsinstanzen parallel. Die alten Instanzen erwarten weiterhin Strings. Das Datenbankschema wurde unter ihnen geändert, und sie sind kaputtgegangen.

Das ist der Kernkonflikt von Datenbankmigrationen in modernen Deployments: Die Datenbank wird geteilt, aber die Anwendungsversionen nicht.

Das Problem der gemeinsamen Datenbank

Wenn Sie mit Rolling Updates, Blue-Green-Deployments oder Canary-Releases ausrollen, laufen mehrere Versionen Ihrer Anwendung gleichzeitig. Sie alle verbinden sich mit derselben Datenbank. Aber jede Version hat andere Erwartungen an das Schema.

Die alte Anwendung erwartet bestimmte Spalten, Datentypen und Constraints. Die neue Anwendung erwartet eine leicht andere Struktur. Beide müssen während der Übergangsphase korrekt funktionieren. Wenn Ihre Migration die Kompatibilität mit der alten Anwendung bricht, erhalten Sie Produktionsfehler.

Das ist kein theoretisches Problem. Es passiert jedes Mal, wenn eine Migration etwas ändert, von dem laufender Code abhängt.

Rückwärtskompatibilität: Die nicht verhandelbare Regel

Die grundlegende Regel ist einfach: Jede Migration muss rückwärtskompatibel mit der alten Anwendung sein. Der alte Code muss in der Lage sein, Daten ohne Fehler zu lesen und zu schreiben – auch nachdem die Migration ausgeführt wurde.

Betrachten Sie diese beiden SQL-Migrationen, um den Unterschied zu sehen:

-- Sicher: Eine nullable Spalte mit Default-Wert hinzufügen
-- Die alte App kann weiterhin INSERT ohne phone_number ausführen
ALTER TABLE users ADD COLUMN phone_number VARCHAR(20) DEFAULT NULL;

-- Breaking: Spaltentyp von VARCHAR auf INT ändern
-- Der SELECT * FROM users WHERE status = 'active' der alten App schlägt fehl
-- weil 'active' ein String ist, kein Integer
ALTER TABLE users ALTER COLUMN status TYPE INT USING status::integer;

Manche Änderungen sind von Natur aus rückwärtskompatibel. Das Hinzufügen einer nullable Spalte mit einem Default-Wert bricht beispielsweise keine bestehenden Abfragen. Das INSERT INTO users (name, email) der alten Anwendung funktioniert weiterhin, weil die neue Spalte phone Nullwerte akzeptiert.

Andere Änderungen brechen die Kompatibilität sofort. Das Ändern eines Spaltentyps, das Umbenennen einer Spalte, das Hinzufügen eines NOT-NULL-Constraints zu einer befüllten Spalte oder das Hinzufügen eines Fremdschlüssels, den vorhandene Daten nicht erfüllen können, verursacht Fehler in alten Anwendungsinstanzen.

Die Regel ist nicht optional. Wenn Sie keine Rückwärtskompatibilität garantieren können, können Sie nicht sicher mit Zero Downtime deployen.

Das Expand-Contract-Pattern

Der sicherste Weg, breaking changes zu handhaben, ist das Expand-Contract-Pattern, manchmal auch Dual-Write genannt. Die Idee ist, Änderungen in Phasen durchzuführen und alte Strukturen erst zu entfernen, wenn alle Anwendungsinstanzen aktualisiert wurden.

Das folgende Sequenzdiagramm zeigt den zeitlichen Ablauf des Expand-Contract-Patterns und veranschaulicht, wie alte und neue Anwendungsinstanzen während jeder Phase mit der Datenbank interagieren.

sequenceDiagram participant OldApp as Alte App participant DB as Datenbank participant NewApp as Neue App Note over OldApp,NewApp: Phase 1: Expand DB->>DB: Neue Spalte hinzufügen (alte behalten) OldApp->>DB: Liest/Schreibt alte Spalte (OK) NewApp->>DB: Schreibt beide Spalten (OK) Note over OldApp,NewApp: Phase 2: Daten migrieren DB->>DB: Neue Spalte aus alter befüllen Note over OldApp,NewApp: Phase 3: Alle Apps aktualisieren OldApp->>NewApp: Neue Version deployen NewApp->>DB: Liest/Schreibt beide Spalten (OK) Note over OldApp,NewApp: Phase 4: Contract DB->>DB: Alte Spalte löschen NewApp->>DB: Liest/Schreibt nur neue Spalte (OK)

Phase 1: Expand. Fügen Sie die neue Struktur hinzu, ohne die alte zu entfernen. Wenn Sie status (VARCHAR) durch status_id (INT) ersetzen möchten, fügen Sie die neue Spalte hinzu und behalten die alte. Die neue Anwendung schreibt in beide Spalten. Die alte Anwendung verwendet weiterhin status. Beide funktionieren.

Phase 2: Daten migrieren. Befüllen Sie die neue Spalte mit konvertierten Werten aus der alten Spalte. Dies kann als Hintergrundjob oder als separater Migrationsschritt laufen.

Phase 3: Anwendungscode aktualisieren. Deployen Sie die neue Anwendungsversion auf alle Instanzen. Jetzt kennt jede laufende Instanz beide Spalten.

Phase 4: Contract. Entfernen Sie in einem separaten Deployment die alte Spalte. Zu diesem Zeitpunkt hängt keine laufende Anwendung mehr davon ab.

Das Pattern erhöht die Komplexität. Ihr Anwendungscode muss während der Übergangsphase die Dual-Write-Logik handhaben. Sie haben vorübergehend zusätzliche Spalten zu pflegen. Aber das ist der Preis für die Vermeidung von Ausfallzeiten und Fehlern während des Deployments.

Vorwärtskompatibilität: Die andere Richtung

Rückwärtskompatibilität schützt alten Anwendungscode. Vorwärtskompatibilität schützt neuen Anwendungscode, wenn die Datenbank noch nicht vollständig migriert ist.

Betrachten Sie ein Szenario, in dem Sie zuerst die neue Anwendung deployen, die Migration aber noch nicht auf allen Datenbankreplikaten ausgeführt wurde. Der neue Code muss sowohl alte als auch neue Schemaformate verarbeiten können. Wenn er status als VARCHAR liest, aber INT erwartet, sollte er die Konvertierung graceful handhaben.

Vorwärtskompatibilität ist schwieriger zu erreichen und hat normalerweise Grenzen. Sie bedeutet, dass Ihr neuer Code defensiv gegenüber den gelesenen Daten sein muss. Er sollte nicht davon ausgehen, dass sich das Schema bereits geändert hat. Dies bedeutet oft, Fallback-Logik oder Datenkonvertierung in der Anwendungsschicht hinzuzufügen, bis die Migration abgeschlossen ist.

Mehr als Spalten: Indizes, Constraints und Fremdschlüssel

Kompatibilität betrifft nicht nur Spalten und Datentypen. Auch Indizes, Constraints und Fremdschlüssel können laufende Anwendungen zerstören.

Das Hinzufügen eines neuen Fremdschlüssel-Constraints kann dazu führen, dass vorhandene INSERT- oder UPDATE-Abfragen fehlschlagen, wenn die referenzierten Daten nicht existieren. Das Hinzufügen eines UNIQUE-Constraints zu einer Spalte, die zuvor Duplikate erlaubte, bricht jede Abfrage, die versucht, doppelte Werte einzufügen. Selbst das Hinzufügen eines Index kann Leistungsprobleme verursachen, wenn die Datenbank die Tabelle während der Indexerstellung sperrt.

Jede Schemaänderung muss auf ihre Auswirkungen auf laufenden Anwendungscode bewertet werden. Fragen Sie sich: Wird diese Änderung dazu führen, dass eine Abfrage der alten Anwendung fehlschlägt? Wird sie das Verhalten auf eine Weise ändern, die der alte Code nicht erwartet?

Praktische Checkliste für sichere Migrationen

Bevor Sie eine Migration in der Produktion ausführen, überprüfen Sie diese Punkte:

  • Kann die alte Anwendung nach der Migration alle vorhandenen Daten ohne Fehler lesen?
  • Kann die alte Anwendung nach der Migration neue Daten ohne Fehler schreiben?
  • Sind alle neuen Spalten nullable oder haben sie Default-Werte?
  • Gelten neue Constraints bereits für vorhandene Daten?
  • Wird die Migration Tabellensperren verursachen, die Abfragen blockieren?
  • Gibt es einen Rollback-Plan, falls etwas schiefgeht?

Das Fazit

Datenbankmigrationen während Zero-Downtime-Deployments erfordern, dass das Schema als gemeinsame Schnittstelle zwischen Anwendungsversionen behandelt wird. Jede Migration muss rückwärtskompatibel mit dem alten Code sein. Breaking Changes benötigen das Expand-Contract-Pattern: zuerst neue Strukturen hinzufügen und alte erst entfernen, nachdem alle Instanzen aktualisiert wurden.

Die Datenbank ist die einzige Quelle der Wahrheit, die alle Anwendungsversionen teilen. Wenn Sie sie unvorsichtig ändern, zerstören Sie laufenden Code. Entwerfen Sie Ihre Migrationen wie Brücken, die sowohl alte als auch neue Anwendungen sicher überqueren können. Erst nachdem jede Instanz auf die neue Seite gewechselt ist, sollten Sie die Brücke selbst modifizieren.