Symfony Datenbank-Migrationen: Der vollständige Leitfaden für sichere Schema-Änderungen

#Symfony Datenbank-Migration

Gründer & Lead Developer

Experte für Softwareentwicklung und Legacy-Code-Optimierung

LinkedIn

Jede Symfony-Anwendung braucht irgendwann eine Schema-Änderung, und jede Schema-Änderung ist eine Gelegenheit, die Produktion lahmzulegen. Eine Symfony-Datenbankmigration, die in einer lokalen Umgebung harmlos wirkt, eine neue NOT-NULL-Spalte, ein umbenanntes Feld, ein Index, der günstig schien, kann eine Tabelle für Minuten sperren, wenn der Datensatz längst über das hinausgewachsen ist, wogegen jemand getestet hat. Dieser Leitfaden deckt den gesamten Workflow ab: wie Doctrine Migrations tatsächlich funktionieren, was vor dem Ausliefern einer Migration zu prüfen ist, wie man Schemata ohne Downtime ändert und was zu tun ist, wenn eine Migration bereits gegen die Produktion gelaufen ist, das Deployment aber trotzdem fehlgeschlagen ist.

Wie Doctrine Migrations funktionieren

Doctrine Migrations verfolgt Schema-Änderungen als versionierte PHP-Klassen, jede mit einer up()-Methode, die eine Änderung anwendet, und einer down()-Methode, die sie rückgängig macht. Symfony speichert, welche Versionen bereits gelaufen sind, in einer Tabelle namens doctrine_migration_versions, sodass das Tool jederzeit den aktuellen Zustand der Datenbank relativ zur Codebasis kennt.

Es gibt zwei Wege, eine Migration zu erstellen. doctrine:migrations:diff vergleicht die Doctrine-Entity-Mappings mit dem laufenden Schema und generiert automatisch eine Migrationsdatei. Das geht schnell, generiert aber auch genau das, was es sieht: Wenn eine Entity-Änderung eine Datentransformation impliziert und nicht nur eine strukturelle Änderung, taucht das in der generierten Datei nicht auf. Die zweite Option ist, die Migration von Hand zu schreiben, was immer dann die richtige Wahl ist, wenn eine Änderung individuelles SQL, ein Daten-Backfill oder Logik braucht, die der Doctrine-Schema-Vergleich nicht selbst ableiten kann.

Welchen Weg man auch wählt, jede Migration muss idempotent sein. Wenn ein Deployment mittendrin fehlschlägt und erneut ausgeführt wird, oder wenn eine Migration versehentlich zweimal gegen dieselbe Datenbank läuft, darf das keinen Fehler werfen oder Daten beschädigen. ALTER TABLE-Anweisungen in Existenzprüfungen einzupacken und Datenmigrationen mit WHERE-Klauseln abzusichern, die ein erneutes Ausführen sicher machen, kostet ein paar zusätzliche Minuten und erspart einen deutlich schlimmeren Nachmittag.

Die Pre-Deployment-Checkliste

Bevor eine Migration die Produktion erreicht, sollten drei Dinge bereits zutreffen.

Die Migration wurde gegen eine Kopie der Produktionsdaten getestet, nicht gegen eine mit ein paar hundert Zeilen befüllte lokale Datenbank. Query-Planer verhalten sich bei größeren Datenmengen anders, und eine Migration, die gegen 500 Zeilen sofort durchläuft, kann gegen 50 Millionen zwanzig Minuten brauchen. Wer keinen routinemäßigen Weg hat, gegen produktionsgroße Daten zu testen, sollte diese Lücke schließen, bevor sie ein Wartungsfenster kostet.

Eine Rollback-Migration existiert und wurde tatsächlich schon einmal ausgeführt, nicht nur geschrieben. down()-Methoden verrotten leise: Jemand schreibt eine, sie wird nie ausgeführt, und wenn sie sechs Monate später gebraucht wird, verweist sie auf eine Spalte, die längst nicht mehr existiert. Den Rollback als Teil derselben Änderung in einer Staging-Umgebung auszuführen, deckt das auf, bevor es zum Problem wird.

