sichere Schema-Änderungen ohne Downtime
Ein blind ausgeführtes Migrations-Diff kann Datenverlust, Tabellensperren oder Downtime verursachen. Mit dem Expand-Contract-Pattern, additive Migrations und konsequenter CI-Prüfung werden Doctrine Migrations planbar, symmetrisch und produktionssicher.
Inhaltsverzeichnis
- 1. Warum Migrations-Diffs nicht blind vertrauen
- 2. Sichere Migrationen: up und down symmetrisch schreiben
- 3. Zero-Downtime mit dem Expand-Contract-Pattern
- 4. Indizes auf großen Tabellen ohne Tabellensperre
- 5. Daten-Migrationen von Schema-Änderungen trennen
- 6. Versionierung und Organisation der Migrationsdateien
- 7. CI-Integration und automatisierte Prüfungen
- 8. Rollback-Strategien richtig einsetzen
- 9. Migrations-Patterns im direkten Vergleich
- 10. Zusammenfassung
- 11. FAQ
1. Warum Migrations-Diffs nicht blind vertrauen
Der Befehl doctrine:migrations:diff generiert automatisch eine Migration aus dem Unterschied zwischen aktuellem Entity-Mapping und tatsächlichem Datenbankschema. Diese Automatisierung ist der Grund, warum Doctrine Migrations so beliebt sind, birgt aber ein systematisches Risiko: Der Diff-Generator kennt keine fachliche Absicht, nur strukturelle Unterschiede. Eine umbenannte Spalte wird vom Generator fast immer als DROP COLUMN gefolgt von ADD COLUMN interpretiert, nicht als RENAME COLUMN. Das Ergebnis: sämtliche Daten in dieser Spalte gehen verloren, obwohl die Absicht lediglich eine Umbenennung war.
Der erste und wichtigste Schritt bei jeder generierten Migration ist deshalb, sie mit --dry-run auszuführen und den generierten SQL-Code Zeile für Zeile zu lesen, bevor sie auf eine echte Datenbank angewendet wird. Diese Disziplin ist bei Doctrine Migrations nicht optional, sie ist die einzige verlässliche Absicherung gegen automatisch generierte, aber fachlich falsche Schema-Änderungen. Wer Migrations blind aus dem Diff übernimmt und direkt in Produktion ausführt, riskiert genau die Art von Datenverlust, die Migrations eigentlich verhindern sollen.
Ein zweiter Aspekt betrifft implizite Verhaltensänderungen: Das Hinzufügen einer NOT NULL-Spalte ohne Default-Wert auf eine bereits befüllte Tabelle schlägt in MySQL mit strict mode sofort fehl, in älteren Konfigurationen füllt es die Spalte stillschweigend mit einem impliziten Default. Beide Verhaltensweisen sind bei Doctrine Migrations zu prüfen, bevor die Migration in einer Umgebung mit echten Produktionsdaten läuft, nicht erst danach.
2. Sichere Migrationen: up und down symmetrisch schreiben
Jede Migration in Doctrine Migrations besteht aus einer up()- und einer down()-Methode. Die down()-Methode soll den exakten Zustand vor der Migration wiederherstellen, aber in der Praxis wird sie oft vernachlässigt oder mit einem leeren Kommentar versehen. Das rächt sich, sobald eine Migration in Produktion fehlerhaft ist und zurückgerollt werden muss, während parallel bereits Anwendungscode auf das neue Schema deployt wurde.
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260730120000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add nullable "sku" column to product table';
}
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE product ADD sku VARCHAR(64) DEFAULT NULL');
$this->addSql('CREATE INDEX idx_product_sku ON product (sku)');
}
public function down(Schema $schema): void
{
$this->addSql('DROP INDEX idx_product_sku ON product');
$this->addSql('ALTER TABLE product DROP sku');
}
}
Eine wichtige Regel bei Doctrine Migrations: Jede Migration sollte in genau einer Transaktion laufen können, sofern das Datenbanksystem transaktionale DDL unterstützt. PostgreSQL erlaubt das vollständig, MySQL nur eingeschränkt, da bestimmte DDL-Befehle implizite Commits auslösen. Für MySQL bedeutet das, größere Migrationen in mehrere kleinere, unabhängig anwendbare Schritte aufzuteilen, statt auf eine Transaktion zu vertrauen, die es in dieser Form gar nicht gibt.
3. Zero-Downtime mit dem Expand-Contract-Pattern
Das Expand-Contract-Pattern ist die zentrale Technik, um Doctrine Migrations ohne Downtime auf Anwendungen mit mehreren gleichzeitig laufenden Deployments anzuwenden. Statt eine Spalte in einem einzigen Schritt umzubenennen oder ihren Typ zu ändern, wird die Änderung in drei unabhängige Deployments aufgeteilt: Expand fügt die neue Struktur additiv hinzu, ohne die alte zu entfernen. Migrate schreibt Anwendungscode, der beide Strukturen parallel befüllt oder liest. Contract entfernt die alte Struktur erst, nachdem sämtlicher alter Code entfernt wurde.
Diese Aufteilung ist notwendig, weil bei rollierenden Deployments für kurze Zeit alte und neue Anwendungsversionen parallel auf dieselbe Datenbank zugreifen. Eine Doctrine Migration, die eine Spalte in einem Schritt umbenennt, bricht sofort den alten Code, der die ursprüngliche Spalte noch referenziert. Mit Expand-Contract bleibt das Schema während der gesamten Rollout-Phase kompatibel mit beiden Codeversionen.
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
// EXPAND migration: add new column alongside the old one, additive only
final class Version20260730130000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Expand: add new "price_cents" column, keep legacy "price" column';
}
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE product ADD price_cents INT DEFAULT NULL');
// Backfill in small batches, not in a single UPDATE — see section 5
$this->addSql(
'UPDATE product SET price_cents = ROUND(price * 100) WHERE price_cents IS NULL LIMIT 5000'
);
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE product DROP price_cents');
}
}
Erst in einer späteren, separaten Migration, nachdem der gesamte Code auf price_cents umgestellt und deployt wurde, folgt der Contract-Schritt mit DROP COLUMN price. Diese zeitliche Trennung ist der Kern jeder Zero-Downtime-Strategie mit Doctrine Migrations.
4. Indizes auf großen Tabellen ohne Tabellensperre
Auf Tabellen mit mehreren Millionen Zeilen kann eine naive CREATE INDEX-Anweisung die Tabelle für die Dauer der Index-Erstellung sperren, was in Produktion zu Timeouts und Request-Fehlern führt. MySQL mit InnoDB unterstützt seit Version 5.6 Online-DDL für viele Index-Operationen, gesteuert über ALGORITHM=INPLACE und LOCK=NONE. Eine Doctrine Migration, die diese Hinweise explizit setzt, vermeidet die längere exklusive Sperre der klassischen Tabellenkopie.
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260730140000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add index on large orders table without locking it';
}
public function up(Schema $schema): void
{
// ALGORITHM=INPLACE avoids a full table copy, LOCK=NONE keeps writes flowing
$this->addSql(
'ALTER TABLE orders ADD INDEX idx_orders_customer_id (customer_id), ALGORITHM=INPLACE, LOCK=NONE'
);
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE orders DROP INDEX idx_orders_customer_id, ALGORITHM=INPLACE, LOCK=NONE');
}
public function isTransactional(): bool
{
// Online DDL statements are not compatible with an implicit transaction wrapper
return false;
}
}
Wichtig für Doctrine Migrations auf MySQL: Die Methode isTransactional() muss false zurückgeben, wenn die Migration Online-DDL-Anweisungen enthält, da diese sonst mit dem impliziten Transaktions-Wrapper des Migrations-Bundles kollidieren können. PostgreSQL bietet mit CREATE INDEX CONCURRENTLY ein vergleichbares Konzept, das ebenfalls außerhalb einer Transaktion laufen muss.
5. Daten-Migrationen von Schema-Änderungen trennen
Eine große Datenmigration, die Millionen Zeilen in einem einzigen UPDATE-Statement verändert, blockiert Schreibzugriffe auf die betroffene Tabelle für die gesamte Laufzeit der Anweisung und kann den Replikations-Lag auf Read-Replicas dramatisch erhöhen. Doctrine Migrations sollten Datenänderungen deshalb in kleinen, wiederholbaren Batches ausführen, mit LIMIT-Klauseln und einer Schleife, die den Fortschritt zwischen den Batches kurz pausiert, um Replikations-Lag abzubauen.
Für sehr große Datenmengen, bei denen selbst eine gebatchte Migration zu lange für ein einzelnes Deployment-Fenster dauert, ist die richtige Antwort, die Datenmigration komplett aus der Schema-Migration herauszulösen und als separaten, asynchronen Prozess über die Messenger-Komponente zu implementieren. Die Doctrine Migration selbst beschränkt sich dann auf die additive Schema-Änderung, während ein eigenständiges Kommando oder ein Messenger-Handler die eigentliche Datenmigration im Hintergrund abarbeitet, mit Fortschrittsanzeige und Fehlerbehandlung.
6. Versionierung und Organisation der Migrationsdateien
Mit wachsendem Projekt sammeln sich schnell hunderte Migrationsdateien an, was doctrine:migrations:migrate spürbar verlangsamt, weil jede einzelne Migration beim Start geprüft wird. Doctrine Migrations unterstützt das sogenannte Squashing: Alle bereits in Produktion angewendeten Migrationen werden zu einer einzigen konsolidierten Schema-Definition zusammengefasst, während die Versions-Tabelle in der Datenbank unverändert bleibt. Neue Migrationen bauen danach auf dieser konsolidierten Basis auf.
Für Teams mit mehreren parallelen Feature-Branches empfiehlt sich zusätzlich ein Namespace pro Bounded Context, etwa DoctrineMigrations\Billing und DoctrineMigrations\Catalog, konfiguriert über mehrere migrations_paths-Einträge in der Bundle-Konfiguration. Das reduziert Merge-Konflikte zwischen Teams erheblich, weil unterschiedliche Teams in unterschiedlichen Verzeichnissen arbeiten, auch wenn beide gleichzeitig neue Doctrine Migrations erstellen.
7. CI-Integration und automatisierte Prüfungen
Der Befehl doctrine:migrations:status zeigt an, ob alle Migrationen angewendet wurden und ob das aktuelle Schema mit den vorhandenen Mapping-Definitionen übereinstimmt. In einer CI-Pipeline lohnt es sich, diesen Befehl nach jedem Migrations-Lauf zu prüfen und den Build fehlschlagen zu lassen, wenn eine Diskrepanz gefunden wird. Ergänzend prüft doctrine:schema:validate, ob das Entity-Mapping und das tatsächliche Datenbankschema nach Anwendung aller Doctrine Migrations konsistent sind, was vergessene oder fehlerhafte Migrationsdateien zuverlässig aufdeckt.
# .gitlab-ci.yml — verify migrations before deploy
migration-check:
stage: test
script:
- bin/console doctrine:database:create --if-not-exists --env=test
- bin/console doctrine:migrations:migrate --no-interaction --env=test
- bin/console doctrine:schema:validate --env=test
- bin/console doctrine:migrations:status --env=test | grep -q "New Migrations: *0" || exit 1
Diese Prüfungen laufen bei jedem Merge Request und stellen sicher, dass niemand versehentlich Entity-Mapping-Änderungen ohne zugehörige Doctrine Migration committet, ein Fehler, der sonst erst beim nächsten Deployment auf einer echten Umgebung sichtbar würde.
8. Rollback-Strategien richtig einsetzen
Ein Rollback über doctrine:migrations:migrate prev ist bei Doctrine Migrations nur dann sicher, wenn die Migration wirklich reversibel ist, ohne Datenverlust zu erzeugen. Eine Migration, die eine Spalte löscht, kann in down() die Spalte zwar wieder anlegen, aber die zuvor enthaltenen Daten sind unwiderruflich verloren. Für solche destruktiven Änderungen ist ein Rollback im klassischen Sinne eine Illusion, kein echter Sicherheitsnetz.
In der Praxis ist die verlässlichere Strategie bei Doctrine Migrations, destruktive Schema-Änderungen erst nach einer Wartephase von mehreren Tagen oder Wochen durchzuführen, nachdem verifiziert wurde, dass die alte Struktur wirklich nicht mehr benötigt wird, und in der Zwischenzeit ein aktuelles Datenbank-Backup als eigentliches Rollback-Mittel zu betrachten. Ein Rollback per down()-Methode eignet sich gut für additive, nicht destruktive Änderungen, aber nicht als universelle Absicherung gegen jede Art von Migrationsfehler.
9. Migrations-Patterns im direkten Vergleich
Die folgende Tabelle stellt unterschiedliche Ansätze für Schema-Änderungen mit Doctrine Migrations gegenüber und zeigt, welches Risiko jeweils besteht und welches Pattern es entschärft.
| Änderungstyp | Risikoreicher Ansatz | Empfohlenes Pattern | Vorteil |
|---|---|---|---|
| Spalte umbenennen | DROP + ADD (Diff-Default) | Expand-Contract mit paralleler Spalte | Kein Datenverlust, kein Breaking Change |
| Index auf großer Tabelle | CREATE INDEX (Default-Lock) | ALGORITHM=INPLACE, LOCK=NONE | Keine exklusive Tabellensperre |
| Millionen Zeilen aktualisieren | Ein einzelnes UPDATE-Statement | Gebatchte Updates oder Messenger-Job | Kein Replikations-Lag, unterbrechbar |
| NOT NULL ohne Default | Direkt auf befüllter Tabelle | Erst Default setzen, dann NOT NULL | Kein Fehlschlag bei strict mode |
| Migration validieren | Direkt in Produktion testen | --dry-run plus CI-Pipeline-Check | Fehler vor dem Deployment sichtbar |
Die Tabelle zeigt ein durchgängiges Muster: Fast jedes Risiko bei Doctrine Migrations entsteht aus einer einzigen, großen, synchronen Operation. Die Lösung ist fast immer dieselbe, die Änderung additiv, gebatcht und zeitlich entkoppelt vom Anwendungscode durchzuführen, statt sie in einem einzigen destruktiven Schritt zu erzwingen.
Mironsoft
Symfony-Deployment, Datenbank-Migrationen und Zero-Downtime-Architektur
Schema-Änderungen ohne Downtime deployen?
Wir richten Expand-Contract-Workflows, CI-Migrations-Checks und Batch-Prozesse für eure Datenmigrationen ein, damit Deployments planbar bleiben, auch bei Tabellen mit Millionen Zeilen.
Migrations-Audit
Bestehende Migrationen auf Risiken und fehlende down()-Methoden prüfen
Zero-Downtime-Setup
Expand-Contract-Workflow für rollierende Deployments etablieren
CI-Integration
Automatisierte Migrations-Checks in der Deployment-Pipeline
10. Zusammenfassung
Sichere Doctrine Migrations entstehen nicht durch blindes Vertrauen in den Diff-Generator, sondern durch systematische Disziplin: jede generierte Migration mit --dry-run prüfen, up() und down() symmetrisch halten, und destruktive Änderungen konsequent über das Expand-Contract-Pattern in mehrere, zeitlich entkoppelte Deployments aufteilen. Auf großen Tabellen verhindert ALGORITHM=INPLACE, LOCK=NONE unnötige Sperren, und Datenmigrationen gehören in gebatchte Schleifen oder asynchrone Messenger-Jobs, niemals in ein einzelnes großes UPDATE-Statement.
CI-Checks mit doctrine:migrations:status und doctrine:schema:validate fangen vergessene Migrationen ab, bevor sie in Produktion zum Problem werden. Rollback über down() ist ein nützliches Werkzeug für additive Änderungen, aber kein Ersatz für ein aktuelles Datenbank-Backup bei destruktiven Operationen. Wer diese Prinzipien konsequent auf jede Doctrine Migration anwendet, reduziert das Risiko von Downtime und Datenverlust erheblich.
Doctrine Migrations Best Practices — Das Wichtigste auf einen Blick
Diff pruefen
Jede generierte Migration mit --dry-run lesen, bevor sie angewendet wird. Umbenennungen werden sonst zu DROP plus ADD.
Expand-Contract
Destruktive Änderungen in Expand, Migrate und Contract aufteilen, für Kompatibilität während rollierender Deployments.
Große Tabellen
ALGORITHM=INPLACE, LOCK=NONE für Indizes, gebatchte UPDATE-Statements für Datenmigrationen.
CI-Absicherung
doctrine:migrations:status und doctrine:schema:validate in jeder Pipeline erzwingen.