Zruck zum Blog, du Zniachtl
Feb. 2026·3 Min. zum LesnVo da KI gmocht

Sichere, idempotente Datenbank-Migrationen schreibn

#sql#databases#devops

A Migration, de im Staging erfolgreich glaufen is und dann in Produktion auf halbm Weg scheitert, lasst di in da schlimmstmöglichen Lage zruck: a Schema, des weder die oide noch die neiche Version is, a Deployment, des ned vorwärts kann, und a Rollback, des vielleicht ned rückgängig mocht, wos scho passiert is. Migrationen so zum schreibn, dass erneutes Ausführn imma sicher is - idempotent im strengen Sinn -, macht aus "die Migration is teilweise gscheitert" von an Vorfall a Nicht-Ereignis.

Wos tatsächlich schiefgeht

A 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;

Wenn die Index-Erstellung auf ana großen Tabelle in a Timeout läuft (oda der Deploy-Prozess abgschossn wird, oda die Verbindung abbricht), bleibst mit da hinzugfügten Spoitn, aber fehlendem Index und nicht ausgführtem Backfill zruck. Das gesamte Skript von vorn nochamoi zum laufn lassn scheitert jetzt sofort in da ersten Zeiln - column "last_login_at" already exists -, obwohl du eigentlich brauchst, dass das Skript dort weitermacht, wo's aufghört hot.

Jedes Statement wiederholsicher machn

De meisten Schema-Änderungs-Statements hom a IF EXISTS / IF NOT EXISTS-Form. Nutz sie überoi.

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 ganze Skript nach jedem Teilfehler nochamoi laufn. De ADD COLUMN- und CREATE INDEX-Aufrufe wern zu No-Ops, wenn sie scho angewendet sand, und das UPDATE is ohnehin scho natürlich idempotent - WHERE last_login_at IS NULL fasst nur Zeiln an, de die Änderung no brauchn, drum macht zwoamoliges Ausführn beim zwoatn Moi koa zusätzliche Orbeit.

Der letzte Punkt gilt allgemein: A UPDATE, des durch a Bedingung abgsichert is, de "braucht die Änderung no" beschreibt statt "wende die Änderung an", is von Konstruktion her idempotent. Vergleich:

-- 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 a Aktion; das zwoate beschreibt an Zielzustand. Beschreib bevorzugt den Zielzustand, wann immer die Migration mehr mocht ois a reine Schema-Änderung.

Löschen braucht die söbe Behandlung

DROP TABLE IF EXISTS legacy_sessions;
ALTER TABLE orders DROP COLUMN IF EXISTS legacy_status;

Symmetrisch zum additiven Foi - ohne IF EXISTS scheitert das erneute Ausführn ana Migration, die das Löschn scho erfolgreich erledigt hot, laut an an Statement, das faktisch scho seine Pflicht tan hot.

Backfills miassn wiederaufnehmbar sei, ned nur idempotent

A einmaliges UPDATE über a riesige Tabelle is a Lock, der die gesamte Dauer gholten wird, und a einzige Alles-oder-nix-Arbeitseinheit. Stückel's in Batches, damit a Fehler auf halbm Weg Minuten an Fortschritt kost, ned den ganzn 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 is seine eigene klane Transaktion. Wenn der Prozess nach Batch 40 stirbt, macht a Neustart einfach dort weiter, wo last_login_at IS NULL no Zeiln hot - ka Buchhaltungstabelle zum Fortschritt-Tracken nötig, weil die WHERE-Klausel der Fortschrittsanzeiger is.

Den sicheren vom destruktiven Teil trennen

ADD COLUMN is sicher zum Wiederholn. DROP COLUMN is nimma umkehrbar, sobois die Daten weg sand. A Schema-Änderung wia "benenn status in order_status um" in getrennte Deploys aufzuteiln hoit jede einzelne Migration für sich idempotent und sicher:

  1. Die neiche Spoitn hinzufügen, aus da oidn befüllen, Code deployn, der in beide schreibt.
  2. Code deployn, der nur mehr von da neichn Spoitn liest.
  3. In ana späteren, separaten Migration - nachdem bestätigt is, dass Schritt 2 a Weile sauber lauft - die oide Spoitn löschn.

Wenn die Migration von Schritt 3 auf halbm Weg scheitert, host nix Unersetzliches valorn; die oide Spoitn is no do, um's nochamoi zu versuchen. Olle drei in oane Migration zu packen heißt, dass a Teilfehler dich mit Daten zruckloss, de in a Spoitn gschriebn wurden, de dein zruckgrollter Anwendungscode nimma liest.

Migrations-Tools machen scho an Teil davon - wisse, wöchan

Frameworks wia Rails' ActiveRecord-Migrationen oda Djangos Migrationssystem tracken in ana Metadaten-Tabelle, wöche Migrationen scho glaufen sand, drum führt erneutes Ausführn vom Migrationsbefehl ka vollständig abgschlossene Migration nochamoi aus. Wovor sie dich im Allgemeinen ned schützn kinnan, is a einzelne Migrationsdatei, die mittn in ihrem eigenen Body scheitert - des muasst weiterhin du defensiv schreibn, mit den obigen IF EXISTS- / Zielzustand-Mustern, in jeder Migration.

Fazit

Schreib jedes Migrations-Statement ois "stell sicher, dass des stimmt" statt ois "mach diese Aktion", und a Migration, die auf halbm Weg scheitert, wird zu wos, des du nochamoi laufn lasst, ned zu wos, wegn dem du um 2 in da Nacht wen anrufst. Die Kosten sand a poar zusätzliche IF EXISTS-Klauseln und WHERE-Bedingungen; der Gewinn is, dass Teilfehler aufhören, a Sonderfall zu sei, über den du unter Druck nochdenken muasst.

NeicherGit Rebase vs. Merge: Wann nimmt ma wosElterWia Datenbank-Indizes wirklich funktionieren