Zero-Downtime Database Migrations with Drizzle ORM

Run database migrations without downtime using Drizzle ORM. Covers expand-contract pattern, backward-compatible schema changes, rollback strategies, and CI/CD integration for PostgreSQL.

E
ECOSIRE Research and Development Team
|19. März 202610 Min. Lesezeit2.1k Wörter|

Datenbankmigrationen ohne Ausfallzeiten mit Drizzle ORM

Datenbankmigrationen sind der gefährlichste Vorgang in Produktionsbereitstellungen. Eine Migration, die eine Tabelle sperrt – selbst für 200 ms – führt zu Verbindungs-Timeouts, die zu 500 Fehlern führen, die um 2 Uhr morgens zu Alarmstürmen führen. Die meisten Tutorials zur ORM-Migration zeigen Ihnen den „einfachen“ Weg: App herunterfahren, Migration ausführen, neu starten. Das ist keine Null-Ausfallzeit; Das ist eine geplante Wartung.

Echte Migrationen ohne Ausfallzeiten erfordern ein anderes mentales Modell: Ihr Schema muss während des gesamten Bereitstellungsprozesses abwärtskompatibel mit dem aktuell ausgeführten Anwendungscode sein. Das bedeutet, dass der alte und der neue Code während des Rollout-Fensters gleichzeitig funktionieren müssen. Das Expand-Contract-Muster macht dies möglich. Der SQL-First-Ansatz von Drizzle ORM gibt Ihnen die Kontrolle über die korrekte Implementierung.

Wichtige Erkenntnisse

– Führen Sie drizzle-kit push niemals in der Produktion aus – generieren und überprüfen Sie SQL und wenden Sie es dann kontrolliert an

  • Das Expand-Contract-Muster: hinzufügen (erweitern) → neuen Code bereitstellen → alten (Vertrag) entfernen – Das Hinzufügen einer Nullable-Spalte ist sicher; Das Hinzufügen von NOT NULL ohne Standardeinstellung ist gefährlich – Das Umbenennen einer Spalte erfordert: Neue Spalte hinzufügen → Backfill → Code aktualisieren → Alte Spalte entfernen (4 Schritte) – Verwenden Sie PostgreSQL CREATE INDEX CONCURRENTLY, um Tabellensperren während der Indexerstellung zu vermeiden
  • Testen Sie Migrationen an einer Kopie Ihrer Datenbank in Produktionsgröße, bevor Sie sie in der Produktion anwenden
  • Haben Sie immer einen Rollback-Plan: Schreiben und testen Sie für jede Migration die Rollback-SQL – Schemaänderungen, die ein Umschreiben der Tabelle erfordern (ALTER TYPE bei einer großen Tabelle), benötigen Wartungsfenster

Drizzle ORM-Schema-Setup

// packages/db/src/schema/contacts.ts
import { pgTable, uuid, varchar, text, timestamp, pgEnum, index } from 'drizzle-orm/pg-core';

export const contactStatusEnum = pgEnum('contact_status', ['active', 'inactive', 'archived']);

export const contacts = pgTable('contacts', {
  id:             uuid('id').primaryKey().defaultRandom(),
  organizationId: uuid('organization_id').notNull(),
  name:           varchar('name', { length: 255 }).notNull(),
  email:          varchar('email', { length: 255 }),
  phone:          varchar('phone', { length: 50 }),
  status:         contactStatusEnum('status').default('active').notNull(),
  notes:          text('notes'),
  createdAt:      timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
  updatedAt:      timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
}, (table) => ({
  orgIdIdx:  index('contacts_org_id_idx').on(table.organizationId),
  emailIdx:  index('contacts_email_idx').on(table.email),
  statusIdx: index('contacts_status_idx').on(table.status),
}));

Migrationen generieren:

# Generate SQL migration (never auto-apply to production)
npx drizzle-kit generate

# Review the generated SQL before applying
cat drizzle/0001_add_contact_status.sql

# Apply to development database
npx drizzle-kit migrate

# For production: apply via your deployment pipeline

Das Expand-Contract-Muster

Das Grundprinzip: Jede Schemaänderung wird in mehrere abwärtskompatible Bereitstellungen aufgeteilt.

Phase 1: Erweitern (Hinzufügen ohne Unterbrechung)

Stellen Sie Schemaänderungen bereit, die nur additiv sind – neue Spalten mit Standardwerten, neue Tabellen, neue Indizes.

-- Migration: 0010_expand_add_company.sql
-- Safe to apply while old code is running: nullable column with a default

ALTER TABLE contacts
  ADD COLUMN IF NOT EXISTS company_id UUID,
  ADD COLUMN IF NOT EXISTS company_name VARCHAR(255);

