Zum Inhalt springen

Datenbankmigrationen richtig gemacht

Schemaänderungen sind die gefährlichsten Deploys — hier sind die Muster für Zero-Downtime-Migrationen, sichere Rollbacks und die Vermeidung von Datenverlust.

3 Min. Lesezeit
Zeitleiste der Datenbankmigrationen, die schrittweise über Deployments hinweg angewendete Schemaänderungen zeigt

Datenbankmigrationen sind der unangenehmste Teil jedes Deployments. Eine fehlgeschlagene Migration kann Tabellen minutenlang sperren, Daten beschädigen oder die Produktion lahmlegen. Anders als Codeänderungen lassen sich Schemaänderungen nur schwer zurückrollen — man kann nicht einfach einen Commit reverten, wenn die Daten bereits transformiert wurden.

Migrationsdateien als Single Source of Truth

Jede Schemaänderung lebt in einer nummerierten, mit Zeitstempel versehenen Migrationsdatei. Der aktuelle Zustand der Datenbank ist die Summe aller angewendeten Migrationen.

tstypescript
// migrations/20200501_001_create_orders_table.ts
import { Kysely, sql } from "kysely";
 
export async function up(db: Kysely<any>) {
  await db.schema
    .createTable("orders")
    .addColumn("id", "uuid", (col) =>
      col.primaryKey().defaultTo(sql`gen_random_uuid()`),
    )
    .addColumn("customer_id", "uuid", (col) =>
      col.notNull().references("customers.id"),
    )
    .addColumn("status", "varchar(50)", (col) =>
      col.notNull().defaultTo("pending"),
    )
    .addColumn("total", "decimal(10,2)", (col) => col.notNull())
    .addColumn("created_at", "timestamptz", (col) =>
      col.notNull().defaultTo(sql`now()`),
    )
    .execute();
 
  await db.schema
    .createIndex("idx_orders_customer_id")
    .on("orders")
    .column("customer_id")
    .execute();
}
 
export async function down(db: Kysely<any>) {
  await db.schema.dropTable("orders").execute();
}

Jede Migration hat ein up (anwenden) und ein down (zurückrollen). In der Praxis können down-Migrationen bei destruktiven Änderungen (Spalten löschen, Typen ändern) die Daten oft nicht wiederherstellen. Sie zu schreiben lohnt sich trotzdem für strukturelle Rollbacks.

Das Expand-Contract-Pattern

Zero-Downtime-Migrationen folgen einem dreiphasigen Ansatz: Schema erweitern, Code migrieren, dann Schema verkleinern.

sqlsql
-- Phase 1: EXPAND — add the new column, keep the old one
ALTER TABLE users ADD COLUMN full_name VARCHAR(200);
 
-- Phase 2: MIGRATE — backfill data, deploy code that writes to both
UPDATE users SET full_name = first_name || ' ' || last_name
WHERE full_name IS NULL;
 
-- Phase 3: CONTRACT — remove the old columns (after all code uses full_name)
ALTER TABLE users DROP COLUMN first_name;
ALTER TABLE users DROP COLUMN last_name;
tstypescript
// ❌ Single migration — requires downtime, breaks running code
// Deploy migration: rename column from "name" to "full_name"
// Old code writes to "name" → fails
// New code writes to "full_name" → needs migration first
// You MUST take downtime to coordinate
 
// ✅ Expand-contract — zero downtime
// Deploy 1: Add full_name column, code writes to BOTH name and full_name
// Deploy 2: Backfill full_name from name where null
// Deploy 3: Code reads from full_name only
// Deploy 4: Drop name column

Jeder Deploy ist für sich genommen sicher. Schlägt ein Schritt fehl, funktioniert der vorherige Zustand weiterhin.

Gefährliche Operationen

Manche Schemaänderungen sperren die Tabelle und blockieren alle Lese- und Schreibzugriffe. Bei einer Tabelle mit Millionen von Zeilen bedeutet das Downtime.

sqlsql
-- ❌ DANGEROUS: locks the entire table while rewriting all rows
ALTER TABLE orders ADD COLUMN shipped_at TIMESTAMPTZ NOT NULL DEFAULT now();
-- On a 10M row table, this takes minutes with an exclusive lock
 
