Migrations de bases de données sans temps d'arrêt avec Drizzle ORM
Les migrations de bases de données constituent l’opération la plus dangereuse dans les déploiements de production. Une migration qui verrouille une table, même pendant 200 ms, provoque des délais d'attente de connexion qui se transforment en 500 erreurs qui se transforment en tempêtes d'alertes à 2 heures du matin. La plupart des didacticiels de migration ORM vous montrent le chemin « simple » : fermez l'application, exécutez la migration, redémarrez. Il ne s’agit pas d’un temps d’arrêt nul ; c'est la maintenance programmée.
Les véritables migrations sans temps d'arrêt nécessitent un modèle mental différent : votre schéma doit être rétrocompatible avec le code de l'application en cours d'exécution tout au long du processus de déploiement. Cela signifie que l’ancien code et le nouveau code doivent fonctionner simultanément pendant la fenêtre de déploiement. Le modèle d’expansion-contrat rend cela possible. L'approche SQL first de Drizzle ORM vous donne le contrôle nécessaire pour l'implémenter correctement.
Points clés à retenir
- N'exécutez jamais
drizzle-kit pushen production - générez et révisez SQL, puis appliquez-le avec contrôle- Le modèle étendre-contrat : ajouter (développer) → déployer du nouveau code → supprimer l'ancien (contrat)
- L'ajout d'une colonne nullable est sécurisé ; ajouter NOT NULL sans valeur par défaut est dangereux
- Renommer une colonne nécessite : ajouter une nouvelle colonne → remplir → mettre à jour le code → supprimer l'ancienne colonne (4 étapes)
- Utilisez PostgreSQL
CREATE INDEX CONCURRENTLYpour éviter les verrous de table lors de la création d'index- Testez les migrations sur une copie de production de votre base de données avant de postuler en production
- Ayez toujours un plan de rollback : pour chaque migration, écrivez et testez le SQL de rollback
- Les modifications de schéma qui nécessitent des réécritures de table (ALTER TYPE sur une grande table) nécessitent des fenêtres de maintenance
Configuration du schéma ORM Drizzle
// 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),
}));
Générer des migrations :
# 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
Le modèle d'expansion-contrat
Le principe de base : chaque modification de schéma est divisée en plusieurs déploiements rétrocompatibles.
Phase 1 : Développer (Ajouter sans interrompre)
Déployez des modifications de schéma qui sont uniquement additives : nouvelles colonnes avec valeurs par défaut, nouvelles tables, nouveaux index.
-- 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 : Déployer un nouveau code d'application
Déployez la nouvelle version de l'application qui lit et écrit les anciennes et les nouvelles colonnes. Lors du déploiement progressif, les anciennes instances (pas de support de l'entreprise) et les nouvelles instances (support de l'entreprise) s'exécutent simultanément : les deux fonctionnent car la colonne est nullable.
Phase 3 : Remblai
Remplissez la nouvelle colonne pour les lignes existantes par petits lots pour éviter les verrous de table :
-- 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 : Contrat (supprimer l'ancienne colonne)
Une fois que tout le code de l’application est mis à jour et que le remplissage est terminé, supprimez l’ancienne colonne ou contrainte.
Modèles de migration sécurisés
Ajout d'une colonne NON NULL
-- 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';
Renommer une colonne (processus en 4 étapes)
-- 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;
Modification d'un type d'énumération
PostgreSQL ne permet pas de supprimer les valeurs d'énumération sans une réécriture complète de la table. Pour des modifications d'énumération sécurisées :
-- 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;
Création d'index sans verrouillage
Le CREATE INDEX régulier acquiert un ShareLock qui bloque toutes les écritures pour la durée. Sur une grande table, cela peut prendre quelques minutes.
-- 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
Dans votre fichier de migration Drizzle, ajoutez manuellement CONCURRENTLY après génération :
-- 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;
Migration de bruine en production CI/CD
N'utilisez jamais drizzle-kit push en production : il applique les migrations sans étape de révision. Utilisez drizzle-kit migrate avec les fichiers SQL générés dont la version est contrôlée :
# 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();
}
## Stratégies de restauration
Chaque migration doit être accompagnée d'une migration de restauration écrite et testée avant le déploiement :
// 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;
Pour une restauration d'urgence :
# 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
Stratégie de test de migration
// 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();
});
});
Opérations dangereuses nécessitant des fenêtres de maintenance
Certaines opérations ne peuvent pas être effectuées sans temps d'arrêt sur de très grandes tables (des centaines de millions de lignes) :
| Opération | Risque | Atténuation |
|---|---|---|
ALTER TYPE USING sur grande colonne | Réécriture complète de la table, verrouillage long | Planifier la fenêtre de maintenance |
ADD COLUMN NOT NULL sans défaut | Échec immédiat si des lignes existent | Utiliser un contrat d'extension en trois étapes |
CREATE INDEX (sans CONCURRENTEMENT) | Verrouillage d'écriture pour la durée | Utilisez toujours CONCURRENTLY |
ALTER TABLE ... RENAME TO | Instantané, mais brise l'ancien code | Uniquement en phase d'expansion après la mise à jour du code |
DROP TABLE | Irréversible | Ayez toujours une sauvegarde ; Politique de suppression logicielle de 30 jours |
VACUUM FULL | Verrouillage de la table pour la durée | Utiliser régulièrement VACUUM + planning FULL pendant la maintenance |
Questions fréquemment posées
Quelle est la différence entre drizzle-kit push et drizzle-kit migrate ?
push applique directement votre schéma actuel à la base de données sans générer de fichiers de migration – utile pour le développement où vous ne vous souciez pas de l'historique de migration. migrate applique les fichiers SQL dans votre répertoire drizzle/ dans l'ordre, en suivant ceux qui ont été appliqués. Utilisez toujours migrate en production afin de disposer d'un historique contrôlé, consultable et contrôlé par la version de chaque modification de schéma.
Comment Drizzle gère-t-il la table de suivi drizzle_migrations ?
Drizzle crée une table drizzle_migrations (ou __drizzle_migrations) qui enregistre quels fichiers de migration ont été appliqués et quand. Avant d'appliquer une migration, il vérifie ce tableau. N'insérez pas ou ne supprimez pas manuellement de lignes de ce tableau - il s'agit du mécanisme de suivi d'état de Drizzle. Si vous devez revenir à un état de migration précédent, exécutez votre rollback SQL et supprimez manuellement la ligne correspondante.
Comment tester les migrations sur une base de données de taille production ?
Restaurez une copie nettoyée (PII supprimée) de votre base de données de production sur un serveur intermédiaire. Appliquez-y la migration et mesurez : l'heure de l'horloge murale, le temps d'attente du verrouillage (à partir de pg_stat_activity), la surcharge de la table (à partir de pg_stat_user_tables) et l'impact sur les performances des requêtes (à partir de EXPLAIN ANALYZE). Si la migration prend plus de 5 secondes lors de la préparation, reconcevez-la.
Puis-je utiliser des transactions pour les migrations de bases de données ?
La plupart des DDL dans PostgreSQL sont transactionnels : vous pouvez encapsuler ALTER TABLE, CREATE TABLE, CREATE INDEX (mais PAS CREATE INDEX CONCURRENTLY) dans une transaction et annuler si une étape échoue. Drizzle encapsule chaque fichier de migration dans une transaction par défaut. Pour les migrations qui utilisent CONCURRENTLY, vous devez les diviser dans un fichier de migration distinct qui s'exécute en dehors d'une transaction.
Comment gérer les comparaisons d'énumérations Drizzle dans TypeScript ?
Les colonnes d'énumération de Drizzle renvoient la valeur de chaîne de la base de données. Lors de la comparaison, TypeScript peut ne pas restreindre correctement le type. Convertissez explicitement : if ((contact.status as ContactStatus) === 'active') ou utilisez une assertion de type dans votre clause Drizzle where : where(eq(contacts.status, 'active' as ContactStatus)). Cela évite les erreurs d'exécution tout en gardant TypeScript satisfait.
Prochaines étapes
Les migrations de bases de données sans temps d'arrêt nécessitent de la discipline, mais l'alternative (temps d'arrêt, corruption des données, restaurations d'urgence à 3 heures du matin) est bien pire. Le modèle d'extension-contrat, CREATE INDEX CONCURRENTLY, et une préparation appropriée de la restauration font des modifications de schéma une opération de routine et sûre.
ECOSIRE gère les schémas de base de données pour les applications NestJS avec Drizzle ORM, PostgreSQL 17 et un pipeline de déploiement sans temps d'arrêt testé sur plus de 65 fichiers de schéma et plus de 300 migrations. Découvrez nos services d'ingénierie backend pour découvrir comment nous gérons les opérations de bases de données en toute sécurité à grande échelle.
Rédigé par
ECOSIRE Research and Development Team
Création de produits numériques de niveau entreprise chez ECOSIRE. Partage d'analyses sur les intégrations Odoo, l'automatisation e-commerce et les solutions d'entreprise propulsées par l'IA.
Articles connexes
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.