Es gibt eine Schätzung, wie lange die Migration auf produktionsgroßen Tabellen dauern wird. Dabei geht es nicht um Präzision, sondern darum zu wissen, ob man es mit einem zwei Sekunden dauernden ALTER TABLE oder einem zehnminütigen Table-Rewrite zu tun hat, der außerhalb der Spitzenlast eingeplant werden muss. EXPLAIN auf die zugrunde liegende Query ausführen, die Zeilenanzahl der Tabelle prüfen, und wenn die Antwort "wissen wir nicht" lautet, ist genau das die Antwort, die zuerst behoben werden muss.

Zero-Downtime-Muster für häufige Änderungen

Nicht jede Schema-Änderung trägt das gleiche Risiko, und alle gleich zu behandeln bremst entweder harmlose Änderungen aus oder lässt riskante ungeprüft durch.

Eine nullable Spalte hinzuzufügen ist auf PostgreSQL und MySQL 8 fast kostenlos, weil die Datenbank bestehende Zeilen nicht neu schreiben muss, wenn ein NULL-Standardwert kein Backfill erfordert. Eine NOT-NULL-Spalte hinzuzufügen ist ein anderes Problem. Ältere MySQL-Versionen schreiben die gesamte Tabelle neu, um den neuen Standardwert zu befüllen, was Schreibvorgänge für die Dauer blockiert. Die sicherere Reihenfolge: die Spalte als nullable hinzufügen, sie in Batches befüllen und die NOT-NULL-Beschränkung erst hinzufügen, sobald jede Zeile einen Wert hat.

Eine Spalte umzubenennen sollte niemals eine einzelne Migration sein, wenn die Tabelle aktiv genutzt wird, denn jedes Deployment, das nicht augenblicklich ist, lässt alten Code in einen Spaltennamen schreiben, der nicht mehr existiert. Das Expand-Contract-Muster vermeidet das: die neue Spalte hinzufügen, Anwendungscode deployen, der in die alte und die neue Spalte schreibt, bestehende Zeilen befüllen, Code deployen, der nur noch aus der neuen Spalte liest, und erst dann, in einer späteren Migration, die alte Spalte löschen. Das sind mehr Schritte, aber jeder einzelne ist für sich reversibel.

Ob sich ein Index ohne Sperren der Tabelle hinzufügen lässt, hängt von der Datenbank-Engine ab. PostgreSQLs CREATE INDEX CONCURRENTLY baut den Index, ohne eine Sperre zu halten, die Schreibvorgänge blockiert, allerdings auf Kosten einer längeren Bauzeit und der Tatsache, dass es nicht innerhalb einer Transaktion laufen kann, was für die Struktur der Doctrine-Migration selbst relevant ist. MySQLs ALGORITHM=INPLACE verhält sich für die meisten Indextypen auf modernen Versionen ähnlich. Prüfen, was die eigene Engine tatsächlich unterstützt, bevor man annimmt, ein Index-Add sei standardmäßig sicher.

Migrationen in CI testen

Eine Migration, die nur getestet wird, weil ein Entwickler sie lokal ausführt, ist keine wirklich getestete Migration. CI sollte jede ausstehende Migration gegen eine echte PostgreSQL- oder MySQL-Instanz laufen lassen, die der Produktion so nahe wie praktikabel kommt, statt gegen SQLite. Das Typsystem und Transaktionsverhalten von SQLite unterscheiden sich genug von echten Produktionsdatenbanken, dass eine in SQLite erfolgreiche Migration wenig darüber aussagt, ob sie gegen das echte System bestehen wird.

Der praktische Aufbau: einen Datenbank-Service in der CI-Pipeline starten, doctrine:migrations:migrate gegen eine frische Instanz laufen lassen, die aus dem bestehenden Schema aufgebaut wurde, und den Build fehlschlagen lassen, wenn die Migration einen Fehler wirft, ein Timeout auslöst oder das Schema in einem unerwarteten Zustand zurücklässt. Das fängt genau die Art von Bug ab, die nur auftritt, wenn eine Migration gegen eine Datenbank läuft, die bereits Daten enthält, und das trifft auf die meisten zu. Spricht die Anwendung mit mehr als einer Datenbank, hat jede ihr eigenes Migrations-Set und braucht einen eigenen CI-Schritt; unser Leitfaden zu Doctrine Migrations mit mehreren Datenbanken zeigt die Konfiguration und die Befehle.

Migrationen mit Kamal deployen

