Eine Schema-Änderung, die auf Staging 30 Sekunden dauert, kann eine Produktionstabelle mit 50 Millionen Zeilen 20 Minuten lang sperren. Ich habe bei genau diesem Incident mehr als einmal im War Room gesessen. Die Entwicklerin, die die Migration geschrieben hat, hat nach jeder üblichen Definition nichts falsch gemacht. Sie hat eine Spalte hinzugefügt, Doctrine Migrations ausgeführt, getestet, ausgeliefert. Die Staging-Datenbank hatte 40.000 Zeilen. Die Produktionsdatenbank hatte 50 Millionen. Der Unterschied zwischen diesen beiden Zahlen, nicht irgendein Unterschied im Code, hat den Ausfall verursacht.
Zero-Downtime-Schema-Migration ist kein Framework-Feature. Es ist eine Design-Disziplin. Sie verlangt, dass Sie aufhören, Schema-Änderungen als atomare Events zu betrachten, und stattdessen als mehrstufige Choreografien, die sich über mehrere Deployments erstrecken, wobei das System nach jedem Schritt auslieferbar bleibt.
Dieser Essay ist die Gewohnheitsliste, mit der ich nach genügend Durchläufen gelandet bin, um sie im Muskelgedächtnis zu haben. Er setzt PostgreSQL voraus, weil die meisten meiner Kunden darauf laufen. Die Prinzipien übertragen sich aber sauber auf MySQL, mit den Lock-Besonderheiten, die ich an den entsprechenden Stellen erwähne.
Warum die naive Migration scheitert
Die naive Migration ist die, die Doctrine standardmäßig für Sie generiert: ALTER TABLE post ADD COLUMN tenant_id UUID NOT NULL DEFAULT gen_random_uuid();. Sie besteht das Review. Sie besteht Staging. Dann trifft sie die Produktion und nimmt den Lock.
In PostgreSQL tut dieses Statement zwei Dinge. Es holt sich einen ACCESS EXCLUSIVE-Lock auf die Tabelle, der jede andere Query blockiert (auch lesende). Danach schreibt es jede Zeile neu, um die neue Spalte mit ihrem Default-Wert einzufügen. Auf einer Tabelle mit 50 Millionen Zeilen dauert dieses Neuschreiben Minuten bis zehner Minuten. Währenddessen blockiert jeder Request, der die Tabelle berührt, läuft schließlich in ein Timeout und schlägt fehl. Ihre Nutzer sehen 500er-Fehler. Ihr Pager geht los. Sie rollen zurück.
Das Statement ist valide. Es ist nur nicht valide in Ihrer Größenordnung während der Servicezeit. Die Lösung ist nicht, besser in Einzelstatement-Migrations zu werden. Die Lösung ist, auf großen Tabellen keine Einzelstatement-Migrations mehr auszuführen. Zerlegen Sie die Änderung in Schritte, die jeweils Locks im Millisekundenbereich nehmen, nicht im Minutenbereich.
Das Expand-Contract-Muster
Das Muster, das hier funktioniert, heißt Expand-Contract (auch “Parallel Change”). Wenn Sie aus diesem Essay eine Sache mitnehmen, dann diese.
Jede Schema-Änderung, egal wie klein sie aussieht, wird als Abfolge von vier Phasen modelliert. Jede Phase ist ein separates Deployment. Das System ist nach jedem Schritt voll funktionsfähig.
- Expand. Das neue Schema (Spalte, Tabelle, Index) wird in einer Form hinzugefügt, die online sicher ist. Das alte Schema existiert weiter und bleibt maßgeblich.
- Writes migrieren. Der Anwendungscode wird so angepasst, dass jeder Schreibvorgang sowohl das alte als auch das neue Schema aktualisiert. Gelesen wird weiter aus dem alten Schema.
- Backfill. Historische Daten im neuen Schema werden aus dem alten befüllt, in Batches, im Hintergrund, gegebenenfalls über Stunden. Lesen ist nun von beiden Seiten möglich.
- Contract. Lesevorgänge werden auf das neue Schema umgestellt. Nach einer Cooldown-Phase, in der Sie verifizieren, dass kein Aufrufer mehr das alte Schema liest, wird es entfernt.
Jede dieser Phasen wird eigenständig ausgeliefert. Jede Phase lässt die Anwendung voll funktionsfähig zurück. Wenn in einer Phase ein Problem auftritt, stoppen Sie dort und rollen entweder vorwärts oder auf die vorige Phase zurück. Sie liefern niemals eine “Big Bang”-Migration aus, die entweder komplett durchläuft oder gar nicht.
Das bedeutet mehr Deployments, mehr Code und mehr verstrichene Zeit als der naive Ansatz. Das ist der Preis. Der Gewinn ist, dass kein einzelnes Deployment die Datenbank stillegen kann.
Beispiel: eine Spalte auf einer großen Tabelle umbenennen
Ein konkreter Fall. Sie haben eine users-Tabelle mit einer username-Spalte. Das Produktteam hat das Konzept in “Handle” umbenannt. Engineering will, dass die Spalte passt. Die Tabelle hat 80 Millionen Zeilen über mehrere Tenants.
Die naive Migration:
ALTER TABLE users RENAME COLUMN username TO handle;
In PostgreSQL ist das tatsächlich schnell (reine Metadaten-Änderung, Mikrosekunden). Der Grund, warum sie trotzdem einen Ausfall verursacht, ist nicht das SQL, sondern das Deployment. Anwendungscode in Version N ruft u.username auf. Anwendungscode in Version N+1 ruft u.handle auf. Im Zeitfenster, in dem beide Versionen parallel laufen (Rolling Deploy, Blue-Green, jede realistische Deploy-Strategie), ist eine von beiden falsch. Entweder ist die Spalte umbenannt und alte Pods schlagen fehl, oder sie ist es nicht und neue Pods schlagen fehl.
Die Expand-Contract-Variante:
Deploy 1 (Expand). handle wird als neue Spalte hinzugefügt, nullable, ohne Default. Das ist in PostgreSQL günstig (reine Metadaten, kein Neuschreiben der Zeilen).
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE users ADD COLUMN handle VARCHAR(255)');
}
Der Anwendungscode bleibt unverändert. Jede Zeile hat handle = NULL und username = '<value>'.
Deploy 2 (Dual-Write). Der Anwendungscode schreibt bei jedem Create und Update in beide Spalten.
public function setUsername(string $value): void
{
$this->username = $value;
$this->handle = $value;
}
Besser: Führen Sie ein einzelnes setHandle(string $value) ein, das beide schreibt, und leiten Sie alle Aufrufer darüber. Neue Zeilen haben jetzt beide Felder befüllt. Bestehende Zeilen haben weiterhin handle = NULL.
Deploy 3 (Backfill). Ein einmaliger Job (Symfony Command oder eine Migration mit Batch-Loop) setzt handle = username für jede Zeile, in der handle IS NULL ist, in Batches von 1.000, mit kurzer Verzögerung zwischen den Batches. Das läuft über Stunden oder Tage. Die Datenbank wird nicht blockiert; einzelne Batches sind so schnell, dass sie nicht auffallen.
#[AsCommand(name: 'app:users:backfill-handle')]
final readonly class BackfillHandleCommand
{
public function __construct(
private UserRepositoryInterface $users,
private EntityManagerInterface $em,
) {}
public function __invoke(OutputInterface $output): int
{
$batchSize = 1000;
do {
$affected = $this->em->getConnection()->executeStatement(
'UPDATE users
SET handle = username
WHERE handle IS NULL
LIMIT :batch',
['batch' => $batchSize],
);
$output->writeln(\sprintf('Backfilled %d rows', $affected));
\usleep(100_000); // 100ms, keeps the table responsive
} while (0 < $affected);
return Command::SUCCESS;
}
}
Am Ende des Backfills hat jede Zeile ein handle, das nicht null ist.
Deploy 4 (Read Switch). Der Anwendungscode liest nun überall aus handle, wo er vorher aus username gelesen hat. Schreibvorgänge befüllen weiterhin beide Spalten. Die Produktion läuft damit eine Woche; Sie beobachten die Metriken auf Fehler, die damit zusammenhängen, dass handle null oder nicht vorhanden ist.
Deploy 5 (Contract). Sobald Sie sicher sind, dass kein Aufrufer username mehr liest, wird die Spalte entfernt:
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE users DROP COLUMN username');
}
In den meisten RDBMS ist das eine reine Metadaten-Operation, auch auf großen Tabellen sofort.
Fünf Deployments, um eine Spalte umzubenennen. Verstrichene Zeit: typischerweise zwei bis vier Wochen, inklusive der Soak-Phase für den Backfill. Downtime: null. Das ist der Tausch.
Die Lock-Tabelle: wissen, was jedes Statement tut
Bevor Sie irgendeine Migration auf einer großen Tabelle ausliefern, müssen Sie wissen, welchen Lock sie nimmt und wie lange. Staging wird Sie belügen. Eine 10-MB-Staging-Tabelle blockiert nie lange genug, um aufzufallen. Eine 100-GB-Produktionstabelle schon.
Hier die Kurzfassung für PostgreSQL 15+, die den Großteil dessen abdeckt, was Ihnen begegnen wird. Das lohnt sich auszudrucken und an die Wand zu hängen.
Schnell (nur Metadaten, in jeder Größe sicher):
ADD COLUMNohne Default oder mit konstantem Default (ab PG 11).DROP COLUMN.RENAME COLUMN,RENAME TABLE.ADD CONSTRAINT ... NOT VALID(verzögerte Validierung).ALTER TYPE ... ADD VALUEauf einem Enum (ab PG 12).
Potenziell blockierend (erst auf einer Replica testen):
ADD COLUMNmit volatilem Default (gen_random_uuid(),now()sofern nichtdeterministisch): vollständiges Rewrite auf PG < 11; reine Metadaten bei neueren Versionen, aber Backfill-Kosten.CREATE INDEXohneCONCURRENTLY: nimmt für den gesamten Build einenACCESS EXCLUSIVE-Lock. Tun Sie das nie auf einer großen Tabelle in Produktion.ALTER COLUMN SET NOT NULL: erfordert einen Full Table Scan. Auf großen Tabellen scannt das minutenlang.ALTER COLUMN TYPE ..., das ein Rewrite verlangt (z. B.VARCHARzuTEXTist sicher;INTzuBIGINTist ein Rewrite): blockiert.
Auf großen Tabellen immer gefährlich:
ALTER TABLE ... ADD FOREIGN KEY ...ohneNOT VALID: nimmt Locks auf referenzierender und referenzierter Tabelle und scannt die referenzierende Tabelle.VACUUM FULL: schreibt die ganze Tabelle neu und blockiert alles.- Einen Index entfernen: nimmt auf PG kurz einen
ACCESS EXCLUSIVE-Lock (schnell, aber blockiert trotzdem kurz).
Das Werkzeug, nach dem ich bei jeder nicht trivialen Migration greife: strong_migrations als mentales Modell (es stammt aus dem Rails-Ökosystem, aber die Regeln lassen sich übertragen), oder manuelles Gegenprüfen in der PG-Dokumentation für jedes Statement.
MySQL bringt seine eigenen Eigenheiten rund um ALGORITHM=INPLACE und das spezifische InnoDB-Verhalten mit. Wenn Sie auf MySQL sind, ist das Äquivalent dieser Lock-Tabelle noch wichtiger, weil die Fallstricke andere sind.
Die vier Statements, die Sonderbehandlung brauchen
Vier konkrete Schema-Änderungen kommen so häufig vor, dass sie explizite Rezepte verdienen.
Eine NOT NULL-Spalte hinzufügen
Die naive Form (ADD COLUMN status VARCHAR(32) NOT NULL DEFAULT 'active') ist auf aktuellem PostgreSQL sicher, weil konstante Defaults als Metadaten gespeichert werden. Wenn Sie aber einen dynamischen Default, einen berechneten Default oder gar keinen Default wollen, sieht die sichere Form so aus:
- Spalte als nullable hinzufügen.
- Anwendungscode ausliefern, der sie bei jedem Schreibvorgang befüllt.
- Historische Zeilen backfillen.
- Ein
NOT NULL-Constraint überADD CONSTRAINT ... CHECK (column IS NOT NULL) NOT VALIDhinzufügen, dannVALIDATE CONSTRAINT(das nur einenSHARE UPDATE EXCLUSIVE-Lock nimmt und gleichzeitige Reads und Writes erlaubt). - Das Check-Constraint in ein
NOT NULL-Spalten-Constraint umwandeln (PG 12+ unterstützt das günstig).
Das sind vier Migrations, aber jeder Schritt ist schnell und sicher.
Einen Foreign Key hinzufügen
Das Hinzufügen eines Foreign Keys scannt die referenzierende Tabelle, um zu prüfen, dass jede Zeile auf eine valide Zeile in der referenzierten Tabelle zeigt. Auf großen Tabellen blockiert dieser Scan.
Die sichere Form nutzt NOT VALID:
ALTER TABLE posts ADD CONSTRAINT fk_posts_author
FOREIGN KEY (author_id) REFERENCES authors(id)
NOT VALID;
Das fügt das Constraint hinzu, ohne bestehende Zeilen zu validieren. Neue Writes werden validiert; alte Zeilen werden noch nicht geprüft. Der Lock ist schnell.
Dann, in einer separaten Migration (oder später, wenn die Last gering ist):
ALTER TABLE posts VALIDATE CONSTRAINT fk_posts_author;
VALIDATE nimmt einen SHARE UPDATE EXCLUSIVE-Lock, der Reads und Writes weiterlaufen lässt. Es scannt die Tabelle, blockiert aber keinen Traffic.
Einen Index auf einer großen Tabelle anlegen
CREATE INDEX blockiert. CREATE INDEX CONCURRENTLY nicht. Verwenden Sie auf einer großen Produktionstabelle immer Letzteres:
CREATE INDEX CONCURRENTLY idx_posts_published_at ON posts (published_at);
Der Haken: CONCURRENTLY darf nicht innerhalb einer Transaktion laufen. Doctrine Migrations verpackt standardmäßig jede Migration in eine Transaktion. Sie müssen für diese Migration explizit aussteigen:
public function isTransactional(): bool
{
return false;
}
Der zweite Haken: Wenn CREATE INDEX CONCURRENTLY auf halbem Weg scheitert (etwa wegen eines Konflikts), lässt es einen invaliden Index zurück. Prüfen Sie pg_indexes und entfernen Sie invalide Indizes, bevor Sie einen neuen Versuch starten.
Den Typ einer Spalte ändern
Das ist schmerzhaft, weil es kein einzelnes sicheres Rezept gibt. Das Muster hängt davon ab, ob der neue Typ binärkompatibel mit dem alten ist (VARCHAR(100) zu VARCHAR(200) ist kostenlos; VARCHAR zu TEXT auf aktuellem PG ist kostenlos; INT zu BIGINT ist ein Rewrite).
Bei einer kompatiblen Änderung ist die Migration ein einzelnes ALTER COLUMN TYPE, reine Metadaten. Bei einer inkompatiblen Änderung sind Sie wieder bei Expand-Contract:
- Neue Spalte mit dem neuen Typ hinzufügen.
- Dual-Write in beide.
- Backfill.
- Reads umstellen.
- Alte Spalte entfernen.
Es gibt keine Abkürzung. Wenn jemand eine Abkürzung vorschlägt, prüfen Sie, welchen Lock er nimmt.
Backfill: in Batches, gedrosselt, beobachtbar
Die Backfill-Phase ist der Ort, an dem sich die meisten Ausfälle verstecken, wenn Teams von naiven Migrations auf Expand-Contract umsteigen. Der Backfill selbst muss sorgfältig ausgeführt werden, sonst wird er zum neuen Ausfall.
Faustregeln für einen sicheren Backfill:
Batchgröße. Fangen Sie mit 1.000 Zeilen an. Wenn die Tabelle heiß ist (hohe Schreiblast), gehen Sie auf 500 oder 250 herunter. Wenn sie kalt ist, können Sie auf 5.000 oder 10.000 hochgehen. Das Signal, auf das Sie achten: Einzelne Batches sind in unter 100 ms durch. Wenn sie länger dauern, halten Sie Locks lange genug, um mit echtem Traffic zu kollidieren.
Throttling. Zwischen Batches schlafen. Schon 100 ms zwischen Batches reduzieren den Druck auf die Datenbank deutlich und geben anderen Queries Luft. Ist die Tabelle sehr heiß, erhöhen Sie auf 500 ms.
Monitoring. Der Backfill sollte Metriken emittieren. Backfillte Zeilen pro Sekunde. Dauer pro Batch. Lock-Wait-Events. Sie wollen den Backfill auf einem Dashboard laufen sehen und ihn sofort stoppen können, wenn etwas schief aussieht. Ich habe mehr als einen Backfill abgeschossen, der sauber lief, bis die p95-Latenz der API zu steigen anfing.
Idempotenz. Die Backfill-Query sollte immer ein WHERE new_column IS NULL (oder ein äquivalenter Guard) enthalten, damit ein Neustart dort weitermacht, wo er aufgehört hat. Schreiben Sie nie einen Backfill, der seinen eigenen Zustand kennen muss.
Kill-Switch. Der Command sollte ein Rate-Limit-Flag akzeptieren, mit dem Sie Batchgröße oder Delay verändern können, ohne neu zu deployen. Ein Symfony Command mit #[Option]-Parametern funktioniert gut. Wenn der On-Call-Engineer nachts um zwei Uhr sieht, dass die Latenz steigt, sollte er den Backfill drosseln können, ohne Code zu mergen.
public function __invoke(
OutputInterface $output,
#[Option(description: 'Rows per batch')]
int $batchSize = 1000,
#[Option(description: 'Milliseconds between batches')]
int $delay = 100,
): int {
// ...
}
Die Migration-Gewohnheiten
Jenseits der konkreten Rezepte gibt es drei Gewohnheiten, die das alles leichter durchhaltbar machen.
Migrations auf Lock-Verhalten reviewen, nicht nur auf SQL-Korrektheit. Jeder Migration-PR in den Teams, mit denen ich arbeite, durchläuft ein Lock-Review: Welchen Lock nimmt das, wie lange, gegen eine Tabelle welcher Größe in Produktion? “Wir haben es auf Staging laufen lassen” ist keine Antwort. “Auf einer Tabelle dieser Größe in Produktion nimmt dieses Statement einen ACCESS EXCLUSIVE-Lock für geschätzt 8 Minuten” schon.
Nie eine bereits gemergte Migration verändern. Sobald eine Migration in Produktion ist, ist sie eingefroren. War sie falsch, schreiben Sie eine neue Migration, die vorwärts korrigiert. Die Historie zu editieren, zerbricht jede andere Umgebung, die die alte Version schon ausgeführt hat. Die CLAUDE.md-Konvention dafür ist explizit und richtig.
Schema-Migrations von Daten-Migrations trennen. Schema-Änderungen gehören in Doctrine Migrations. Massendatenänderungen (Backfills, Cleanups, Rewrites) gehören in Symfony Commands. Beides in einer einzelnen Migration zu vermischen ist ein häufiger Fehler, der aus einer Schema-Änderung von einer Sekunde eine Datenoperation von 20 Minuten macht, eingepackt in eine Transaktion. Die Transaktion hält den Schema-Lock über die gesamte Dauer der Datenoperation, und so bekommen Sie einen 20-minütigen Ausfall.
Migrations gegen Daten in Produktionsgröße testen. In jedem Engagement frage ich nach der Größe der Staging-Datenbank. Im Schnitt sind es 0,1 Prozent der Produktion. Das ist für die Verifikation der Migration-Sicherheit wertlos. Die Lösung: eine gesäuberte, anonymisierte Kopie der Produktion vorhalten (größenmäßig angepasst, nicht datenmäßig), gegen die Engineering Migrations ausführen kann. Das ist Infrastrukturarbeit, zahlt sich aber beim ersten Mal aus, wenn sie ein Pre-Production-Lock-Desaster abfängt.
Wann Sie das alles weglassen können
Es gibt Fälle, in denen der volle Expand-Contract-Tanz überzogen ist:
- Tabellen mit wenigen Hunderttausend Zeilen. Die naive Migration ist in Ordnung. Selbst ein kompletter Table-Rewrite läuft in Sekunden durch. Führen Sie für eine
settings-Tabelle mit 200 Zeilen nicht den Overhead von Expand-Contract ein. - Echte Wartungsfenster. Wenn das Unternehmen ein wöchentliches Wartungsfenster hat, in dem die Anwendung legitim offline ist, können Sie darin eine klassische Migration fahren. (Die meisten Unternehmen haben keine echten Wartungsfenster mehr. Prüfen Sie das, bevor Sie es voraussetzen.)
- Neue Tabellen. Eine Tabelle anzulegen, die noch von keinem Code referenziert wird, ist eine einzelne Migration. Es gibt nichts zu koordinieren. Sie brauchen Expand-Contract nur, wenn die Tabelle bereits Aufrufer hat.
Die Disziplin ist für die Fälle da, in denen es zählt: große Tabellen mit Traffic, der nicht pausiert werden kann. Das sind die Fälle, die Incidents auslösen.
Das Playbook, kompakt
Die Checkliste, die ich jeden Migration-PR durchgehen lasse, auf einen Blick:
- Wie viele Zeilen hat diese Tabelle in Produktion?
- Welchen Lock nimmt das Statement und wie lange?
- Lässt sich das in Expand-Contract-Schritte zerlegen?
- Wenn es einen Backfill gibt: ist er in Batches, gedrosselt, idempotent und beobachtbar?
- Steigt die Migration bei Bedarf aus der Transaktion aus (
CREATE INDEX CONCURRENTLY)? - Hat im PR ein Lock-Review mit Zahlen stattgefunden?
- Ist das Deployment so sequenziert, dass die Anwendung in jedem Schritt funktionsfähig ist?
Wenn jede Antwort “ja, und ich kann auf die Zeile zeigen, die das belegt” lautet, ist die Migration sicher. Wenn irgendeine Antwort “daran habe ich nicht gedacht” lautet, schicken Sie den PR zurück. Die Produktion wird die Frage stellen, die Sie übersprungen haben, und sie wird nicht freundlich fragen.
Wenn Sie auf eine Datenbank schauen, die zu groß ist, um mit den Mustern Ihres Teams sicher migriert zu werden, beinhaltet mein Scaling-Engagement ein vollständiges Migration-Audit. Die Lock-Tabelle übersetzt auf Ihren Stack, ein Template für Expand-Contract-Migrations und die Review-Checkliste verdrahtet in Ihren PR-Prozess, damit die nächste Schema-Änderung niemanden mehr aus dem Bett klingelt.
Referenzen
- ParallelChange von Danilo Sato (Martin Fowlers Bliki): der kanonische Writeup des Expand / Migrate / Contract-Musters, auf dem dieser Essay aufbaut.
- PostgreSQL: Explicit Locking: offizielle Referenz zu
ACCESS EXCLUSIVE,SHARE UPDATE EXCLUSIVEund der vollständigen Lock-Kompatibilitätsmatrix. - PostgreSQL: CREATE INDEX: dokumentiert die
CONCURRENTLY-Option und ihre Transaktionsbeschränkungen. - PostgreSQL: ALTER TABLE: Referenz zu
ADD CONSTRAINT ... NOT VALID,VALIDATE CONSTRAINTund der Optimierung vonADD COLUMNmit konstantem Default. - Doctrine Migrations: die Migration-Library und der
isTransactional()-Opt-Out, den das CONCURRENTLY-Rezept nutzt. - strong_migrations (Rails-Ökosystem): ein Katalog unsicherer Migration-Muster, dessen Regeln sich sauber auf Doctrine übertragen.