-- Old code ignores these columns; new code uses them
-- Both versions coexist during rolling deployment
// Drizzle schema after expansion
export const contacts = pgTable('contacts', {
  // ... existing columns ...
  companyId:   uuid('company_id'),           // nullable — old code ignores it
  companyName: varchar('company_name', { length: 255 }), // nullable — safe
});

Phase 2: Neuen Anwendungscode bereitstellen

Stellen Sie die neue App-Version bereit, die sowohl alte als auch neue Spalten liest und schreibt. Während der fortlaufenden Bereitstellung werden alte Instanzen (kein Unternehmenssupport) und neue Instanzen (Unternehmenssupport) gleichzeitig ausgeführt – beide funktionieren, da die Spalte nullwertfähig ist.

Phase 3: Hinterfüllung

Füllen Sie die neue Spalte für vorhandene Zeilen in kleinen Mengen auf, um Tabellensperren zu vermeiden:

-- Migration: 0011_backfill_company_name.sql
-- Run in small batches to avoid locking

DO $$
DECLARE
  batch_size INTEGER := 1000;
  offset_val INTEGER := 0;
  rows_updated INTEGER;
BEGIN
  LOOP
    UPDATE contacts
    SET company_name = 'Unknown'
    WHERE company_name IS NULL
      AND id IN (
        SELECT id FROM contacts
        WHERE company_name IS NULL
        ORDER BY id
        LIMIT batch_size
        OFFSET offset_val
      );

    GET DIAGNOSTICS rows_updated = ROW_COUNT;
    EXIT WHEN rows_updated = 0;

    offset_val := offset_val + batch_size;
    -- Brief pause between batches to reduce I/O pressure
    PERFORM pg_sleep(0.1);
  END LOOP;
END $$;

Phase 4: Vertrag (Alte Spalte entfernen)

Nachdem der gesamte Anwendungscode aktualisiert und der Backfill abgeschlossen ist, entfernen Sie die alte Spalte oder Einschränkung.


Sichere Migrationsmuster

Hinzufügen einer NOT NULL-Spalte

-- WRONG — will fail if table has rows (no default, no nullable)
ALTER TABLE contacts ADD COLUMN tier VARCHAR(20) NOT NULL;

-- WRONG — locks table while it writes the default to every row
ALTER TABLE contacts ADD COLUMN tier VARCHAR(20) NOT NULL DEFAULT 'free';

-- CORRECT — three-step approach
-- Step 1: Add nullable column
ALTER TABLE contacts ADD COLUMN tier VARCHAR(20);

-- Step 2: Backfill existing rows
UPDATE contacts SET tier = 'free' WHERE tier IS NULL;

-- Step 3 (next deployment): Add NOT NULL constraint (instant if no NULLs exist)
ALTER TABLE contacts ALTER COLUMN tier SET NOT NULL;
ALTER TABLE contacts ALTER COLUMN tier SET DEFAULT 'free';

Eine Spalte umbenennen (4-Schritte-Prozess)

-- Step 1: Add the new column
ALTER TABLE contacts ADD COLUMN full_name VARCHAR(255);

-- Step 2: Backfill + keep in sync with a trigger
UPDATE contacts SET full_name = name;

CREATE OR REPLACE FUNCTION sync_full_name() RETURNS trigger AS $$
BEGIN
  NEW.full_name := NEW.name;
  RETURN NEW;
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER contacts_sync_full_name
  BEFORE INSERT OR UPDATE OF name ON contacts
  FOR EACH ROW EXECUTE FUNCTION sync_full_name();

-- Step 3: Deploy new code that writes to full_name, reads from both
-- Step 4 (next deployment, after all instances updated):
DROP TRIGGER contacts_sync_full_name ON contacts;
DROP FUNCTION sync_full_name;
ALTER TABLE contacts DROP COLUMN name;

Einen Enum-Typ ändern

PostgreSQL erlaubt nicht das Entfernen von Enum-Werten ohne eine vollständige Neuschreibung der Tabelle. Für sichere Enum-Änderungen:

-- Adding a new enum value is safe (instant, no lock)
ALTER TYPE contact_status ADD VALUE IF NOT EXISTS 'pending';

-- Removing an enum value requires a full table rewrite
-- Use the expand-contract pattern: add a new enum type, migrate, drop old
CREATE TYPE contact_status_new AS ENUM ('active', 'inactive', 'archived', 'pending');

-- Migrate data
ALTER TABLE contacts
  ALTER COLUMN status TYPE contact_status_new
  USING status::text::contact_status_new;

DROP TYPE contact_status;
ALTER TYPE contact_status_new RENAME TO contact_status;

Indexerstellung ohne Sperrung

Der reguläre CREATE INDEX erhält einen ShareLock, der alle Schreibvorgänge für die Dauer blockiert. Auf einem großen Tisch kann dies einige Minuten dauern.

-- WRONG — locks writes during index creation
CREATE INDEX contacts_email_idx ON contacts(email);

