Schemaänderungen ohne Ausfall: erweitern, migrieren, zurückbauen
Eine Schemaänderung birgt ein anderes Risiko als eine Änderung am Anwendungscode. Ist der Code fehlerhaft, kann man auf die vorherige Version zurückgehen. Hat man dagegen eine Spalte gelöscht, einen Typ geändert oder eine Tabelle aufgeteilt, bedeutet der Rückweg meist Datenverlust oder eine lange Wiederherstellung. Hinzu kommt: In modernen Deployments läuft nie nur eine einzige Anwendungsversion. Während eines Rolling Updates nutzen alte und neue Instanzen eine Zeit lang dieselbe Datenbank.
In diesem Beitrag stelle ich den Ansatz „erweitern, migrieren, zurückbauen“ (Expand/Contract) für Schemaänderungen ohne Ausfall vor. Ziel ist, jeden Schritt für sich sicher und umkehrbar zu halten.
Das Problem: Zwei Versionen teilen sich ein Schema
Angenommen, Sie möchten die Spalte Name der Tabelle Customers in FullName umbenennen. Benennen Sie sie in einer einzigen Migration um, fragen noch nicht aktualisierte Instanzen weiter Name ab und schlagen fehl. Rollen Sie dagegen zuerst die Anwendung aus, sucht der neue Code nach einer Spalte FullName, die noch nicht existiert.
Egal welche Reihenfolge Sie wählen, es entsteht ein Zeitfenster. Die Lösung besteht darin, die Änderung in kleine Schritte zu zerlegen, bei denen Schema und Anwendung jederzeit zueinander passen. In jedem Schritt müssen sowohl die alte als auch die neue Version funktionieren.
Erweitern: Neues hinzufügen, ohne Altes zu brechen
Die erste Phase fügt nur hinzu. Die neue Spalte FullName wird angelegt und erlaubt Nullwerte, weil bestehende Zeilen noch keinen Wert haben und der alte Code sie nie beschreibt.
ALTER TABLE Customers ADD FullName nvarchar(200) NULL;
Dieser Schritt ist für die alte Anwendung unsichtbar. Eine Warnung: Bei manchen Datenbanken kann das Hinzufügen einer Spalte mit Standardwert oder NOT NULL eine große Tabelle sperren. Das Verhalten hängt von Engine und Version ab, daher sollte man die Änderung zuerst an einer Kopie in produktionsähnlicher Größe testen. Bei großen Tabellen ist es meist berechenbarer, die Spalte leer anzulegen und die Werte danach zu füllen.
Auch bei Indizes ist dieselbe Sorgfalt nötig. Viele Engines unterstützen das Online-Erstellen von Indizes, aber Voraussetzungen und Zusatzlast unterscheiden sich. Prüfen Sie die Dokumentation Ihrer Engine, bevor Sie in Produktion einen Index anlegen.
Doppeltes Schreiben: Der neue Code schreibt an beide Stellen
In der zweiten Phase wird die Anwendung aktualisiert. Die neue Version schreibt beim Anlegen und Ändern von Datensätzen sowohl in Name als auch in FullName. Beim Lesen betrachtet sie zuerst FullName und fällt auf Name zurück, wenn der Wert leer ist.
public sealed class Customer
{
public string Name { get; set; } = "";
public string? FullName { get; set; }
public string DisplayName => FullName ?? Name;
public void Rename(string value)
{
Name = value; // ältere Versionen lesen diese Spalte noch
FullName = value; // die neue Spalte
}
}
Diese Übergangsversion ist in beide Richtungen kompatibel. Alte Instanzen arbeiten nur mit Name; neue Instanzen halten beide Spalten korrekt. Mitten in einem Rolling Update spielt es keine Rolle, bei welcher Instanz eine Anfrage landet: Das Ergebnis ist konsistent.
Backfill: Altdaten in kleinen Stapeln übertragen
Doppeltes Schreiben deckt nur neue Änderungen ab. Der Wert von FullName für bestehende Zeilen muss separat gefüllt werden. Millionen Zeilen mit einem einzigen UPDATE zu ändern bedeutet eine lange Transaktion, große Sperren und ein aufgeblähtes Transaktionsprotokoll. Verarbeiten Sie die Daten stattdessen in begrenzten Stapeln.
UPDATE TOP (5000) Customers
SET FullName = Name
WHERE FullName IS NULL;
Dieser Befehl wird in einer Schleife ausgeführt, bis keine Zeile mehr betroffen ist; eine kurze Pause nach jedem Stapel lässt Platz für den Live-Verkehr. Schreiben Sie den Vorgang als Hintergrundjob, sollte er neu startbar sein: Da die Bedingung bereits WHERE FullName IS NULL lautet, setzt ein unterbrochener Job dort fort, wo er aufgehört hat, und eine doppelte Ausführung schadet nicht.
Prüfen Sie nach Abschluss mit Abfragen, dass keine Zeilen mit FullName IS NULL übrig sind und beide Spalten gleich sind. Diese Prüfung ist die Grundlage für die Freigabe des nächsten Schritts.
Lesen umstellen und Constraints verschärfen
Sind die Daten vollständig, liest die Version nur noch FullName. Beim Schreiben ist es sinnvoll, noch eine Weile beide Spalten zu beschreiben, denn bei Problemen mit diesem Release muss der Rückweg zur vorherigen Version weiterhin möglich sein.
Nach einer unauffälligen Beobachtungsphase kann FullName einen NOT NULL-Constraint erhalten. Dabei muss sichergestellt sein, dass keine Instanz mit altem Code mehr läuft, die diese Spalte nicht beschreibt. Sonst schlägt die Insert-Abfrage der alten Version wegen des Constraints fehl. Deshalb ist es wichtig, die aktiven Versionen im Deployment-System zu prüfen und geplante Jobs nicht zu vergessen.
Zurückbauen: Die alte Spalte in einem eigenen Release entfernen
Die letzte Phase entfernt die Spalte Name, die kein Code mehr verwendet. Behandeln Sie das als nicht umkehrbaren Schritt und führen Sie ihn getrennt vom Haupt-Release durch. Prüfen Sie vor dem Löschen: Sind alle Abfragen im Repository, Berichte, Exportjobs und ETL-Prozesse auf die neue Spalte umgestellt, und gibt es einen Wiederherstellungsplan aus dem Backup?
Manche Teams entfernen die Spalte nicht sofort, sondern zuerst ihre Nutzung in der Anwendung, warten ein bis zwei Releases und löschen sie erst dann. Das Warten mag überflüssig wirken, doch ein vergessener nächtlicher Bericht oder die direkte Abfrage eines anderen Dienstes zeigt sich meist nur auf diese Weise.
Praktische Regeln und die .NET-Seite
Ein paar Regeln helfen. Halten Sie Migrationen klein und geben Sie jeder genau eine Absicht. Kombinieren Sie Hinzufügen und Löschen nicht im selben Release. Planen Sie die zerstörerische Änderung als letzten Schritt, unabhängig von der Anwendungsversion. Messen Sie Sperrdauer und Wachstum des Transaktionsprotokolls mit produktionsnahen Daten.
Wenn Sie EF Core verwenden, prüfen Sie das mit dotnet ef migrations script --idempotent erzeugte Skript und führen Sie es als kontrollierten Schritt in der Deployment-Pipeline aus, statt beim Anwendungsstart automatisch zu migrieren. Versuchen mehrere Instanzen während eines Rolling Updates gleichzeitig zu migrieren, können Race Conditions entstehen. Beachten Sie außerdem, dass das erzeugte Skript bei einer Umbenennung die Spalte löschen und neu anlegen kann; das ist Datenverlust und muss von Hand korrigiert werden.
Denken Sie schließlich jede Phase unter dem Gesichtspunkt des Rollbacks: Der Erweiterungsschritt lässt sich leicht rückgängig machen, die Version mit doppeltem Schreiben kann auf das vorherige Release zurück, und der Backfill lässt sich erneut ausführen. Nur der abschließende Rückbau ist endgültig, deshalb sollte er der letzte und sorgfältigste Schritt sein.
Eine Schemaänderung ohne Ausfall hängt weniger an einem einzelnen cleveren Befehl als an einer disziplinierten Abfolge. Zerlegen Sie die Änderung in Erweitern, doppeltes Schreiben, Backfill, Lesen umstellen und Zurückbauen, ist jedes Release klein, beobachtbar und umkehrbar – und die Datenbank kann mit derselben Sicherheit weiterentwickelt werden wie die Anwendung.