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 pushniemals 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:
| Betrieb | Risiko | Schadensbegrenzung |
|---|---|---|
ALTER TYPE USING auf großer Spalte | Vollständiges Umschreiben der Tabelle, lange Sperre | Wartungsfenster planen |
ADD COLUMN NOT NULL ohne Standard | Sofortiger Fehler, wenn Zeilen vorhanden sind | Verwenden Sie den dreistufigen Erweiterungsvertrag |
CREATE INDEX (ohne CONCURRENTLY) | Schreibsperre für Dauer | Verwenden Sie immer CONCURRENTLY |
ALTER TABLE ... RENAME TO | Sofort, bricht aber alten Code | Nur in der Erweiterungsphase, nachdem der Code aktualisiert wurde |
DROP TABLE | Irreversibel | Halten Sie immer ein Backup bereit; 30-tägige Soft-Delete-Richtlinie |
VACUUM FULL | Tabellensperre für Dauer | Verwenden 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.
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.
Verwandte Artikel
AWS EC2 Deployment Guide for Web Applications
Complete AWS EC2 deployment guide: instance selection, security groups, Node.js deployment, Nginx reverse proxy, SSL, auto-scaling, CloudWatch monitoring, and cost optimization.
Drizzle ORM with PostgreSQL: Complete Guide
Complete guide to Drizzle ORM with PostgreSQL: schema design, migrations, type-safe queries, relations, transactions, and production patterns for TypeScript apps.
Enterprise Security for OpenClaw AI Deployments
Comprehensive guide to securing OpenClaw AI agent deployments in enterprise environments. Covers authentication, secrets management, network isolation, and compliance.