-- CORRECT — concurrent index creation; no write lock
CREATE INDEX CONCURRENTLY IF NOT EXISTS contacts_email_idx
  ON contacts(email)
  WHERE email IS NOT NULL; -- Partial index for better performance

-- Drizzle note: drizzle-kit does not generate CONCURRENTLY by default
-- Edit the generated SQL migration to add CONCURRENTLY before applying

Fügen Sie in Ihrer Drizzle-Migrationsdatei nach der Generierung manuell CONCURRENTLY hinzu:

-- drizzle/0012_add_email_index.sql (edited after generation)
CREATE INDEX CONCURRENTLY IF NOT EXISTS contacts_email_idx
  ON contacts (email)
  WHERE email IS NOT NULL;

Drizzle-Migration in der Produktions-CI/CD

Verwenden Sie drizzle-kit push niemals in der Produktion – es führt Migrationen ohne einen Überprüfungsschritt durch. Verwenden Sie drizzle-kit migrate mit generierten SQL-Dateien, die einer Versionskontrolle unterliegen:

# scripts/deploy-production.sh

echo "=== Running database migrations ==="

# Check if there are pending migrations
PENDING=$(npx drizzle-kit migrate --dry-run 2>&1 | grep "pending")
if [ -n "$PENDING" ]; then
  echo "Pending migrations detected:"
  echo "$PENDING"

  # Apply migrations
  npx drizzle-kit migrate
  echo "Migrations applied successfully"
else
  echo "No pending migrations"
fi
// Programmatic migration in main.ts (NestJS)
import { drizzle } from 'drizzle-orm/postgres-js';
import { migrate } from 'drizzle-orm/postgres-js/migrator';

async function runMigrations() {
  const migrationClient = postgres(process.env.DATABASE_URL!, { max: 1 });
  const db = drizzle(migrationClient);

  await migrate(db, {
    migrationsFolder: join(__dirname, '..', '..', '..', 'drizzle'),
  });

  await migrationClient.end();
}

// In bootstrap(), before app.listen():
if (process.env.RUN_MIGRATIONS === 'true') {
  await runMigrations();
}

Rollback-Strategien

Für jede Migration muss vor der Bereitstellung eine begleitende Rollback-Migration geschrieben und getestet werden:

// drizzle/0013_add_company_id.sql (forward migration)
ALTER TABLE contacts ADD COLUMN IF NOT EXISTS company_id UUID;
CREATE INDEX CONCURRENTLY IF NOT EXISTS contacts_company_id_idx
  ON contacts(company_id);

// drizzle/rollbacks/0013_rollback_add_company_id.sql
DROP INDEX CONCURRENTLY IF EXISTS contacts_company_id_idx;
ALTER TABLE contacts DROP COLUMN IF EXISTS company_id;

Für Notfall-Rollback:

# Emergency rollback script
# Run the rollback SQL, then redeploy the previous app version

psql "$DATABASE_URL" < drizzle/rollbacks/0013_rollback_add_company_id.sql
git checkout HEAD~1
pnpm build && pm2 restart ecosystem.config.cjs --update-env

Migrationsteststrategie

// packages/db/src/tests/migration.spec.ts
import { describe, it, expect, beforeAll } from 'vitest';
import { migrate } from 'drizzle-orm/postgres-js/migrator';
import postgres from 'postgres';
import { drizzle } from 'drizzle-orm/postgres-js';

describe('Database Migrations', () => {
  let sql: ReturnType<typeof postgres>;

  beforeAll(async () => {
    sql = postgres(process.env.TEST_DATABASE_URL!);
  });

  it('all migrations apply cleanly on a fresh database', async () => {
    const db = drizzle(sql);
    await expect(migrate(db, { migrationsFolder: './drizzle' }))
      .resolves.not.toThrow();
  });

  it('migrations are idempotent (can be applied twice safely)', async () => {
    const db = drizzle(sql);
    await migrate(db, { migrationsFolder: './drizzle' });
    // Running again should be a no-op, not an error
    await expect(migrate(db, { migrationsFolder: './drizzle' }))
      .resolves.not.toThrow();
  });
});

Gefährliche Vorgänge, die Wartungsfenster erfordern

Einige Vorgänge können bei sehr großen Tabellen (Hunderte Millionen Zeilen) nicht ohne Ausfallzeit ausgeführt werden:

BetriebRisikoSchadensbegrenzung
ALTER TYPE USING auf großer SpalteVollständiges Umschreiben der Tabelle, lange SperreWartungsfenster planen
ADD COLUMN NOT NULL ohne StandardSofortiger Fehler, wenn Zeilen vorhanden sindVerwenden Sie den dreistufigen Erweiterungsvertrag
CREATE INDEX (ohne CONCURRENTLY)Schreibsperre für DauerVerwenden Sie immer CONCURRENTLY
ALTER TABLE ... RENAME TOSofort, bricht aber alten CodeNur in der Erweiterungsphase, nachdem der Code aktualisiert wurde
DROP TABLEIrreversibelHalten Sie immer ein Backup bereit; 30-tägige Soft-Delete-Richtlinie
VACUUM FULLTabellensperre für DauerVerwenden Sie während der Wartung den regulären VACUUM + Zeitplan FULL

