Zero-Downtime-Datenbankmigrationen: Ein Praxis-Playbook
Jedes Team hat eine Kriegsgeschichte, die gleich beginnt: eine Schema-Migration, die unkompliziert schien, an einem Dienstagnachmittag deployt wurde und fünf Minuten lang 500er-Fehler produzierte, bevor jemand herausfand, wie man sie zurückrollt. Oder schlimmer – eine Migration, die unter Last stillschweigend eine kritische Tabelle sperrte und eine Kaskade von Timeouts auslöste, die eine Stunde dauerte und ein Postmortem erzeugte, das niemand schreiben wollte.
Datenbankmigrationen sind auf einzigartige Weise gefährlich, weil sie an der Schnittstelle zweier Systeme sitzen, die sich selten sauber gemeinsam ändern: Ihres Anwendungscodes und Ihrer persistenten Daten. Dieses Playbook behandelt die Muster, die diese Migrationen sicher und wiederholbar machen. Falsch deployt, kann eine Migration Produktionsausfälle, Datenverlust oder subtile Korruption verursachen, die erst Tage später auftaucht. Die gute Nachricht: Die Engineering-Community hat eine Reihe von Mustern entwickelt, die Zero-Downtime-Datenbankmigrationen zuverlässig und wiederholbar machen. Dieser Beitrag geht diese Muster in praktischer Form durch, mit Beispielen für PHP/Symfony-Anwendungen, die Doctrine Migrations nutzen.
Warum Datenbankmigrationen riskant sind
Das Kernproblem von Datenbank-Schema-Änderungen ist, dass sie aus Sicht einer laufenden Anwendung nicht atomar sind. Wenn Sie eine neue Version Ihrer Anwendung deployen und gleichzeitig eine Migration ausführen, gibt es ein Fenster – wie kurz auch immer –, in dem entweder der neue Code gegen das alte Schema läuft oder der alte Code gegen das neue Schema. Keine der beiden Kombinationen funktioniert garantiert korrekt.
Die drei gefährlichen Migrationsoperationen sind Spalten-Löschungen, Spalten-Umbenennungen und NOT-NULL-Constraints, die zu bestehenden Spalten hinzugefügt werden. Jede davon erzeugt eine harte Inkompatibilität zwischen einer Version der Anwendung und einem Zustand des Schemas.
Eine Spalten-Löschung ist offensichtlich: Wenn alter Code versucht, eine nicht mehr existierende Spalte zu lesen oder zu schreiben, wirft er einen Fehler. Weniger offensichtlich ist ein NOT-NULL-Constraint, das zu einer Spalte mit bestehenden Zeilen hinzugefügt wird. Selbst wenn Ihr neuer Code immer einen Wert für diese Spalte schreibt, erzeugt das Ausführen der Migration ein Fenster, in dem von altem Code – der während des Deployments noch läuft – eingefügte Zeilen am neuen Constraint scheitern. Unter Last, mit einem rollierenden Deployment, kann dieses Fenster Minuten dauern.
Tabellensperren sind ein eigenes Problem. Mehrere gängige Migrationsoperationen veranlassen die Datenbank, während der Migration eine exklusive Sperre auf die Tabelle zu erwerben. ALTER TABLE in MySQL mit MyISAM-Storage, oder in älterem InnoDB ohne Online-DDL-Unterstützung, sperrt die Tabelle für Lese- und Schreibvorgänge, bis die Operation abgeschlossen ist. Bei einer users-Tabelle mit fünf Millionen Zeilen kann das Hinzufügen eines Index mit voller Tabellensperre Minuten dauern. Jeder Request, der diese Tabelle in diesen Minuten berührt, wartet entweder oder scheitert.
Das Expand-Contract-Pattern
Das Expand-Contract-Pattern (manchmal Parallel Change genannt) ist die grundlegende Technik für Zero-Downtime-Schema-Migrationen. Die Kernidee ist, jede brechende Schema-Änderung in drei separate, sichere Phasen zu zerlegen.
Phase 1: Expand. Fügen Sie die neuen Schema-Elemente hinzu, ohne etwas zu entfernen. Wenn Sie eine Spalte umbenennen, fügen Sie die neue Spalte neben der alten hinzu. Wenn Sie einen Spaltentyp ändern, fügen Sie eine neue Spalte mit dem korrekten Typ hinzu. Wenn Sie ein NOT-NULL-Constraint hinzufügen, fügen Sie die Spalte zunächst als nullable hinzu. Das Schema unterstützt nun sowohl den alten als auch den neuen Anwendungscode.
Phase 2: Migrate. Deployen Sie den neuen Anwendungscode, der in beide Spalten schreibt (oder aus der neuen Spalte liest, mit einem Fallback auf die alte während des Übergangs). Füllen Sie bestehende Zeilen nach, indem Sie Daten von der alten in die neue Spalte kopieren. Sobald alle Zeilen nachgefüllt sind und alle neuen Schreibvorgänge in die neue Spalte gehen, trägt die alte Spalte nur noch historische Daten.
Phase 3: Contract. Deployen Sie eine finale Version des Codes, die die alte Spalte nicht mehr referenziert. Erst dann löschen Sie die alte Spalte aus dem Schema. Zu diesem Zeitpunkt liest oder schreibt kein laufender Code die alte Spalte, sodass die Löschung sicher ist.
Betrachten Sie als konkretes Beispiel die Umbenennung einer Spalte user_name in display_name in einer Symfony-Anwendung mit Doctrine.
// Migration: Phase 1 — Expand (neue Spalte hinzufügen)
public function up(Schema $schema): void
{
$this->addSql(
'ALTER TABLE users ADD COLUMN display_name VARCHAR(255) NULL'
);
}
Zu diesem Zeitpunkt existieren beide Spalten. Deployen Sie Anwendungscode, der in beide schreibt:
// Entity während der Übergangsphase
class User
{
#[Column(type: 'string', nullable: true)]
private ?string $userName = null; // alte Spalte — wird noch geschrieben
#[Column(type: 'string', nullable: true)]
private ?string $displayName = null; // neue Spalte — wird ebenfalls geschrieben
public function setDisplayName(string $name): void
{
$this->userName = $name; // alte Spalte synchron halten
$this->displayName = $name; // neue Spalte befüllen
}
public function getDisplayName(): string
{
// aus neuer Spalte lesen, auf alte zurückfallen, falls noch nicht befüllt
return $this->displayName ?? $this->userName ?? '';
}
}
Füllen Sie bestehende Zeilen in einer separaten Migration nach:
// Migration: Phase 2 — Backfill
public function up(Schema $schema): void
{
$this->addSql(
'UPDATE users SET display_name = user_name WHERE display_name IS NULL'
);
}
Führen Sie diesen Backfill bei Tabellen mit Millionen von Zeilen in Batches aus, um langlaufende Transaktionen zu vermeiden, die Sperren halten:
// Batch-Backfill — als Symfony-Command ausführen, nicht als Migration
public function execute(InputInterface $input, OutputInterface $output): int
{
$batchSize = 1000;
$lastId = 0;
do {
$count = $this->connection->executeStatement(
'UPDATE users SET display_name = user_name
WHERE display_name IS NULL AND id > :lastId
ORDER BY id LIMIT :batchSize',
['lastId' => $lastId, 'batchSize' => $batchSize]
);
$lastId += $batchSize;
sleep(0); // zwischen Batches anderen Queries den Vortritt lassen
} while ($count > 0);
return Command::SUCCESS;
}
Sobald alle Zeilen nachgefüllt sind und der Übergangscode einen Deployment-Zyklus lang in Produktion war, deployen Sie die finale Version, die die Referenz auf die alte Spalte entfernt, und führen dann die Contract-Migration aus:
// Migration: Phase 3 — Contract (alte Spalte entfernen)
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE users DROP COLUMN user_name');
}
Online-DDL und sperrenfreie Schema-Änderungen
Moderne Datenbankversionen haben sich erheblich darin verbessert, Schema-Änderungen ohne exklusive Sperren durchzuführen. Das Online-DDL von MySQL InnoDB, verfügbar seit MySQL 5.6, erlaubt vielen ALTER TABLE-Operationen, gleichzeitig mit Lese- und Schreibvorgängen zu laufen, indem ein internes Log die während der Operation vorgenommenen DML-Änderungen wiederholt. PostgreSQLs CREATE INDEX CONCURRENTLY baut Indizes, ohne eine Sperre auf die Tabelle zu halten.
Das Schlüsselprinzip: Wissen Sie, welche Operationen in Ihrer Datenbankversion eine volle Tabellensperre erfordern und welche Online-Ausführung unterstützen. In MySQL 8 mit InnoDB sind das Hinzufügen einer Spalte und das Hinzufügen eines nicht-eindeutigen Index beides Online-Operationen. Das Ändern eines Spaltentyps, der eine Änderung des Zeilenformats erfordert, ist es nicht. In PostgreSQL ist das Hinzufügen einer nullable-Spalte sofort wirksam (es erfordert nur ein Katalog-Update). Das Hinzufügen eines NOT-NULL-Constraints zu einer bestehenden Spalte erfordert einen vollständigen Tabellen-Scan zur Verifizierung.
Für Symfony-Projekte behandelt das Doctrine-DBAL-Typsystem viele dieser Unterschiede, trifft aber keine plattformspezifischen DDL-Entscheidungen für Sie. Wenn Sie Migrationen für stark frequentierte Tabellen schreiben, verifizieren Sie den DDL-Plan direkt:
-- MySQL: prüfen, ob eine Migration Online-DDL verwendet
EXPLAIN ALTER TABLE orders ADD COLUMN processed_at DATETIME NULL;
-- Achten Sie auf "ALGORITHM=INPLACE, LOCK=NONE" in der Ausgabe
-- PostgreSQL: CONCURRENTLY für die Index-Erstellung verwenden
CREATE INDEX CONCURRENTLY idx_orders_processed_at
ON orders (processed_at);
Für Operationen, die nicht online laufen können, ziehen Sie pt-online-schema-change (MySQL) oder pg_repack (PostgreSQL) in Betracht. Diese Werkzeuge implementieren Online-Schema-Änderungen, indem sie eine neue Tabelle erstellen, Zeilen in Batches kopieren und die alte und neue Tabelle atomar tauschen – wodurch das Sperrfenster eliminiert wird, das ein direktes ALTER TABLE erzeugen würde.
CI/CD-Pipeline-Integration
Das Ausführen von Migrationen in einer CI/CD-Pipeline bringt ein Synchronisationsproblem mit sich: Die Migration und das Code-Deployment müssen in der richtigen Reihenfolge ausgeführt werden, und die Abfolge hängt davon ab, in welcher Phase des Expand-Contract-Patterns Sie sich befinden.
Die allgemeine Regel lautet:
- Expand-Migrationen laufen, bevor der neue Anwendungscode deployt wird. Der alte Code ignoriert neue Spalten; das neue Schema kann sicher vor dem Code-Wechsel hinzugefügt werden.
- Contract-Migrationen laufen, nachdem der neue Anwendungscode deployt wurde. Führen Sie eine Löschung oder ein NOT-NULL-Hinzufügen erst aus, nachdem Sie bestätigt haben, dass der neue Code – der die alte Spalte nicht mehr referenziert – live und stabil ist.
In einer typischen GitHub-Actions-Pipeline für ein Symfony-Deployment:
jobs:
deploy:
steps:
- name: Run expand migrations
run: php bin/console doctrine:migrations:execute --up \
'App\Migrations\Version20260411_ExpandPhase'
- name: Deploy application
run: |
# Rollierendes Deployment: neue Container ersetzen alte
kubectl set image deployment/app app=$NEW_IMAGE
kubectl rollout status deployment/app
# Contract-Migrationen laufen in einem separaten Job,
# ausgelöst, nachdem das Expand-Deployment als stabil bestätigt wurde
cleanup:
needs: [deploy]
if: ${{ inputs.run_contract_migration }}
steps:
- name: Run contract migrations
run: php bin/console doctrine:migrations:execute --up \
'App\Migrations\Version20260411_ContractPhase'
Die Expand- und Contract-Phasen in getrennte Pipeline-Jobs aufzuteilen – statt alle Migrationen in einem einzigen Pre-Deployment-Schritt auszuführen – erzwingt die Disziplin, dass Contract-Migrationen nicht laufen können, bevor das Expand-Deployment stabil war.
Sichere Muster für gängige Migrationstypen
Über Spalten-Umbenennungen hinaus verursachen einige Migrationstypen in Produktionssystemen hartnäckigen Ärger.
Eine NOT-NULL-Spalte zu einer bestehenden Tabelle hinzufügen. Fügen Sie niemals ein NOT-NULL-Constraint direkt zu einer Spalte auf einer Tabelle mit bestehenden Zeilen hinzu. Stattdessen: Fügen Sie die Spalte als nullable hinzu, deployen Sie Code, der den neuen Wert schreibt, füllen Sie bestehende Zeilen nach und fügen Sie dann das NOT-NULL-Constraint in einer separaten Migration hinzu, nachdem der Backfill abgeschlossen ist.
Einen Spaltentyp ändern. Den Typ zu erweitern (VARCHAR(50) → VARCHAR(200)) ist in modernen Datenbanken typischerweise sicher und online. Die Datenrepräsentation zu ändern (VARCHAR zu JSON, INT zu BIGINT) erfordert einen Expand-Contract-Ansatz mit einer Übergangsspalte.
Ein Unique-Constraint hinzufügen. Das Hinzufügen eines Unique-Constraints kann scheitern, wenn doppelte Werte in der Spalte existieren. Prüfen Sie immer auf Duplikate und beheben Sie sie, bevor Sie das Constraint hinzufügen – verlassen Sie sich nicht darauf, dass die Migration sie abfängt, denn ein Migrationsfehler mitten im Deployment ist schwerer wiederherzustellen als eine Vorab-Prüfung.
Foreign-Key-Hinzufügungen. Das Hinzufügen eines Foreign-Key-Constraints zu einer bestehenden Spalte erfordert, dass alle bestehenden Werte in der Spalte das Constraint bereits erfüllen. Validieren Sie die referentielle Integrität vor der Migration, nicht währenddessen.
Monitoring und Rollback
Jedes Migrations-Deployment sollte einen definierten Rollback-Pfad haben, und dieser Pfad sollte vor dem Deployment validiert werden, nicht während eines Incidents.
Bei Doctrine Migrations definiert die Methode down() das Rollback. In der Expand-Phase löscht das Rollback einfach die neue Spalte – unkompliziert und sicher. In der Contract-Phase ist das Rollback komplexer und möglicherweise nicht vollständig reversibel, wenn die gelöschte Spalte Daten enthielt, die nirgendwo sonst erfasst sind. Diese Asymmetrie ist ein weiterer Grund, die Contract-Phase separat zu halten und sie erst nach längerer Stabilität in Produktion anzuwenden.
Überwachen Sie während der Migrationsausführung diese Signale:
- Wartezeiten bei Datenbanksperren — eine Spitze bei
innodb_row_lock_waits(MySQL) oderlock_awaited_queries(PostgreSQL) deutet auf Contention durch eine länger als erwartet laufende Migration hin. - Anwendungs-Fehlerraten — eine 5xx-Spitze innerhalb von 60 Sekunden nach einer Migration signalisiert ein Kompatibilitätsproblem zwischen der Schema-Änderung und dem laufenden Code.
- Slow-Query-Log — Migrationen, die als lange Transaktionen laufen, erscheinen hier und können bei Bedarf abgebrochen werden.
Ein Code-Qualitäts-Audit der Migrationshistorie einer Symfony-Anwendung fördert häufig Migrationen zutage, die unter Last Probleme verursachen würden – NOT-NULL-Hinzufügungen ohne Backfills, Index-Hinzufügungen ohne ALGORITHM=INPLACE-Prüfungen und Batch-Updates, die als einzelne Transaktionen geschrieben sind und ganze Tabellen sperren. Diese abzufangen, bevor sie in Produktion laufen, ist um Größenordnungen günstiger, als sich nachträglich von ihnen zu erholen.
Alles zusammenführen
Die hier beschriebenen Muster sind nicht neu – es sind gut etablierte Techniken, die Teams im großen Maßstab seit Jahren nutzen. Was sie in der Praxis schwierig macht, ist Disziplin: Die Expand-Contract-Sequenz erfordert das Ausführen dreier Deployments, wo eines ausreichend erscheint, und die Versuchung, sie unter Zeitdruck in eine einzige Migration zusammenzufassen, ist real.
Die Teams, die dies konsistent tun, teilen zwei Praktiken. Erstens kodifizieren sie das Muster in ihrem Migrations-Review-Prozess – jeder Migrations-PR wird nicht nur auf Korrektheit geprüft, sondern auch auf seine Position in der Expand-Contract-Sequenz und sein erwartetes DDL-Verhalten unter Last. Zweitens betreiben sie Staging-Umgebungen unter realistischen Datenvolumen, sodass die Migrationsperformance vor der Produktion bekannt ist, nicht während eines Incidents entdeckt wird.
Wenn Ihr Team auf Zero-Downtime-Deployments hinarbeitet und auf Reibung bei Datenbankmigrationen stößt, ist dies ein lösbares Problem mit etablierten Werkzeugen und Methodik. Wolf-Tech hat Engineering-Teams in Deutschland und in der gesamten EU geholfen, sichere Deployment-Pipelines zu implementieren, die Datenbankmigrationen als erstklassiges Anliegen einbeziehen. Erreichen Sie uns unter hello@wolf-tech.io oder besuchen Sie wolf-tech.io für eine kostenlose Beratung zu Ihrer Deployment-Architektur.

