Das Datenbankschema Ihrer Business-Anwendung versionieren ── Migrationspraxis gegen „jeder Kunde hat eine andere Datenbank“
· Go Komura · Datenbank, SQLite, SQL Server, Migration, Schemaverwaltung, C#, .NET, Wartung, Entscheidungstabelle, Windows-Entwicklung
„Die bei Firma A installierte Datenbank hat diese Spalte, die bei Firma B aber nicht. Und mittlerweile erinnert sich niemand mehr, in welcher Version sie hinzugekommen ist.“ — Übernehmen Sie die Wartung einer Business-Anwendung, die bei jedem Kunden separat installiert wird, stoßen Sie öfter als nicht auf genau diese Situation.
Im Update-Runbook steht: „Dieses SQL gegen die Datenbank ausführen.“ Doch ob es tatsächlich ausgeführt wurde, weiß nur, wer die Arbeit vor Ort erledigt hat, und mit der Zeit entsteht eine Mischung aus Kunden: bei manchen wurde der Schritt vergessen, bei manchen brach er mittendrin mit einem Fehler ab und wurde so belassen, und bei manchen wurde bei einem Update eine Version übersprungen, sodass ein dazwischenliegendes ALTER TABLE fehlt. Jahre später verschlingt die Untersuchung „eines Fehlers, der nur bei diesem einen Kunden auftritt“ endlos Entwicklungsaufwand.
In diesem Blog haben wir bereits ein minimal versioniertes Schema in Wo eine Windows-Anwendung ihre Daten speichern sollte behandelt und das Betriebsdesign von SQLite in SQLite in Business-Anwendungen mit C# einsetzen. Dieser Artikel knüpft dort an und vertieft, wie sich Schemaänderungen versionieren und sicher auf die vielen über Kundenstandorte verstreuten Datenbanken anwenden lassen. SQLite steht im Mittelpunkt, doch das Design lässt sich gleichermaßen auf SQL Server (Express) verallgemeinern.
1. Das Wichtigste zuerst
- Schemaänderungen sollten als Code ausgeliefert werden – nummerierte, in die Anwendung selbst eingebettete Migrationen – statt als SQL-Runbook, und beim Start automatisch angewendet werden. Jeder Ablauf, der davon abhängt, dass ein Mensch ein Runbook ausführt, bricht in dem Moment zusammen, in dem die Datenbank über Kundenstandorte verstreut ist.
- Die Datenbank selbst sollte ihre aktuelle Schemaversion aufzeichnen. Bei SQLite ist
PRAGMA user_versionein genau dafür reservierter Speicherplatz.1 Bei SQL Server führen Sie eine Anwendungshistorie in einer eigenen Tabelle. - Migrationen bewegen sich nur vorwärts und werden nur angehängt. Schreiben Sie niemals das SQL unter einer bereits ausgelieferten Nummer um; korrigieren Sie es stattdessen unter einer neuen Nummer. So wird selbst ein Sprung-Update direkt von v1.2 auf v1.5 zu nichts weiter als „das, was noch nicht angewendet wurde, der Reihe nach ausführen“.
- Behandeln Sie Breaking Changes – das Entfernen oder Umbenennen einer Spalte – als zweistufiges Expand-Contract-Release. Liefern Sie zuerst ein reines Hinzufügungs-Release aus, und erst wenn nichts mehr auf die alte Form verweist, das Release, das sie entfernt.
- Schützen Sie sich mit einer Mindestversionsprüfung vor dem Missgeschick, dass eine ältere Anwendungsversion eine neuere Datenbank öffnet. Das Prinzip lautet: Lassen Sie die Anwendung niemals in ein zukünftiges Schema schreiben, das sie nicht versteht.
- Erstellen Sie vor jeder Anwendung automatisch ein Backup. In SQLite erzeugt eine einzelne Anweisung
VACUUM INTOeine konsistente Kopie,2 wodurch sich die Wiederherstellung nach einem Fehlschlag auf einen einfachen Dateitausch reduziert. - Eine Migration entspricht einer Transaktion, und die Aktualisierung der Versionsnummer gehört in dieselbe Transaktion. SQLite kann DDL innerhalb einer Transaktion ebenfalls zurückrollen.3 SQL Server hat DDL-Anweisungen, die davon ausgenommen sind, isolieren Sie diese Operationen also in eigene Migrationen.4
2. Warum „jeder Kunde hat eine andere Datenbank“ entsteht
Zerlegt man die Ursachen, führt jede davon auf einen betrieblichen Ablauf zurück, der davon ausgeht, dass ein Mensch sich darum kümmert.
- Ein manuelles ALTER wird übersehen. Nichts in der Datenbank selbst zeichnet auf, ob das SQL des Runbooks tatsächlich ausgeführt wurde, und sobald die einzige Prüfmöglichkeit „die Tabellendefinition mit bloßem Auge betrachten“ ist, sind Auslassungen garantiert.
- Ein Fehlschlag mittendrin wird belassen. Wirft die dritte von fünf SQL-Anweisungen im Runbook einen Fehler, kann derjenige, der es ausführt, nicht beurteilen, ob fortgefahren oder zurückgerollt werden soll, und es endet als „die Anwendung läuft noch, also lassen wir es so“. Diese Datenbank hat nun ein einzigartiges Schema, das zu überhaupt keiner Version passt.
- Sprung-Updates über Versionen hinweg. Ein Kunde, der direkt von v1.2 auf v1.5 wechselt, muss die Schemaänderungen von sowohl v1.3 als auch v1.4 korrekt gemeinsam durchlaufen, was unter runbook-gesteuertem Betrieb schwer richtig hinzubekommen ist.
- Ein Notfall-Patch vor Ort. „Wir haben diese Spalte vorab hinzugefügt, nur für diesen einen Kunden“ kommt vor, und das später folgende offizielle Update schlägt dann mit einem Fehler wegen doppelter Anwendung fehl.
Ein Web-System mit einem einzelnen Server hat genau eine Datenbank, deren Zustand stets bekannt ist. Was Desktop-Business-Anwendungen grundlegend schwieriger macht, ist, dass die Datenbank ein und derselben Anwendung über Dutzende oder Hunderte PCs bei Kunden und Niederlassungen verstreut ist, und nicht alle notwendigerweise auf derselben Version stehen. Ein betrieblicher Ablauf, bei dem ein Mensch die Maschinen einzeln bearbeitet, bricht direkt proportional zur Anzahl der Maschinen zusammen, also bleibt nur ein wirklicher Schluss: der Anwendung selbst die Fähigkeit zu geben, ihre eigene Datenbank zu untersuchen und auf das neueste Schema zu bringen.
3. Das Grundmuster: Schemaversion + Vorwärtsmigrationen
Das Grundgerüst des Mechanismus besteht aus nur drei Teilen.
- Die Datenbank selbst hält eine Schemaversionsnummer (eine reine Schema-Ganzzahl, getrennt von der Produktversion der Anwendung).
- Schemaänderungen werden als nummerierte Sequenz von Migrationen an den Code der Anwendung angehängt.
- Beim Start (unmittelbar nach dem Verbinden mit der Datenbank) wendet die Anwendung jede Migration mit einer höheren Nummer als der aktuellen Version der Reihe nach und innerhalb von Transaktionen an.
Bei SQLite steht PRAGMA user_version als Speicherort für die Versionsnummer zur Verfügung. Es ist eine im Datenbankkopf gespeicherte Ganzzahl (bei Offset 60), und die offizielle Dokumentation stellt unmissverständlich fest, dass „die Anwendung sie frei nutzen darf und SQLite selbst diesen Wert nie anfasst“.1 Ohne eine eigene Tabelle anzulegen, kann eine einzelne Datenbankdatei ihre eigene Version selbst deklarieren.
Eine selbst entwickelte C#-Implementierung wird in den nächsten paar Dutzend Zeilen praxistauglich.
using Microsoft.Data.Sqlite;
public static class SchemaMigrator
{
// Nur-Anhängen-Liste. Niemals das SQL unter einer bereits ausgelieferten Nummer umschreiben
private static readonly (int Version, string Sql)[] Migrations =
{
(1, "CREATE TABLE customer (id INTEGER PRIMARY KEY, name TEXT NOT NULL)"),
(2, "ALTER TABLE customer ADD COLUMN phone TEXT"),
(3, """
CREATE TABLE invoice (
id INTEGER PRIMARY KEY,
customer_id INTEGER NOT NULL REFERENCES customer(id),
issued_at TEXT NOT NULL, -- als UTC speichern, in festem Format
amount INTEGER NOT NULL -- Beträge sind Ganzzahlen in der kleinsten Währungseinheit
)
"""),
};
public static void Migrate(SqliteConnection conn)
{
// Ein Fehler beim Anhängen (eine doppelte oder nicht geordnete Nummer) wird zu einer
// stillen doppelten Anwendung oder einer stillen Auslassung, daher vor jeder Anwendung erkennen und stoppen
for (int i = 1; i < Migrations.Length; i++)
if (Migrations[i].Version <= Migrations[i - 1].Version)
throw new InvalidOperationException(
"Migration version numbers must be strictly ascending and unique.");
int current = GetUserVersion(conn);
int latest = Migrations[^1].Version;
if (current > latest)
// Der Fall, dass die Datenbank einer neueren Anwendung von einer älteren Anwendung geöffnet wird (siehe 5.2).
// Es ist am sichersten, hier zu stoppen, statt ein Schema anzufassen, das wir nicht verstehen
throw new InvalidOperationException(
$"This database (schema v{current}) was created by a newer version of the " +
"app. Please update the app.");
foreach (var (version, sql) in Migrations)
{
if (version <= current) continue;
using var tx = conn.BeginTransaction();
using var cmd = conn.CreateCommand();
cmd.Transaction = tx;
cmd.CommandText = sql;
cmd.ExecuteNonQuery();
// Die Versionsaktualisierung innerhalb derselben Transaktion committen.
// Das eliminiert den Zustand, in dem „die Änderung ist eingegangen, aber die Nummer ist noch alt“
cmd.CommandText = $"PRAGMA user_version = {version}";
cmd.ExecuteNonQuery();
tx.Commit();
}
}
private static int GetUserVersion(SqliteConnection conn)
{
using var cmd = conn.CreateCommand();
cmd.CommandText = "PRAGMA user_version";
return Convert.ToInt32(cmd.ExecuteScalar());
}
}
Das löst die Probleme aus Kapitel 2 strukturell. Nichts wird übersehen (es wird bei jedem Start geprüft), ein Fehlschlag mittendrin wird zurückgerollt (Kapitel 6), und das Überspringen von Versionen ist unproblematisch (steht eine v1.2-Datenbank bei Schema v2, wendet eine v1.5-Anwendung einfach 3, 4 und 5 der Reihe nach an). Selbst „in welchem Zustand befindet sich die Datenbank dieses Kunden“ lässt sich mit einem einzigen Lesen von PRAGMA user_version beantworten.
Es gibt genau zwei betriebliche Regeln, die ohne Ausnahme einzuhalten sind.
- Schreiben Sie niemals eine bereits ausgelieferte Nummer um. Selbst wenn das SQL von v3 einen Fehler hat, korrigieren Sie ihn in v4. Ein Umschreiben schafft eine neue Quelle des Auseinanderdriftens: manche Datenbanken haben „das alte v3 angewendet“, andere „das neue v3 angewendet“.
- Beziehen Sie Datentransformationen ebenfalls in die Migration mit ein. Über das Hinzufügen einer Spalte hinaus gehört das Migrieren bestehender Daten (ein UPDATE) unter dieselbe Nummer. Datums-/Zeitspalten von Anfang an auf UTC und ein festes Format zu vereinheitlichen – wie in Datum, Uhrzeit und Zeitzonen in Business-Anwendungen behandelt – hält spätere Migrationen einfacher.
Es gibt noch eine weitere Aufgabe, die nur beim nachträglichen Einbau dieses Mechanismus in ein bestehendes System nötig ist. Eine Datenbank, die über manuelle Patches betrieben wurde, kann in einem Zustand landen, in dem „user_version noch bei 0 steht, das tatsächliche Schema sich aber teilweise weiterentwickelt hat“ (der Notfall-Patch aus Kapitel 2 ist genau das). Setzt man sie unverändert auf diese Kette, schlägt ein ALTER TABLE für eine bereits angewendete Änderung mit einem Fehler „Spalte existiert bereits“ fehl. Untersuchen Sie für das erste Release, das dieses System einführt, das tatsächliche Schema als einmaligen Baseline-Schritt (in SQLite lässt sich das Vorhandensein einer Spalte mit PRAGMA table_info prüfen), brennen Sie für jede Datenbank mit bekanntem, bereits angewendetem manuellem Patch die passende Versionsnummer ein, und überlassen Sie erst danach alles Weitere den Vorwärtsmigrationen. Diesen Schritt zu überspringen ist nur eine Option, wenn Sie diesen Mechanismus bereits ab dem allerersten Release eingebaut haben.
SQL Server besitzt kein Äquivalent zu user_version, fügen Sie also pro Migration eine Zeile in eine eigene Tabelle (z. B. schema_version) ein, die Versionsnummer, Anwendungszeitpunkt und die anwendende Anwendungsversion festhält. Da die Historie als Zeilen bestehen bleibt, ist sie robuster, wenn Sie später nachforschen müssen.
4. Ein Werkzeug nutzen oder selbst entwickeln — eine Entscheidungstabelle
Es gibt drei Werkzeugfamilien, die dasselbe erreichen: EF Core Migrations, eine Migrationsbibliothek (etwa DbUp) und die selbst entwickelte Implementierung aus dem vorigen Kapitel.
| Vergleichsachse | EF Core Migrations | Eine Migrationsbibliothek (DbUp usw.) | Selbst entwickelt |
|---|---|---|---|
| Beschreibung einer Änderung | Automatisch aus einer C#-Modelländerung generiert | SQL-Skripte, unverändert als Assets aufbewahrt | SQL-Strings oder C#-Code |
| Lernaufwand | Hoch (erfordert Verständnis von Modell, Werkzeug und Einschränkungen) | Niedrig bis mittel | Minimal (nur ein paar Dutzend Zeilen verstehen) |
| Eignung für SQLite | Mäßig — Spaltenänderungen/-entfernungen werden zu einem Tabellenumbau; idempotente Skripte lassen sich nicht erzeugen5 | Gut — SQL-Server-zentriert, unterstützt aber auch SQLite und andere6 | Ausgezeichnet — direkt unter voller Kenntnis der Einschränkungen geschrieben |
| Wiederverwendung bestehenden Roh-SQLs | Schwer wiederzuverwenden (muss durch Modelldefinitionen ersetzt werden) | Ausgezeichnet — Runbook-SQL lässt sich fast unverändert übernehmen | Ausgezeichnet — wie links |
| Nachverfolgung des Angewendeten | Historientabelle (automatisch) | Journal-Tabelle (automatisch)6 | user_version / eine eigene Tabelle |
| Eignung für die Distribution | In die Anwendung eingebettet, Migrate() beim Start (Vorbehalte unten) |
In die Anwendung eingebettet, beim Start ausgeführt | In die Anwendung eingebettet, beim Start ausgeführt |
Hier die Empfehlung nach Situation.
| Situation | Empfehlung | Begründung |
|---|---|---|
| Bereits EF Core für den Datenzugriff im Einsatz | EF Core Migrations | Vermeidet die doppelte Verwaltung von Modell und Schema; kein Grund, ein weiteres Werkzeug hinzuzufügen |
| Überwiegend Roh-SQL (ADO.NET / Dapper) + SQLite | Selbst entwickelt | Null Abhängigkeiten genügen; man muss sich ohnehin mit den ALTER-TABLE-Grenzen von SQLite auseinandersetzen |
| Überwiegend Roh-SQL + SQL Server, mit großem Bestand an Runbook-SQL | Eine Bibliothek wie DbUp | Bestehendes SQL kann zu Skript-Assets werden, und Sie müssen die Anwendungsnachverfolgung nicht selbst bauen |
| Intensiver Einsatz gespeicherter Prozeduren / Views | Eine Bibliothek wie DbUp | Für Objekte, die ein Modell nicht generieren kann, ist die Verwaltung als SQL-Skripte der geradlinige Weg |
| Kleine Datenbank, geringe Änderungshäufigkeit | Selbst entwickelt | Hält den Wartungsaufwand des Mechanismus minimal |
DbUp ist eine „.NET-Bibliothek, die beim Ausrollen von Änderungen an einer SQL-Server-Datenbank hilft“; sie zeichnet ausgeführte Skripte in einer Journal-Tabelle auf und führt nur die noch nicht ausgeführten Skripte aus. Sie unterstützt außerdem SQLite, PostgreSQL, MySQL und mehr.6 Als Migrationspfad, um „Runbook-SQL in eine automatische, nachverfolgte Anwendung“ zu verwandeln, ist das ungefähr so kurz, wie es nur geht.
4.1 Vorbehalte für den Einsatz von EF Core Migrations in einer verteilten Anwendung
Während der Entwicklung werden EF-Core-Migrationen mit dotnet ef database update angewendet, doch der PC eines Kunden hat weder das SDK noch den Quellcode. Der realistische Weg zur Anwendung ist context.Database.Migrate() beim Start der Anwendung.
Was Sie hier wissen müssen: Die Dokumentation von Microsoft warnt ausdrücklich davor, Migrationen beim Start als Mittel zur Verwaltung einer Produktionsdatenbank anzuwenden. Die fünf genannten Gründe sind: (1) Fehlschläge oder Beschädigungen durch gleichzeitige Anwendung von Migrationen durch mehrere Instanzen (vor EF Core 9), (2) andere Anwendungen, die während der Anwendung von Migrationen auf die Datenbank zugreifen, können ernste Probleme verursachen, (3) die Anwendung benötigt erhöhte Berechtigungen, um das Schema zu ändern, (4) es gibt kaum einen Rollback-Mechanismus, und (5) Sie können das auszuführende SQL nicht vorab überprüfen oder korrigieren — die Empfehlung lautet stattdessen, SQL-Skripte zu erzeugen und sie als Teil des Bereitstellungsprozesses anzuwenden.7
Doch diese Empfehlung setzt ein Server-System mit „einer Datenbank und einem tatsächlichen Bereitstellungsprozess“ voraus. Für eine Desktop-Anwendung mit einer lokalen Datenbank auf jedem Kunden-PC ist es genau das Problem aus Kapitel 2, Skripte physisch zu jedem Standort zu tragen, um sie anzuwenden — daher wird Migrate() beim Start zur faktischen Standardantwort. Adressieren Sie stattdessen die verbleibenden Bedenken.
- Nebenläufigkeit: Ab EF Core 9 erwirbt
Migrate()automatisch eine Sperre, um zu verhindern, dass mehrere Prozesse gleichzeitig Migrationen ausführen.7 Bei früheren Versionen serialisieren Sie es selbst, wie in Kapitel 6 beschrieben. Beachten Sie, dass diese Sperre nur Migrationsläufe gegeneinander serialisiert — sie verhindert nicht, dass eine alte Anwendungsversion während einer laufenden Migration normal liest und schreibt. Planen Sie bei einer gemeinsam genutzten Datenbank, dies mit der Mindestversionsprüfung (Abschnitt 5.2) oder einem Wartungsfenster zu kombinieren. - Das SQL vorab überprüfen: Überprüfen Sie stets die erzeugte Migration und proben Sie sie vor dem Release gegen eine Datenbank mit produktionsähnlichem Datenumfang (Abschnitt 6.3).
- Nicht mit
EnsureCreated()mischen: Es baut das Schema ohne Migrationshistorie auf, undMigrate()schlägt danach später fehl. Standardisieren Sie von Anfang an aufMigrate().7
Beim SQLite-Provider läuft eine Migration, die den Typ einer Spalte ändert oder eine Spalte entfernt, als vollständiger Tabellenumbau — eine neue Tabelle anlegen, die Daten kopieren, die alte Tabelle löschen, umbenennen —, und idempotente Skripte lassen sich ebenfalls nicht erzeugen.5 Ob EF Core überhaupt eingesetzt werden sollte, wird in Kapitel 8 von SQLite in Business-Anwendungen mit C# einsetzen behandelt.
5. Migrationen schreiben, die nichts kaputtmachen
Das leitende Prinzip für jede einzelne Migration lautet: niemals eine rückwärtsinkompatible Änderung in einem einzigen Release vornehmen.
5.1 Breaking Changes mit Expand-Contract behandeln (ein zweistufiges Release)
Das Hinzufügen einer Spalte ist sicher, doch das Entfernen, Umbenennen oder Ändern des Typs einer Spalte macht „alles kaputt, was die alte Form vorausgesetzt hat“. Selbst bei einer lokalen SQLite-Datenbank in einer Eins-zu-eins-Beziehung mit der Anwendung haben Sie meist mindestens eines von: (a) die Möglichkeit, die Anwendung bei Problemen auf eine ältere Version zurückzusetzen, (b) ein anderes Werkzeug, das direkt auf die Datenbank zugreift (ein Reporting-Tool, ein CSV-Export, eine Access-Anbindung), oder (c) eine SQL-Server-Konfiguration, bei der alte und neue Clients gleichzeitig verbunden sind. Breaking Changes werden also in zwei Stufen aufgeteilt — zunächst Expand, dann Contract.
| Änderung | Was passiert, wenn Sie es in einem Schritt tun | Ein sicherer zweistufiger Ansatz |
|---|---|---|
| Eine Spalte umbenennen | Alte Anwendungen/Berichte, die auf den alten Namen verweisen, sterben sofort | expand: die neue Spalte hinzufügen und Werte von der alten kopieren. Die neue Anwendung schreibt in beide, und Lesevorgänge behandeln weiterhin die alte Spalte als maßgeblich (denn auf einer gemeinsam genutzten Datenbank, auf der alte und neue Anwendung gleichzeitig laufen, schreibt die alte Anwendung nur in die alte Spalte — ein datenbankseitiger Trigger kann sie ebenfalls synchron halten) → contract: sobald alte Anwendungen ausgesperrt sind, eine abschließende Kopie der neuesten Werte der alten Spalte in die neue Spalte durchführen, Lesevorgänge auf die neue Spalte umstellen, dann die alte löschen (ein Umstellen vor dem Aussperren alter Anwendungen würde jede Aktualisierung verlieren, die die alte Anwendung nur in die alte Spalte geschrieben hat) |
| Eine Spalte entfernen | INSERT/SELECT der alten Anwendung schlägt mit einem Fehler fehl | expand: die Anwendung verweist einfach nicht mehr darauf (die Spalte bleibt) → contract: mehrere Releases später löschen |
| Typ/Bedeutung ändern (z. B. Ortszeit zu UTC) | Alte und neue Werte koexistieren in einer Spalte und es bricht still zusammen | expand: eine neue Spalte hinzufügen und mit umgewandelten Werten befüllen. Die Koexistenzphase wie bei einer Umbenennung behandeln (die neue Anwendung schreibt in beide, und Lesevorgänge behandeln die alte Spalte als maßgeblich) → contract: sobald alte Anwendungen ausgesperrt sind, eine abschließende Umwandlung aus der alten Spalte durchführen, Lesevorgänge umstellen, dann die alte Spalte löschen |
| Eine NOT-NULL-Bedingung hinzufügen | Die Anwendung schlägt gegen bestehende NULL-Zeilen fehl; ein NULL-Schreibvorgang einer alten Anwendung stirbt an der Bedingung ebenfalls sofort | expand: einen Standardwert bereitstellen und jeden Client auf eine Version aktualisieren, die Nicht-NULL schreibt → contract: sobald alte Anwendungen ausgesperrt sind, verbleibende NULLs per UPDATE auffüllen, dann die Bedingung hinzufügen |
Es ist am sichersten, die Contract-(Entfernungs-)Seite des Releases erst auszuliefern, sobald die Mindestversionsprüfung (nächster Abschnitt) vorhanden ist und alte Anwendungen tatsächlich aussperren kann.
SQLite hat hier eigene, spezifische Grenzen: ALTER TABLE unterstützt nur das Umbenennen der Tabelle, das Umbenennen einer Spalte, das Hinzufügen einer Spalte und das Entfernen einer Spalte, und selbst das Entfernen einer Spalte kommt mit einer langen Liste von Einschränkungen — Sie können keine Spalte entfernen, die Teil eines PRIMARY-KEY- oder UNIQUE-Constraints ist, oder eine, auf die ein Index, ein CHECK-Constraint, ein Fremdschlüssel oder eine View verweist. Jede andere Änderung durchläuft das von der offiziellen Dokumentation dargelegte Verfahren: eine neue Tabelle innerhalb einer Transaktion anlegen, die Daten mit INSERT INTO new_X SELECT ... FROM X verschieben, dann die alte Tabelle löschen und umbenennen.3 Bei einer großen Tabelle wird daraus eine vollständige Kopie, planen Sie also sowohl die Anwendungszeit als auch den benötigten freien Speicherplatz ein.
5.2 Schutz vor Downgrades — eine Mindestversionsprüfung
In einem reinen Vorwärtsmigrations-Design schreiben Sie keine Rückwärtsmigrationsskripte (sie werden bei Kunden vor Ort nie ausgeführt, und ungetesteter Code ist nur eine Verbindlichkeit). Was Sie stattdessen benötigen, ist ein Mechanismus, der stoppt, wenn jemals eine ältere Anwendungsversion eine neuere Datenbank öffnet. Genau das tut der obere Teil des Codes aus Kapitel 3: Ist user_version größer als die höchste Nummer, die die Anwendung kennt, wirft sie eine Ausnahme und bricht den Start ab.
Verpflichten Sie sich von vornherein auf „keine Breaking Changes in irgendeinem Release, das möglicherweise zurückgerollt werden muss — nur Expand“, wird es sicher für eine alte Anwendung, eine neuere Datenbank zu lesen, und Sie können die Prüfung auf etwas wie „warnen und schreibgeschützt starten“ lockern. Welche Variante Sie wählen, hängt davon ab, wie tolerant das Geschäft gegenüber Ausfallzeiten ist.
5.3 Automatische Backups vor der Anwendung
Eine Migration ist eine Operation an „Produktionsdaten, die auf dem PC einer anderen Person liegen“. Automatisieren Sie die Praxis, vor ihrer Ausführung ein Backup zu erstellen. In SQLite ist VACUUM INTO das ideale Werkzeug — eine einzelne Anweisung erzeugt einen konsistenten Snapshot in einer separaten Datei, selbst aus einer aktiven Datenbank heraus.2
// Nur wenn tatsächlich eine Migration nötig ist, unmittelbar davor ein Backup dieser einen Generation erstellen
if (GetUserVersion(conn) < latest)
{
Directory.CreateDirectory(backupDir);
var backupPath = Path.Combine(backupDir,
$"app_schema_v{GetUserVersion(conn)}_{DateTime.Now:yyyyMMdd_HHmmss}.db");
// Unter einem temporären Namen erstellen und erst nach Erfolg umbenennen, damit eine
// durch Stromausfall oder abgebrochenen Prozess mitten im Schreiben zurückgelassene
// unvollständige Datei niemals wie ein „fertiges Backup“ aussieht
var tempPath = backupPath + ".tmp";
using var cmd = conn.CreateCommand();
cmd.CommandText = "VACUUM INTO $path";
cmd.Parameters.AddWithValue("$path", tempPath);
cmd.ExecuteNonQuery(); // VACUUM außerhalb einer Transaktion ausführen
File.Move(tempPath, backupPath);
// Ist beim Start noch eine *.tmp-Datei vorhanden, ist das die Spur eines vorherigen Fehlschlags – löschen
}
Die Schemaversion in den Dateinamen aufzunehmen, macht bei der Wiederherstellung auf einen Blick klar, „wie weit zurück“ Sie wiederherstellen. Mehr zu Backups – einschließlich, warum eine einfache Dateikopie einer aktiven Datenbank ein Nährboden für Beschädigungen ist – finden Sie in Kapitel 7 von SQLite in Business-Anwendungen mit C# einsetzen. Bei SQL Server ist das Äquivalent, vor der Anwendung BACKUP DATABASE auszuführen; der zugrunde liegende Gedanke ist derselbe.
6. Betriebliche Fallstricke
6.1 Fehlschläge mittendrin und Transaktionen — die Unterschiede zwischen Datenbank-Engines kennen
Der Code aus Kapitel 3 schließt eine Migration in eine Transaktion ein und bezieht die Aktualisierung von user_version in dieselbe Transaktion ein. Das funktioniert, weil SQLite DDL (CREATE TABLE, ALTER TABLE und Ähnliches) innerhalb einer Transaktion ausführen und bei einem Fehlschlag zurückrollen kann. Das offizielle Verfahren zum Tabellenumbau selbst ist als „eine Transaktion beginnen, CREATE/INSERT/DROP/RENAME durchführen und committen“ strukturiert.3 Fällt der Strom mittendrin aus, befindet sich die Datenbank beim nächsten Start im konsistenten Zustand „unmittelbar vor dieser Migration“.
SQL Server kann ebenfalls einen Großteil von DDL innerhalb einer Transaktion ausführen, es gibt jedoch Ausnahmen. ALTER DATABASE beispielsweise lässt sich nicht innerhalb einer expliziten Transaktion verwenden, und auch CREATE FULLTEXT INDEX lässt sich nicht innerhalb einer Benutzertransaktion platzieren.4 Auch EF Core schließt jede Migration automatisch in eine Transaktion ein, wo möglich, stellt dabei jedoch unmissverständlich fest, dass „einige Operationen bei manchen Datenbanken nicht innerhalb einer Transaktion ausgeführt werden können“.8 In der Praxis läuft die Regel auf genau dies hinaus: Mischen Sie niemals eine transaktionsunfähige Operation mit einer gewöhnlichen Schemaänderung in derselben Migration. Wechseln Sie die Datenbank-Engine, prüfen Sie stets, ob DDL an Transaktionen teilnimmt.
Ein klassisches Missgeschick ist „die Versionsaktualisierung landet in einer separaten Transaktion“. Gelingt die Änderung selbst, stirbt der Prozess aber, bevor die Version aktualisiert wird, läuft dieselbe Migration beim nächsten Start erneut und der Start schlägt für immer mit einem Fehler „Tabelle existiert bereits“ fehl. Halten Sie die Versionsaktualisierung innerhalb derselben Transaktion, und das kann grundsätzlich nicht passieren.
6.2 Mehrere gleichzeitig startende Prozesse — den Anwendungsschritt serialisieren
Eine Business-Anwendung ist Software, die „morgens alle gleichzeitig starten“. Mehrere Clients, die auf eine gemeinsam genutzte Datenbank (SQL Server) zeigen, oder mehrere auf demselben PC gestartete Instanzen können dazu führen, dass Migrationen gleichzeitig laufen.
- Ab EF Core 9 erwirbt
Migrate()automatisch eine datenbankweite Sperre, um gleichzeitige Anwendungen zu verhindern (frühere Versionen bieten diesen Schutz nicht). Beachten Sie, dass die Sperre des SQLite-Providers mit einer eigenen Sperrtabelle implementiert ist, und die offizielle Dokumentation vermerkt, dass die Tabelle zurückbleiben kann, wenn der Prozess, der eine Migration anwendet, abstürzt.7 Bleibt der Start jemals bei dieser Sperre hängen, bestätigen Sie, dass tatsächlich kein anderer Prozess gerade eine Migration ausführt, und stellen Sie dann wieder her, indem Sie die zurückgelassene Sperrtabelle (__EFMigrationsLock) löschen. - Bei einer selbst entwickelten Implementierung ist für eine lokale Datenbank die Serialisierung mit einem benannten Mutex der einfache Weg.
// Mit Global\ voranstellen, damit die Serialisierung über den gesamten PC hinweg gilt, auch beim Start
// aus mehreren Anmeldesitzungen über RDP oder Benutzerwechsel (Local\ ist auf eine einzelne Sitzung beschränkt)
using var mutex = new Mutex(false, @"Global\MyApp.SchemaMigration");
try
{
mutex.WaitOne();
}
catch (AbandonedMutexException)
{
// Der vorherige besitzende Prozess ist abgestürzt, ohne jemals Release aufzurufen.
// Obwohl eine Ausnahme geworfen wird, wurde der Besitz selbst dennoch erworben, es ist also
// in Ordnung fortzufahren. Die Möglichkeit, dass die vorherige Anwendung mittendrin endete,
// wird durch die unmittelbar danach folgende erneute Versionsprüfung sowie die
// Transaktionen je Migration abgedeckt
}
try
{
SchemaMigrator.Migrate(conn);
}
finally
{
mutex.ReleaseMutex();
}
Welcher Prozess auch immer gewartet hat, prüft die Version erneut, sobald er die Sperre erhält (der Code aus Kapitel 3 prüft version <= current jedes Mal frisch, bevor er anwendet), sodass dies niemals zu einer doppelten Anwendung wird. Ein Vorbehalt: Ein benanntes Global\-Objekt trägt standardmäßig eine vom erstellenden Benutzer abgeleitete ACL, sodass das Öffnen desselben Mutex aus der Sitzung eines anderen Windows-Kontos UnauthorizedAccessException auslösen kann. Ist die Nutzung über mehrere Konten hinweg zu erwarten, erstellen Sie ihn entweder mit MutexAcl aus System.Threading.AccessControl, das den relevanten Benutzern Synchronisierungs-/Änderungszugriff gewährt, oder verlassen Sie sich stattdessen auf die im Folgenden beschriebene datenbankseitige Sperrung. Da ein Mutex nicht über Maschinengrenzen hinweg reicht, verlassen Sie sich bei einer gemeinsam genutzten Datenbank stattdessen auf die Serialisierung auf Datenbankebene — „die Anwendung serverseitig abschließen, bevor das Update ausgerollt wird“ oder „beim Beginn der Anwendung eine datenbankseitige Sperre erwerben (BEGIN IMMEDIATE bei SQLite, eine Application Lock bei SQL Server)“.
6.3 Generalprobe — testen Sie eine Einmalanwendung ausgehend von „der ältesten Datenbank“
Fehler in Migrationen zeigen sich fast nie auf der Maschine eines Entwicklers, da die Datenbank einer Entwicklungsmaschine stets auf dem neuesten Schema steht und ihre Daten sauber sind. Was tatsächlich kaputtgeht, ist die Datenbank eines Kunden, die alt, groß und voller Daten ist, mit denen niemand geplant hat. Es gibt mindestens drei Dinge, die Sie vor einem Release tun sollten.
- Bewahren Sie eine Datenbankdatei für jede Schemaversion als Test-Fixture auf und automatisieren Sie einen Test, der direkt von jeder Version bis zur neuesten anwendet. Sprungmuster wie „von v1 auf v5“ oder „von v3 auf v5“ entsprechen genau der Realität bei Kunden. Bei SQLite ist es, da es nur darum geht, die Datenbankdatei ins Repository zu legen, vergleichsweise leicht, diese Art von Test zu schreiben.
- Testen Sie mit Daten von vergleichbarem Umfang und Charakter wie die Produktion. Spalten voller NULLs, unerwartete Duplikate und die Umbauzeit bei einer riesigen Tabelle (Abschnitt 5.1) treten nur zutage, wenn die Daten der Realität nahekommen. Proben Sie nach Möglichkeit gegen eine anonymisierte Kundendatenbank.
- Testen Sie den Fehlerpfad. Beenden Sie den Prozess mitten in einer Anwendung, und bestätigen Sie, dass der nächste Start korrekt wiederherstellt — dass er ab der Version, auf die zurückgerollt wurde, erneut anwendet.
7. Zusammenfassung
- „Jeder Kunde hat eine andere Datenbank“ ist kein Versagen der Sorgfalt irgendeiner Person — es ist die strukturelle Folge eines betrieblichen Ablaufs, bei dem ein Mensch ein SQL-Runbook ausführt. Für eine Desktop-Business-Anwendung, deren Datenbanken über Standorte verstreut sind, gibt es nur eine echte Option: die Anwendung ihre eigene Datenbank selbst aktualisieren zu lassen.
- Das Grundgerüst ist eine von der Datenbank selbst gehaltene Schemaversionsnummer (
PRAGMA user_versionbei SQLite1) plus das Anwenden nummerierter, ausschließlich vorwärts gerichteter Migrationen beim Start. In C# genügen ein paar Dutzend Zeilen selbst entwickelten Codes, um das zum Laufen zu bringen. - Die drei Werkzeugfamilien sind EF Core Migrations, eine Bibliothek wie DbUp und eine selbst entwickelte Implementierung. Wählen Sie danach, ob Sie bereits EF Core einsetzen und wie viel Roh-SQL Sie bereits als Bestand haben (die Entscheidungstabelle in Kapitel 4). EF Cores
Migrate()beim Start kommt mit offiziell dokumentierten Vorbehalten,7 verwenden Sie es also zusammen mit Nebenläufigkeitsbehandlung und Generalprobe. - Behandeln Sie Breaking Changes als zweistufiges Expand-Contract-Release und stoppen Sie das Missgeschick, dass eine alte Anwendung eine neue Datenbank öffnet, mit einer Mindestversionsprüfung. Folgen Sie der offiziellen Dokumentation für die ALTER-TABLE-Grenzen und das Umbauverfahren von SQLite.3
- Das Prinzip lautet eine Migration entspricht einer Transaktion, wobei die Versionsaktualisierung in dieselbe Transaktion gehört. SQL Server hat DDL, das nicht an einer Transaktion teilnehmen kann,4 isolieren Sie diese Operationen also. Erst wenn Sie ein
VACUUM INTO-Backup vor der Anwendung hinzugefügt2 und eine Einmalanwendung ausgehend von der ältesten Version geprobt haben, wird eine Migration tatsächlich zu etwas, das Sie an einen Kunden ausliefern können.
Klingt runbook-gesteuertes ALTER vertraut, versuchen Sie, in Ihrem nächsten Release nur „das Aufzeichnen einer Versionsnummer“ und „die Anwendung beim Start“ einzuführen. Sobald diese Grundlage steht, können Sie danach nach und nach die zweistufigen Releases und Backups hinzufügen.
Verwandte Artikel
- SQLite in Business-Anwendungen mit C# einsetzen — WAL-Modus, Sperren, Schutz vor Beschädigung und wann sich EF Core lohnt
- Wo eine Windows-Anwendung ihre Daten speichern sollte — eine Entscheidungstabelle für SQLite / JSON / Registrierung / Access
- Nicht nur appsettings.json — Praxis der Konfigurationsverwaltung für Windows-Business-Anwendungen
- Datum, Uhrzeit und Zeitzonen in Business-Anwendungen — von DateTime-Fallstricken bis zum UTC-Speicherprinzip und Testdesign
Verwandte Beratungsleistungen
Komura Software LLC übernimmt Datenbankdesign und die Einführung von Migrations-Frameworks für Business-Anwendungen, die bei jedem Kunden vor Ort installiert werden, die Untersuchung und Normalisierung von Schemata, die unter runbook-gesteuertem Betrieb auseinandergedriftet sind, sowie den Entwurf der Update-Distribution sowohl für EF-Core- als auch für Roh-SQL-Konfigurationen.
- Windows-App-Entwicklung
- Wartung und Modernisierung bestehender Windows-Software
- Technische Beratung und Design-Review
- Kontakt
Referenzlinks
-
SQLite, Pragma statements supported by SQLite - user_version. Dazu, dass user_version eine im Datenbankkopf gespeicherte Ganzzahl ist (bei Offset 60), die zur freien Nutzung durch die Anwendung reserviert ist, wobei SQLite selbst den Wert nie anfasst. ↩ ↩2 ↩3
-
SQLite, VACUUM. Dazu, dass VACUUM INTO die ursprüngliche Datenbank unverändert lässt, während es einen konsistenten Snapshot einer aktiven Datenbank in einer separaten Datei erstellt, nutzbar als Alternative zur Backup-API. ↩ ↩2 ↩3
-
SQLite, ALTER TABLE. Dazu, dass das ALTER TABLE von SQLite auf das Umbenennen der Tabelle, das Umbenennen einer Spalte, das Hinzufügen einer Spalte und das Entfernen einer Spalte beschränkt ist, zu den zahlreichen Einschränkungen beim Entfernen einer Spalte sowie dazu, dass andere Schemaänderungen über das offizielle Verfahren durchgeführt werden, eine neue Tabelle innerhalb einer Transaktion anzulegen, die Daten zu kopieren, die alte Tabelle zu löschen und umzubenennen. ↩ ↩2 ↩3 ↩4
-
Microsoft Learn, ALTER DATABASE (Transact-SQL) und CREATE FULLTEXT INDEX (Transact-SQL). Dazu, dass ALTER DATABASE im Autocommit-Modus ausgeführt werden muss und nicht innerhalb einer expliziten oder impliziten Transaktion zulässig ist, sowie dazu, dass CREATE FULLTEXT INDEX innerhalb einer Benutzertransaktion nicht erlaubt ist. ↩ ↩2 ↩3
-
Microsoft Learn, SQLite EF Core Database Provider Limitations. Dazu, dass viele Migrationsoperationen unter dem SQLite-Provider als Tabellenumbau ausgeführt werden, sowie dazu, dass sich idempotente Skripte nicht erzeugen lassen. ↩ ↩2
-
DbUp, DbUp Documentation und Supported Databases. Dazu, dass es sich um eine .NET-Bibliothek handelt, die beim Ausrollen von Änderungen an einer SQL-Server-Datenbank hilft, ausgeführte SQL-Skripte aufzeichnet und nur die noch nicht ausgeführten ausführt, sowie SQLite, PostgreSQL, MySQL und mehr unterstützt. ↩ ↩2 ↩3
-
Microsoft Learn, Applying Migrations (EF Core). Zu den fünf Gründen, weshalb die Anwendung von Migrationen zur Laufzeit (beim Start) als für die Verwaltung einer Produktionsdatenbank ungeeignet gilt, dazu, dass stattdessen die Erzeugung von SQL-Skripten der empfohlene Ansatz ist, dazu, EnsureCreated() niemals mit Migrate() zu kombinieren, dazu, dass Migrate() ab EF Core 9 automatisch eine datenbankweite Sperre erwirbt, sowie dazu, dass die Sperre des SQLite-Providers als eine Tabelle implementiert ist, die nach einem anormalen Abbruch zurückbleiben kann. ↩ ↩2 ↩3 ↩4 ↩5
-
Microsoft Learn, Managing Migrations (EF Core). Dazu, dass EF Core beim Anwenden jede Migration automatisch in eine Transaktion einschließt, wo möglich, sowie dazu, dass manche Operationen je nach Datenbank nicht innerhalb einer Transaktion ausführbar sind. ↩
Verwandte Artikel
Aktuelle Artikel mit denselben Schlagwörtern führen zu verwandten Themen weiter.
Code-Design für Geschäftssysteme — Produkt- und Kundencodes festlegen, und Prüfziffern
Ein praxisnaher Leitfaden zur Festlegung des Codesystems eines Geschäftssystems, einschließlich Produkt- und Kundencodes. Behandelt eine ...
Wie lange laufen VB6-Anwendungen noch? — Support-Status der Laufzeitumgebung und ein praxisnaher Weg zur .NET-Migration
Wie lange laufen VB6-Anwendungen noch? Dieser Artikel ordnet die Asymmetrie zwischen der Support-Richtlinie für die VB6-Laufzeitumgebung ...
CI/CD für WinForms-/WPF-Anwendungen in der Praxis ── Vom Build über die Signierung bis zur Distribution mit GitHub Actions automatisieren
Ein praktischer Leitfaden zur Einrichtung von CI/CD für WinForms-/WPF-Anwendungen mit GitHub Actions. Behandelt eine minimale YAML für Bu...
Eine Legacy-Business-Anwendung ohne Tests sicher verändern ── Charakterisierungstests und Refactoring in der Praxis
Um eine Business-Anwendung ohne Tests sicher zu verändern, erklärt dieser Artikel anhand von C#-Beispielen das Vorgehen bei einem Charakt...
Incident Response endet nicht mit der Wiederherstellung ── Eine Postmortem-Vorlage (Rückfallprävention) für kleine Entwicklungsteams
Einen Vorfall als erledigt zu betrachten, sobald er behoben und man sich entschuldigt hat, garantiert, dass er sich wiederholt. Dieser Ar...
Verwandte Themen
Diese Seiten ordnen den Artikel in einen größeren Leistungs- und Entscheidungskontext ein.
Technische Windows-Themen
Portal zu Windows-Entwicklung, Fehleranalyse und der Nutzung bestehender Assets.
Leistungen zu diesem Thema
Dieser Artikel ist direkt mit den folgenden Leistungen verbunden.
Windows-App-Entwicklung
Geschäftsanwendungen, Geräteintegration und Kommunikationstools von den Anforderungen bis zur Umsetzung.
Wartung und Modernisierung von Windows-Software
Funktionserweiterungen, Wartung und schrittweise Modernisierung bestehender Windows-Software.
Häufige Fragen
Fragen, die in Beratungen zu diesem Artikelthema häufig gestellt werden.
- Wie sollten Schemaänderungen an der Datenbank einer Business-Anwendung verwaltet werden?
- Statt eines betrieblichen Ablaufs, bei dem ein Mensch ein SQL-Runbook ausführt, sollten Sie nummerierte Migrationen (Code für Schemaänderungen) direkt in die Anwendung einbetten und sie beim Start automatisch anwenden lassen. Lassen Sie die Datenbank selbst ihre aktuelle Schemaversion aufzeichnen (PRAGMA user_version bei SQLite, eine eigene Tabelle bei SQL Server), und lassen Sie die Anwendung ausschließlich die noch nicht angewendeten Versionsnummern der Reihe nach und innerhalb von Transaktionen anwenden. In dieser Form wendet selbst ein Sprung-Update von v1.2 auf v1.5 jede dazwischenliegende Schemaänderung an, und der Zustand „jeder Kunde hat eine andere Datenbankform“ entsteht strukturell gar nicht erst.
- Ist es in Ordnung, EF Cores Migrate() beim Start der Anwendung aufzurufen?
- Das ist eine vertretbare Wahl, mit Bedingungen. Die Dokumentation von Microsoft warnt davor, Migrationen in der Produktion beim Start anzuwenden – als Gründe werden unter anderem die gleichzeitige Anwendung durch mehrere Instanzen, das Einräumen von Schemaänderungsrechten für die Anwendung und die fehlende Möglichkeit genannt, das SQL vorab zu überprüfen – und empfiehlt stattdessen, für Server-Anwendungen SQL-Skripte zu erzeugen. Für eine Desktop-Business-Anwendung mit einer lokalen Datenbank auf jedem Client-PC ist die Ausführung von Skripten vor Ort schlicht kein praktikabler Ablauf, sodass Migrate() beim Start zur faktischen Standardantwort wird. Auch dann sollten Sie es stets mit einem Schutz gegen gleichzeitige Starts (der automatischen Sperre von EF Core 9 oder einem selbst entwickelten Mutex) und einem vor der Anwendung erstellten Backup kombinieren.
- Was passiert mit der Datenbank, wenn eine Migration mittendrin fehlschlägt?
- Wenn Sie eine einzelne Migration in eine einzelne Transaktion einschließen und die Aktualisierung der Versionsnummer in dieselbe Transaktion einbeziehen, führt ein Fehlschlag zu einem Rollback in den Zustand unmittelbar vor Beginn dieser Migration, und es bleibt kein halb fertiges Schema zurück. SQLite kann DDL wie CREATE TABLE und ALTER TABLE ebenfalls innerhalb einer Transaktion ausführen, und das offizielle Verfahren zum Tabellenumbau selbst ist unter der Annahme einer Transaktion geschrieben. SQL Server kann ebenfalls einen Großteil von DDL innerhalb einer Transaktion ausführen, es gibt jedoch Ausnahmen – darunter ALTER DATABASE und Anweisungen im Zusammenhang mit Volltextindizes –, isolieren Sie diese Ausnahmeoperationen also in eigene Migrationen. Und mit einem vor der Anwendung erstellten automatischen Backup lässt sich selbst der schlimmste Fall durch einen einfachen Dateitausch wiederherstellen.
- Wenn sich die Schemata über Kundendatenbanken hinweg bereits auseinanderentwickelt haben, wie bringt man sie wieder in Einklang?
- Legen Sie zunächst ein einziges „korrektes, beabsichtigtes Schema“ fest und erheben Sie für jede Kundendatenbank, wie weit sie sich davon entfernt hat. Schreiben Sie dann für Datenbanken ohne Versionsnummer eine Baseline-Migration, die jedes tatsächlich im Feld vorkommende Muster erkennt und normalisiert, und tragen Sie die Versionsnummer erst ein, sobald diese Migration abgeschlossen ist. In SQLite lässt sich mechanisch über sqlite_master oder PRAGMA table_info erkennen, ob eine Spalte existiert, und die Unterschiede lassen sich mit defensivem SQL nach dem Muster „Spalte hinzufügen, falls noch nicht vorhanden“ auffangen. Sobald jede nachfolgende Änderung auf nummerierten Migrationen aufsetzt, wiederholt sich das Auseinanderdriften nicht mehr.
Autorenprofil
Profilseite des Artikelautors.
Go Komura
Geschäftsführer von KomuraSoft LLC
Spezialisiert auf Windows-Softwareentwicklung, technische Beratung und Fehleranalyse, insbesondere bei bestehenden Systemen und schwer reproduzierbaren Störungen.