Häufig gestellte Fragen

Was ist der Unterschied zwischen drizzle-kit push und drizzle-kit migrate?

push wendet Ihr aktuelles Schema direkt auf die Datenbank an, ohne Migrationsdateien zu generieren – nützlich für Entwicklungen, bei denen Ihnen der Migrationsverlauf egal ist. migrate wendet die SQL-Dateien in Ihrem drizzle/-Verzeichnis der Reihe nach an und verfolgt, welche angewendet wurden. Verwenden Sie in der Produktion immer migrate, damit Sie über einen kontrollierten, überprüfbaren und versionierten Verlauf jeder Schemaänderung verfügen.

Wie geht Drizzle mit der Tracking-Tabelle drizzle_migrations um?

Drizzle erstellt eine Tabelle drizzle_migrations (oder __drizzle_migrations), die aufzeichnet, welche Migrationsdateien wann angewendet wurden. Bevor eine Migration durchgeführt wird, wird diese Tabelle überprüft. Fügen Sie in dieser Tabelle keine Zeilen manuell ein oder löschen Sie sie. Dies ist der Zustandsverfolgungsmechanismus von Drizzle. Wenn Sie zu einem früheren Migrationsstatus zurückkehren müssen, führen Sie Ihr Rollback-SQL aus und löschen Sie die entsprechende Zeile manuell.

Wie teste ich Migrationen anhand einer Datenbank in Produktionsgröße?

Stellen Sie eine bereinigte (PII-entfernte) Kopie Ihrer Produktionsdatenbank auf einem Staging-Server wieder her. Wenden Sie die Migration dort an und messen Sie: Wartezeit, Sperrwartezeit (von pg_stat_activity), Tabellenaufblähung (von pg_stat_user_tables) und Auswirkungen auf die Abfrageleistung (von EXPLAIN ANALYZE). Wenn die Migration beim Staging mehr als 5 Sekunden dauert, entwerfen Sie sie neu.

Kann ich Transaktionen für Datenbankmigrationen verwenden?

Die meisten DDLs in PostgreSQL sind transaktional – Sie können ALTER TABLE, CREATE TABLE, CREATE INDEX (aber NICHT CREATE INDEX CONCURRENTLY) in eine Transaktion einbinden und ein Rollback durchführen, wenn ein Schritt fehlschlägt. Drizzle verpackt jede Migrationsdatei standardmäßig in eine Transaktion. Für Migrationen, die CONCURRENTLY verwenden, müssen Sie sie in eine separate Migrationsdatei aufteilen, die außerhalb einer Transaktion ausgeführt wird.

Wie gehe ich mit Drizzle-Enumerationsvergleichen in TypeScript um?

Die Enum-Spalten von Drizzle geben den String-Wert aus der Datenbank zurück. Beim Vergleich schränkt TypeScript den Typ möglicherweise nicht richtig ein. Konvertieren Sie explizit: if ((contact.status as ContactStatus) === 'active') oder verwenden Sie eine Typzusicherung in Ihrer Drizzle-where-Klausel: where(eq(contacts.status, 'active' as ContactStatus)). Dies vermeidet Laufzeitfehler und sorgt gleichzeitig dafür, dass TypeScript zufrieden ist.


Nächste Schritte

Datenbankmigrationen ohne Ausfallzeiten erfordern Disziplin – aber die Alternative (Ausfallzeit, Datenbeschädigung, Notfall-Rollbacks um 3 Uhr morgens) ist weitaus schlimmer. Das Erweiterungsvertragsmuster CREATE INDEX CONCURRENTLY und die richtige Rollback-Vorbereitung machen Schemaänderungen zu einem routinemäßigen und sicheren Vorgang.

ECOSIRE verwaltet Datenbankschemata für NestJS-Anwendungen mit Drizzle ORM, PostgreSQL 17 und einer Bereitstellungspipeline ohne Ausfallzeiten, die über 65 Schemadateien und über 300 Migrationen getestet wurde. [Entdecken Sie unsere Backend-Engineering-Services] (/services), um zu erfahren, wie wir Datenbankoperationen im großen Maßstab sicher handhaben.

E

Geschrieben von

ECOSIRE Research and Development Team

Entwicklung von Enterprise-Digitalprodukten bei ECOSIRE. Einblicke in Odoo-Integrationen, E-Commerce-Automatisierung und KI-gestützte Geschäftslösungen.

Chatten Sie auf WhatsApp