Schema-Migrationen gehören zu den riskantesten Operationen im Betrieb einer Anwendung. Ein einzelnes ALTER TABLE auf einer Tabelle mit 50 Millionen Zeilen kann die Datenbank für Minuten blockieren. In einer Welt, in der Nutzer Ausfallzeiten nicht mehr tolerieren, reicht “kurz Maintenance-Fenster” nicht aus. Dieser Artikel zeigt, wie man Schema-Änderungen sicher und ohne Downtime durchfuehrt.
Warum “einfach migrieren” nicht funktioniert
Das Problem besteht aus drei Teilen, die sich gegenseitig verstaerken.
Locks: Die meisten DDL-Statements in MySQL und PostgreSQL erfordern einen exklusiven Lock auf die Tabelle. Ein ALTER TABLE ... ADD COLUMN blockiert alle Reads und Writes, bis die Operation abgeschlossen ist. Bei kleinen Tabellen fällt das nicht auf. Bei Tabellen mit zweistelligen Millionen-Zeilen kann das Minuten dauern.
Lange Laufzeiten: Ein ALTER TABLE auf einer großen Tabelle muss die gesamte Tabelle neu schreiben. PostgreSQL ist hier besser als MySQL (manche Operationen sind in PG nur Metadaten-Änderungen), aber bei komplexeren Umbauten bleibt auch PG nicht verschont.
Rolling Deployments: In einem Cluster mit mehreren App-Instanzen laufen während eines Deployments Version N und Version N+1 gleichzeitig. Wenn die Migration eine Spalte entfernt, die Version N noch liest, gibt es Fehler. Die Migration muss mit beiden App-Versionen kompatibel sein.
Das Expand-Contract Pattern
Das Expand-Contract Pattern (auch Parallel Change genannt) ist die Grundlage für alle Zero-Downtime-Migrationen. Die Idee: Statt eine destruktive Änderung in einem Schritt durchzufuehren, teilt man sie in drei Phasen auf.
Phase 1 — Expand: Neue Strukturen hinzufuegen, ohne alte zu entfernen. Neue Spalten, neue Tabellen, neue Indizes. Der bestehende Code läuft weiter.
Phase 2 — Migrate: Daten in die neue Struktur ueberfuehren. Neuer Code schreibt in beide Strukturen (dual-write). Bestehende Daten werden im Hintergrund migriert.
Phase 3 — Contract: Alte Strukturen entfernen, nachdem sichergestellt ist, dass kein Code mehr darauf zugreift.
-- Phase 1: Expand -- neue Tabelle erstellen
CREATE TABLE user_profiles (
user_id BIGINT PRIMARY KEY REFERENCES users(id),
bio TEXT,
avatar_url VARCHAR(500),
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- Phase 2: Daten migrieren (in Batches!)
INSERT INTO user_profiles (user_id, bio, avatar_url)
SELECT id, bio, avatar_url FROM users
WHERE id BETWEEN :start AND :end;
-- Phase 3: Contract -- erst nach vollständigem Rollout
-- und nachdem Version N nicht mehr läuft
ALTER TABLE users DROP COLUMN bio;
ALTER TABLE users DROP COLUMN avatar_url; Zwischen Phase 2 und Phase 3 liegen typischerweise Tage, nicht Minuten. Die alte Spalte bleibt bestehen, bis jede App-Instanz auf die neue Version aktualisiert ist und ein Monitoring-Zeitraum ohne Fehler vergangen ist.
Spalten sicher umbenennen
Eine Spalte umzubenennen klingt trivial, ist aber eine der gefaehrlichsten Operationen bei Rolling Deployments. Ein ALTER TABLE ... RENAME COLUMN bricht sofort jeden Code, der den alten Namen verwendet.
Die Lösung ist das Dual-Write Pattern:
-- Schritt 1: Neue Spalte hinzufuegen
ALTER TABLE orders ADD COLUMN total_amount DECIMAL(10,2);
-- Schritt 2: Bestehende Daten kopieren
UPDATE orders SET total_amount = price WHERE total_amount IS NULL;
-- Schritt 3: Trigger für Sync (oder im App-Code dual-write)
CREATE OR REPLACE FUNCTION sync_order_amount()
RETURNS TRIGGER AS $$
BEGIN
IF TG_OP = 'INSERT' OR NEW.price IS DISTINCT FROM OLD.price THEN
NEW.total_amount := NEW.price;
END IF;
IF TG_OP = 'INSERT' OR NEW.total_amount IS DISTINCT FROM OLD.total_amount THEN
NEW.price := NEW.total_amount;
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trg_sync_order_amount
BEFORE INSERT OR UPDATE ON orders
FOR EACH ROW EXECUTE FUNCTION sync_order_amount();
-- Schritt 4: App umstellen auf total_amount (neues Deployment)
-- Schritt 5: Trigger und alte Spalte entfernen (nach Monitoring-Phase)
DROP TRIGGER trg_sync_order_amount ON orders;
ALTER TABLE orders DROP COLUMN price; Der Aufwand ist real. Fuenf Schritte statt einem RENAME COLUMN. Aber jeder einzelne Schritt ist rueckwaertskompatibel und kann im Fehlerfall isoliert zurückgerollt werden.
NOT NULL Spalten zu bestehenden Tabellen hinzufuegen
ALTER TABLE ... ADD COLUMN ... NOT NULL ohne Default-Wert scheitert, wenn die Tabelle bereits Daten enthaelt. Mit Default-Wert kann es je nach Datenbank-Version einen Full Table Rewrite auslösen.
Der sichere Weg:
-- Schritt 1: Spalte als nullable hinzufuegen (schnell, kein Rewrite)
ALTER TABLE users ADD COLUMN status VARCHAR(20);
-- Schritt 2: Default für neue Zeilen setzen
ALTER TABLE users ALTER COLUMN status SET DEFAULT 'active';
-- Schritt 3: Bestehende Daten in Batches fuellen
UPDATE users SET status = 'active'
WHERE id BETWEEN 1 AND 100000 AND status IS NULL;
UPDATE users SET status = 'active'
WHERE id BETWEEN 100001 AND 200000 AND status IS NULL;
-- ... weitere Batches
-- Schritt 4: NOT NULL Constraint hinzufuegen
-- In PostgreSQL 12+ ist das eine reine Metadaten-Operation,
-- WENN ein CHECK-Constraint existiert
ALTER TABLE users ADD CONSTRAINT users_status_not_null
CHECK (status IS NOT NULL) NOT VALID;
ALTER TABLE users VALIDATE CONSTRAINT users_status_not_null;
-- Danach kann der echte NOT NULL gesetzt werden (optional)
ALTER TABLE users ALTER COLUMN status SET NOT NULL;
ALTER TABLE users DROP CONSTRAINT users_status_not_null; Grosse Tabellen migrieren: pt-online-schema-change und gh-ost
Fuer MySQL-Datenbanken mit großen Tabellen gibt es zwei etablierte Tools, die Schema-Änderungen ohne Locks durchfuehren: Perconas pt-online-schema-change und GitHubs gh-ost.
Beide folgen dem gleichen Grundprinzip: Eine Schattenkopie der Tabelle erstellen, die gewuenschte Änderung auf der Kopie ausführen, Daten kopieren, und am Ende die Tabellen atomar tauschen.
pt-online-schema-change nutzt Trigger, um Änderungen während der Migration zu synchronisieren. Das ist erprobt, kann aber bei hoher Write-Last Probleme verursachen.
gh-ost verfolgt stattdessen den Binlog-Stream und ist damit weniger invasiv. Es setzt keine Trigger auf die Originaltabelle, was die Last besser vorhersagbar macht.
gh-ost \
--host=replica.db.internal \
--database=production \
--table=events \
--alter="ADD COLUMN processed_at TIMESTAMP NULL" \
--allow-on-master \
--chunk-size=1000 \
--max-load="Threads_running=25" \
--critical-load="Threads_running=50" \
--initially-drop-ghost-table \
--initially-drop-old-table \
--execute Der Parameter --max-load ist entscheidend: gh-ost pausiert automatisch, wenn die Datenbank-Last einen Schwellwert ueberschreitet. Das verhindert, dass die Migration den Produktionsbetrieb beeintraechtigt.
Fuer PostgreSQL existiert kein direktes Äquivalent, weil PG viele DDL-Operationen ohnehin als Metadaten-Änderungen ausführen kann. Fuer die Fälle, wo das nicht reicht, ist pg_repack das Mittel der Wahl.
Das Shadow-Table Pattern für komplexe Umbauten
Wenn die Änderung zu komplex für ein einzelnes ALTER TABLE ist — etwa eine Tabelle in mehrere aufteilen, Datentypen ändern, oder eine komplett neue Struktur einfuehren — nutzt man Shadow Tables.
Das Prinzip: Die neue Tabellenstruktur existiert parallel zur alten. Ein Hintergrundprozess migriert historische Daten, während der App-Code per Feature-Flag gesteuert zwischen alter und neuer Struktur umschaltet.
-- Alte Struktur: Tags als JSON-Array in der Tabelle
-- articles.tags = '["python", "database", "performance"]'
-- Schritt 1: Neue normalisierte Struktur erstellen
CREATE TABLE tags (
id SERIAL PRIMARY KEY,
name VARCHAR(100) UNIQUE NOT NULL
);
CREATE TABLE article_tags (
article_id BIGINT REFERENCES articles(id),
tag_id INTEGER REFERENCES tags(id),
PRIMARY KEY (article_id, tag_id)
);
-- Schritt 2: Hintergrund-Migration in Batches
INSERT INTO tags (name)
SELECT DISTINCT jsonb_array_elements_text(tags)
FROM articles
WHERE id BETWEEN :start AND :end
ON CONFLICT (name) DO NOTHING;
INSERT INTO article_tags (article_id, tag_id)
SELECT a.id, t.id
FROM articles a,
jsonb_array_elements_text(a.tags) AS tag_name
JOIN tags t ON t.name = tag_name
WHERE a.id BETWEEN :start AND :end
ON CONFLICT DO NOTHING;
-- Schritt 3: App per Feature-Flag auf neue Tabellen umstellen
-- Schritt 4: JSON-Spalte entfernen nach Monitoring-Phase Migration-Reihenfolge bei Rolling Deployments
Die goldene Regel: Migrationen müssen vorwaertskompatibel sein. Waehrend eines Rolling Deployments laufen App-Version N und N+1 gleichzeitig. Beide müssen mit dem aktuellen Schema arbeiten können.
Das bedeutet in der Praxis:
- Spalte hinzufuegen: Kein Problem. Version N ignoriert die neue Spalte, Version N+1 nutzt sie.
- Spalte entfernen: Version N darf die Spalte nicht mehr lesen. Also: Erst App-Code anpassen (Release N), dann Spalte entfernen (Release N+1).
- Spalte umbenennen: Wie oben beschrieben — Dual-Write über mehrere Releases.
- Constraint hinzufuegen: Neuer Code muss den Constraint bereits erfuellen, bevor er in der Datenbank erzwungen wird.
Daraus ergibt sich eine einfache Regel: Destruktive Schema-Änderungen gehören immer in ein separates Release nach der Code-Änderung. Nie im selben Deployment.
Migrationen testen
Eine Migration, die in der Entwicklungsumgebung funktioniert, kann in Production scheitern — andere Datenmengen, andere Constraints, andere Laufzeiten. Deshalb braucht es eine Teststrategie.
Shadow Databases: Eine Kopie der Production-Datenbank (anonymisiert), auf der Migrationen vor dem Rollout getestet werden. Das deckt Laufzeitprobleme auf, die mit leeren Tabellen nicht sichtbar sind.
Dry Runs: Viele Migrations-Frameworks unterstuetzen --dry-run oder generieren SQL ohne Ausfuehrung. Bei komplexen Migrationen sollte man den generierten SQL-Code manuell pruefen.
# Django: SQL anzeigen ohne ausführen
python manage.py sqlmigrate myapp 0042
# Rails: Migration im Dry-Run-Modus
RAILS_ENV=staging rails db:migrate:status
# PostgreSQL: Groesse und geschaetzte Laufzeit pruefen
psql -c "
SELECT
pg_size_pretty(pg_total_relation_size('orders')) as table_size,
reltuples::bigint as estimated_rows
FROM pg_class
WHERE relname = 'orders';
" Ein weiterer Ansatz: Die Migration auf einem Read-Replica ausführen, bevor sie auf den Primary geht. Das zeigt die tatsaechliche Laufzeit unter realistischen Bedingungen, ohne den Produktionsbetrieb zu gefährden.
Rollback-Strategien, die tatsaechlich funktionieren
“Wir rollen einfach zurück” ist in der Theorie beruhigend und in der Praxis oft unmoeglich. Ein ALTER TABLE ... ADD COLUMN laesst sich zwar mit DROP COLUMN rueckgaengig machen, aber wenn die neue Spalte bereits Daten enthaelt, gehen diese verloren.
Funktionierende Rollback-Strategien:
Vorwaerts statt rueckwaerts: Statt eine gescheiterte Migration zurückzurollen, eine korrigierende Migration nachschieben. Das ist sicherer, weil keine Daten verloren gehen.
Feature Flags statt Schema-Rollback: Wenn der neue Code ein Problem hat, den alten Code per Feature-Flag reaktivieren. Das Schema bleibt im neuen Zustand, der Code nutzt die alten Strukturen weiter. Erst wenn das Problem behoben ist, wird der neue Code wieder aktiviert.
Point-in-Time Recovery als letzter Ausweg: Fuer katastrophale Fälle. Funktioniert nur, wenn das Recovery-Fenster die Migration einschliesst und der Datenverlust akzeptabel ist. Das ist selten der Fall.
Die beste Rollback-Strategie ist, keine zu brauchen. Kleine, inkrementelle Migrationen nach dem Expand-Contract Pattern sind jeweils einzeln harmlos. Wenn eine fehlschlägt, ist der Blast-Radius begrenzt.
Fazit
Zero-Downtime-Migrationen sind kein Hexenwerk, aber sie erfordern Disziplin. Das Expand-Contract Pattern als Grundlage, die Zwei-Release-Regel für destruktive Änderungen, und automatisierte Tools wie gh-ost für große Tabellen — damit kommt man weit. Der groesste Fehler ist, Schema-Migrationen als triviale Aufgabe zu behandeln. Sie sind es nicht. Sie verdienen die gleiche Sorgfalt wie jede andere Änderung an einem produktiven System.