Wer mit Kamal deployt, sollte Migrationen in einen Pre-Deploy-Hook legen, der gegen das aktuelle Release läuft, bevor der Traffic auf die neue Version umgeschaltet wird. Diese Reihenfolge ist wichtig: Bei einer Expand-Contract-Änderung muss das Schema während des Deployment-Fensters sowohl den alten als auch den neuen Anwendungscode unterstützen, sodass die Migration abgeschlossen sein muss, bevor der neue Code live geht, und der alte Code weiter gegen das aktualisierte Schema funktionieren muss, bis der Rollout abgeschlossen ist.

Kamals Hooks laufen als Shell-Befehle, die an bestimmte Deployment-Phasen gebunden sind, sodass ein pre-deploy-Hook, der bin/console doctrine:migrations:migrate --no-interaction ausführt, einen einzigen, wiederholbaren Schritt liefert, statt eines manuellen Befehls, an den sich jemand erinnern muss. Das lässt sich mit einem Health-Check kombinieren, der das Deployment fehlschlagen lässt, wenn die Migration einen Fehler wirft, statt ein kaputtes Schema mit der Annahme in die Produktion zu lassen, dass es schon jemand bemerken wird.

Wenn eine Migration bereits gelaufen ist und das Deployment fehlschlägt

Genau das soll die obige Checkliste verhindern, und trotzdem passiert es. Die Migration ist gegen die Produktion durchgelaufen, die Versionstabelle zeigt sie als angewendet, aber etwas anderes im Deployment ist fehlgeschlagen: ein Container, der nicht startet, ein abhängiger Service, der jetzt mit dem neuen Schema inkompatibel ist, ein Codepfad, der davon ausgegangen ist, dass die Migration auch Anwendungslogik aktualisiert, die noch auf der alten Version läuft.

Der erste Schritt ist, auf Datenbankebene zu prüfen, was tatsächlich passiert ist, statt es anzunehmen. In doctrine_migration_versions nachsehen, welche Version aktuell als angewendet vermerkt ist. Dann entscheiden, ob vorwärts oder zurück gerollt wird. Vorwärts zu rollen, also das zu fixen, was kaputtgegangen ist, und den korrigierten Code zu deployen, ist fast immer sicherer, als eine Schema-Änderung zurückzurollen, auf die andere Teile des Systems möglicherweise bereits angewiesen sind. Die down()-Migration gegen eine Produktionsdatenbank auszuführen, die aktiv Traffic auf dem neuen Schema bedient, kann mehr Schaden anrichten als der ursprüngliche Fehler. Ist ein Rollback wirklich die richtige Entscheidung, lohnt es sich, ihn in einem Wartungsfenster bei pausiertem Traffic auszuführen, auch wenn das ein paar zusätzliche Minuten kostet.

Das dauerhaft richtig hinbekommen

Das Muster ist bei alldem dasselbe: Schema-Änderungen sind sicher, wenn sie reversibel sind, gegen realistische Daten getestet wurden und klein genug sind, dass ein einzelner Schritt scheitern kann, ohne die Datenbank mitzureißen. Diese Disziplin lässt sich leichter in eine neue Codebasis einbauen, als sie nachträglich in eine Codebasis zu bringen, in der sich Migrationen über Jahre ohne konsistenten Prozess angesammelt haben.

Wenn Ihr Team mit einer Symfony-Anwendung zu tun hat, bei der Migrationen zur Quelle ständiger Sorge statt zur Routine-Wartung geworden sind, ist das meist ein Zeichen, dass der zugrunde liegende Prozess Aufmerksamkeit braucht, nicht nur die nächste Migration. Wolf-Tech arbeitet mit Teams genau an solchen Problemen, durch Code-Qualitäts-Beratung, um zu prüfen, wie Migrationen, Tests und Deployment zusammenspielen, und durch Legacy-Code-Optimierung, wenn Jahre ad-hoc gewachsener Schema-Änderungen entwirrt werden müssen, bevor man ihnen wieder vertrauen kann.

Wenn Sie eine zweite Meinung zu Ihrem Migrations-Workflow wollen oder eine Schema-Änderung planen, bei der Sie sich nicht sicher sind, melden Sie sich unter hello@wolf-tech.io oder finden Sie mehr auf wolf-tech.io.