Sichere, idempotente Datenbankmigrationen schreiben
Eine Migration, die in Staging erfolgreich lief und dann in Produktion auf halbem Weg scheitert, lässt dich an der schlechtestmöglichen Stelle zurück: ein Schema, das weder die alte noch die neue Version ist, ein Deployment, das nicht vorwärts rollen kann, und ein Rollback, der womöglich nicht rückgängig macht, was bereits passiert ist. Migrationen so zu schreiben, dass ein erneutes Ausführen immer sicher ist – idempotent im strengen Sinn –, macht aus „die Migration ist teilweise fehlgeschlagen" einen Nicht-Vorfall.
Was tatsächlich schiefgeht
Ein typisches Migrationsskript:
ALTER TABLE users ADD COLUMN last_login_at TIMESTAMP;
CREATE INDEX idx_users_last_login ON users (last_login_at);
UPDATE users SET last_login_at = created_at WHERE last_login_at IS NULL;
Läuft die Index-Erstellung bei einer großen Tabelle in ein Timeout (oder der Deploy-Prozess wird beendet, oder die Verbindung bricht ab), bleibt man mit hinzugefügter Spalte, fehlendem Index und nicht ausgeführtem Backfill zurück. Das gesamte Skript erneut von oben auszuführen scheitert jetzt sofort in der ersten Zeile – column "last_login_at" already exists –, obwohl man eigentlich will, dass das Skript dort weitermacht, wo es aufgehört hat.
Jede Anweisung wiederholbar machen
Die meisten Schemaänderungs-Anweisungen haben eine IF EXISTS-/IF NOT EXISTS-Form. Verwende sie überall.
ALTER TABLE users ADD COLUMN IF NOT EXISTS last_login_at TIMESTAMP;
CREATE INDEX IF NOT EXISTS idx_users_last_login ON users (last_login_at);
UPDATE users SET last_login_at = created_at WHERE last_login_at IS NULL;
Jetzt kann das gesamte Skript nach jedem Teilausfall erneut ausgeführt werden. ADD COLUMN und CREATE INDEX werden zu No-Ops, wenn sie schon angewendet wurden, und das UPDATE ist ohnehin natürlicherweise idempotent – WHERE last_login_at IS NULL berührt nur Zeilen, die es noch brauchen, ein zweites Ausführen macht also keine zusätzliche Arbeit.
Dieser letzte Punkt lässt sich verallgemeinern: Ein UPDATE, das durch eine Bedingung geschützt ist, die „braucht diese Änderung noch" beschreibt statt „wende diese Änderung an", ist per Konstruktion idempotent. Vergleiche:
-- NOT idempotent: running this twice doubles every balance
UPDATE accounts SET balance = balance * 1.05;
-- idempotent: running this any number of times has the same effect
UPDATE accounts SET balance = base_balance * 1.05 WHERE balance != base_balance * 1.05;
Das erste beschreibt eine Aktion, das zweite einen Zielzustand. Bevorzuge die Beschreibung des Zielzustands, wann immer die Migration mehr tut als eine reine Schemaänderung.
Löschen braucht dieselbe Behandlung
DROP TABLE IF EXISTS legacy_sessions;
ALTER TABLE orders DROP COLUMN IF EXISTS legacy_status;
Symmetrisch zum additiven Fall – ohne IF EXISTS scheitert das erneute Ausführen einer Migration, die das Löschen schon erfolgreich erledigt hat, laut an einer Anweisung, die ihre Aufgabe faktisch bereits erfüllt hat.
Backfills müssen fortsetzbar sein, nicht nur idempotent
Ein einmaliges UPDATE über eine riesige Tabelle ist eine Sperre, die die gesamte Dauer gehalten wird, und eine einzige Alles-oder-nichts-Arbeitseinheit. Teile es in Batches, damit ein Fehler auf halbem Weg Minuten an Fortschritt kostet, nicht den ganzen Job:
-- repeat until this returns 0 rows affected
UPDATE users
SET last_login_at = created_at
WHERE id IN (
SELECT id FROM users
WHERE last_login_at IS NULL
LIMIT 1000
);
async function backfillLastLogin(db) {
let updated;
do {
const result = await db.query(`
UPDATE users SET last_login_at = created_at
WHERE id IN (SELECT id FROM users WHERE last_login_at IS NULL LIMIT 1000)
`);
updated = result.rowCount;
} while (updated > 0);
}
Jeder Batch ist eine eigene kleine Transaktion. Stirbt der Prozess nach Batch 40, macht ein Neustart einfach dort weiter, wo last_login_at IS NULL noch Zeilen hat – keine Buchhaltungstabelle zur Fortschrittsverfolgung nötig, denn die WHERE-Klausel ist der Fortschrittsanzeiger.
Den sicheren Teil vom destruktiven trennen
ADD COLUMN ist gefahrlos wiederholbar. DROP COLUMN ist nicht umkehrbar, sobald die Daten weg sind. Eine Schemaänderung wie „benenne status in order_status um" auf separate Deployments aufzuteilen hält jede einzelne Migration für sich idempotent und sicher:
- Die neue Spalte hinzufügen, sie aus der alten befüllen, Code deployen, der in beide schreibt.
- Code deployen, der nur noch aus der neuen Spalte liest.
- In einer späteren, separaten Migration – nachdem bestätigt wurde, dass Schritt 2 eine Weile sauber läuft – die alte Spalte löschen.
Scheitert die Migration von Schritt 3 auf halbem Weg, hat man nichts Unersetzliches verloren; die alte Spalte ist noch da, um es erneut zu versuchen. Alle drei in eine Migration zu bündeln bedeutet, dass ein Teilausfall dich mit Daten zurücklassen kann, die in eine Spalte geschrieben wurden, die dein zurückgerollter Anwendungscode nicht mehr liest.
Migrationswerkzeuge erledigen schon einen Teil – wisse, welchen
Frameworks wie die ActiveRecord-Migrationen von Rails oder Djangos Migrationssystem halten in einer Metadatentabelle fest, welche Migrationen bereits gelaufen sind, sodass das erneute Ausführen des Migrationsbefehls eine vollständig abgeschlossene Migration nicht erneut ausführt. Wovor sie dich im Allgemeinen nicht schützen können, ist eine einzelne Migrationsdatei, die mitten in ihrem eigenen Körper scheitert – das bleibt an dir, defensiv zu schreiben, mit den oben beschriebenen IF EXISTS-/Zielzustand-Mustern, innerhalb jeder Migration.
Fazit
Schreibe jede Migrationsanweisung als „stelle sicher, dass das wahr ist" statt als „führe diese Aktion aus", und eine Migration, die auf halbem Weg scheitert, wird zu etwas, das man erneut ausführt, nicht zu etwas, wegen dem man nachts um 2 Uhr jemanden anruft. Der Preis sind ein paar zusätzliche IF EXISTS-Klauseln und WHERE-Bedingungen; der Gewinn ist, dass ein Teilausfall aufhört, ein Sonderfall zu sein, über den man unter Druck nachdenken muss.