Migraciones de bases de datos sin tiempo de inactividad con Drizzle ORM
Las migraciones de bases de datos son la operación más peligrosa en las implementaciones de producción. Una migración que bloquea una tabla, incluso durante 200 ms, provoca tiempos de espera de conexión que se convierten en 500 errores que se convierten en tormentas de alerta a las 2 a.m. La mayoría de los tutoriales de migración de ORM le muestran el camino "fácil": cerrar la aplicación, ejecutar la migración y reiniciar. Eso no es tiempo de inactividad cero; eso es mantenimiento programado.
Las migraciones reales sin tiempo de inactividad requieren un modelo mental diferente: su esquema debe ser compatible con el código de la aplicación actualmente en ejecución durante todo el proceso de implementación. Esto significa que el código antiguo y el nuevo deben funcionar simultáneamente durante la ventana de implementación. El patrón de expansión-contrato lo hace posible. El enfoque SQL primero de Drizzle ORM le brinda el control para implementarlo correctamente.
Conclusiones clave
- Nunca ejecute
drizzle-kit pushen producción: genere y revise SQL, luego aplíquelo con control- El patrón de expansión-contrato: agregar (expandir) → implementar nuevo código → eliminar el antiguo (contrato)
- Agregar una columna que acepta valores NULL es seguro; agregar NOT NULL sin un valor predeterminado es peligroso
- Cambiar el nombre de una columna requiere: agregar una nueva columna → rellenar → actualizar el código → eliminar la columna anterior (4 pasos)
- Utilice PostgreSQL
CREATE INDEX CONCURRENTLYpara evitar bloqueos de tabla durante la creación del índice- Pruebe las migraciones en una copia de tamaño de producción de su base de datos antes de aplicar a producción
- Tenga siempre un plan de reversión: para cada migración, escriba y pruebe el SQL de reversión
- Los cambios de esquema que requieren reescritura de tablas (ALTERAR TIPO en una tabla grande) necesitan ventanas de mantenimiento
Configuración del esquema ORM de llovizna
// 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),
}));
Generar migraciones:
# 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
El patrón de expansión-contrato
El principio básico: cada cambio de esquema se divide en múltiples implementaciones compatibles con versiones anteriores.
Fase 1: Expandir (Agregar sin romper)
Implemente cambios de esquema que sean únicamente aditivos: nuevas columnas con valores predeterminados, nuevas tablas, nuevos índices.
-- 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
});
Fase 2: implementar un nuevo código de aplicación
Implemente la nueva versión de la aplicación que lee y escribe columnas nuevas y antiguas. Durante la implementación continua, las instancias antiguas (sin soporte de la empresa) y las instancias nuevas (con soporte de la empresa) se ejecutan simultáneamente; ambas funcionan porque la columna admite valores NULL.
Fase 3: Relleno
Complete la nueva columna para las filas existentes en lotes pequeños para evitar bloqueos de tabla:
-- 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 $$;
Fase 4: Contrato (eliminar columna anterior)
Una vez actualizado todo el código de la aplicación y completado el reabastecimiento, elimine la columna o restricción anterior.
Patrones de migración segura
Agregar una columna NO NULA
-- 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';
Cambiar el nombre de una columna (proceso de 4 pasos)
-- 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;
Cambiar un tipo de enumeración
PostgreSQL no permite eliminar valores de enumeración sin una reescritura completa de la tabla. Para cambios de enumeración seguros:
-- 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;
Creación de índice sin bloqueo
El CREATE INDEX normal adquiere un ShareLock que bloquea todas las escrituras mientras dure. En una mesa grande, esto puede llevar unos minutos.
-- 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
En su archivo de migración de Drizzle, agregue manualmente CONCURRENTLY después de la generación:
-- 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;
Migración de llovizna en CI/CD de producción
Nunca use drizzle-kit push en producción; aplica migraciones sin un paso de revisión. Utilice drizzle-kit migrate con archivos SQL generados que tengan control de versión:
# 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();
}
Estrategias de reversión
Cada migración debe tener una migración de reversión complementaria escrita y probada antes de la implementación:
// 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;
Para reversión de emergencia:
# 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
Estrategia de prueba de migración
// 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();
});
});
Operaciones peligrosas que requieren ventanas de mantenimiento
Algunas operaciones no se pueden realizar sin tiempo de inactividad en tablas muy grandes (cientos de millones de filas):
| Operación | Riesgo | Mitigación |
|---|---|---|
ALTER TYPE USING en columna grande | Reescritura completa de la tabla, bloqueo largo | Programar ventana de mantenimiento |
ADD COLUMN NOT NULL sin valor predeterminado | Fallo inmediato si existen filas | Utilice contrato de expansión de tres pasos |
CREATE INDEX (sin CONCURRENTE) | Bloqueo de escritura por duración | Utilice siempre CONCURRENTLY |
| CÓDIGO0 | Instantáneo, pero rompe el código antiguo | Solo en fase de expansión después de la actualización del código |
| CÓDIGO0 | Irreversibles | Tenga siempre una copia de seguridad; Política de eliminación temporal de 30 días |
| CÓDIGO0 | Bloqueo de mesa por duración | Utilice el VACUUM regular + horario COMPLETO durante el mantenimiento |
Preguntas frecuentes
¿Cuál es la diferencia entre drizzle-kit push y drizzle-kit migrate?
push aplica directamente su esquema actual a la base de datos sin generar archivos de migración, lo que resulta útil para el desarrollo en el que no le importa el historial de migración. migrate aplica los archivos SQL en su directorio drizzle/ en orden, rastreando cuáles se han aplicado. Utilice siempre migrate en producción para tener un historial controlado, revisable y controlado por versiones de cada cambio de esquema.
¿Cómo maneja Drizzle la tabla de seguimiento drizzle_migrations?
Drizzle crea una tabla drizzle_migrations (o __drizzle_migrations) que registra qué archivos de migración se han aplicado y cuándo. Antes de aplicar una migración, consulta esta tabla. No inserte ni elimine filas manualmente de esta tabla; es el mecanismo de seguimiento del estado de Drizzle. Si necesita volver a un estado de migración anterior, ejecute su SQL de reversión y elimine manualmente la fila correspondiente.
¿Cómo pruebo las migraciones en una base de datos de tamaño de producción?
Restaure una copia desinfectada (eliminada PII) de su base de datos de producción en un servidor provisional. Aplique la migración allí y mida: el tiempo del reloj de pared, el tiempo de espera de bloqueo (desde pg_stat_activity), el aumento de la tabla (desde pg_stat_user_tables) y el impacto en el rendimiento de las consultas (desde EXPLAIN ANALYZE). Si la migración tarda más de 5 segundos en la preparación, rediseñe la misma.
¿Puedo utilizar transacciones para migraciones de bases de datos?
La mayoría de los DDL en PostgreSQL son transaccionales: puede incluir ALTER TABLE, CREATE TABLE, CREATE INDEX (pero NO CREATE INDEX CONCURRENTLY) en una transacción y revertirla si falla algún paso. Drizzle envuelve cada archivo de migración en una transacción de forma predeterminada. Para las migraciones que usan CONCURRENTLY, debe dividirlas en un archivo de migración separado que se ejecute fuera de una transacción.
¿Cómo manejo las comparaciones de enumeraciones de Drizzle en TypeScript?
Las columnas de enumeración de Drizzle devuelven el valor de cadena de la base de datos. Al comparar, es posible que TypeScript no limite el tipo correctamente. Transmita explícitamente: if ((contact.status as ContactStatus) === 'active') o use una aserción de tipo en su cláusula Drizzle where: where(eq(contacts.status, 'active' as ContactStatus)). Esto evita errores de tiempo de ejecución y mantiene feliz a TypeScript.
Próximos pasos
Las migraciones de bases de datos sin tiempo de inactividad requieren disciplina, pero la alternativa (tiempo de inactividad, corrupción de datos, reversiones de emergencia a las 3 a.m.) es mucho peor. El patrón de expansión-contrato, CREATE INDEX CONCURRENTLY, y la preparación adecuada de reversión hacen que los cambios de esquema sean una operación rutinaria y segura.
ECOSIRE administra esquemas de bases de datos para aplicaciones NestJS con Drizzle ORM, PostgreSQL 17 y un proceso de implementación sin tiempo de inactividad probado en más de 65 archivos de esquema y más de 300 migraciones. Explore nuestros servicios de ingeniería backend para aprender cómo manejamos las operaciones de bases de datos de forma segura a escala.
Escrito por
ECOSIRE Research and Development Team
Construyendo productos digitales de nivel empresarial en ECOSIRE. Compartiendo perspectivas sobre integraciones Odoo, automatización de eCommerce y soluciones empresariales impulsadas por IA.
Artículos relacionados
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.