-- ✅ SAFE: add column as nullable (no rewrite), then backfill in batches
ALTER TABLE orders ADD COLUMN shipped_at TIMESTAMPTZ;
-- Instant — no table rewrite
 
-- Backfill in batches to avoid long-running transactions
UPDATE orders SET shipped_at = created_at
WHERE id IN (SELECT id FROM orders WHERE shipped_at IS NULL LIMIT 10000);
OperationLock-TypSicher bei großen Datenmengen?
ADD COLUMN (nullable)Kurzer Metadaten-Lock✅ Ja
ADD COLUMN (mit Default)Tabellen-Rewrite (vor PG 11)⚠️ Versionsabhängig
DROP COLUMNKurzer Metadaten-Lock✅ Ja
ALTER COLUMN TYPETabellen-Rewrite❌ Gefährlich
CREATE INDEXShare Lock (blockiert Schreibzugriffe)❌ CONCURRENTLY verwenden
CREATE INDEX CONCURRENTLYKein Lock✅ Ja

Index-Erstellung ohne Downtime

sqlsql
-- ❌ Blocks all writes until the index is built
CREATE INDEX idx_orders_status ON orders (status);
 
-- ✅ Builds the index without blocking writes
CREATE INDEX CONCURRENTLY idx_orders_status ON orders (status);
-- Takes longer but doesn't lock the table

CONCURRENTLY kann nicht innerhalb einer Transaktion laufen und muss daher ein eigener Migrationsschritt sein.

Daten sicher backfillen

Große Daten-Backfills sollten in Batches laufen, um lang laufende Transaktionen und Lock-Konflikte zu vermeiden.

tstypescript
async function backfillInBatches(
  db: Kysely<any>,
  batchSize = 5000,
) {
  let updated = batchSize;
 
  while (updated === batchSize) {
    const result = await db
      .updateTable("orders")
      .set({ shipped_at: sql`created_at` })
      .where("shipped_at", "is", null)
      .where(
        "id",
        "in",
        db
          .selectFrom("orders")
          .select("id")
          .where("shipped_at", "is", null)
          .limit(batchSize),
      )
      .executeTakeFirst();
 
    updated = Number(result.numUpdatedRows);
 
    // Yield to other queries between batches
    await new Promise((resolve) => setTimeout(resolve, 100));
  }
}

Rollback-Strategie

Habe immer einen Rollback-Plan, bevor du eine Migration anwendest.

tstypescript
// Migration with explicit rollback verification
export async function up(db: Kysely<any>) {
  // Step 1: Add new column
  await db.schema
    .alterTable("products")
    .addColumn("sku", "varchar(50)")
    .execute();
 
  // Step 2: Create unique index
  await sql`CREATE UNIQUE INDEX CONCURRENTLY idx_products_sku ON products (sku)`.execute(db);
}
 
export async function down(db: Kysely<any>) {
  await sql`DROP INDEX CONCURRENTLY IF EXISTS idx_products_sku`.execute(db);
  await db.schema.alterTable("products").dropColumn("sku").execute();
}
 
// Rollback test: run in staging before production
// 1. Apply migration (up)
// 2. Verify application works
// 3. Rollback migration (down)
// 4. Verify application still works
// 5. Re-apply migration (up)

Teste Rollbacks in Staging. Eine Migration, die nicht sicher zurückgerollt werden kann, braucht besondere Vorsicht und ein detailliertes Runbook.

Die wichtigsten Erkenntnisse

  1. Verwende das Expand-Contract-Pattern für Schemaänderungen ohne Downtime
  2. Füge niemals eine NOT-NULL-Spalte mit Default auf großen Tabellen hinzu — nullable hinzufügen, backfillen, dann den Constraint setzen
  3. Erstelle Indizes concurrently, um Schreibzugriffe während des Index-Builds nicht zu blockieren
  4. Backfille Daten in Batches, um lang laufende Transaktionen und Lock-Konflikte zu vermeiden
  5. Teste Rollbacks in Staging, bevor du Migrationen in der Produktion anwendest
  6. Jede Migration braucht eine down-Funktion — auch wenn sie unvollkommen ist, ist sie besser als nichts
Wilfredo Rujel

Wilfredo Rujel

Full-Stack-Softwareentwickler

Diesen Beitrag teilenX