Migraciones de Esquema en PostgreSQL Sin Downtime: La Guía Completa Para Cambiar la Base de Datos en Producción Sin Romper Nada
Son las 2 AM. Tu equipo acaba de deployar una migración que agrega una columna NOT NULL a una tabla con 50 millones de filas. La migración adquirió un ACCESS EXCLUSIVE lock, bloqueando cada query en esa tabla. Los tiempos de respuesta de tu API se dispararon de 50ms a 30 segundos. El teléfono del ingeniero de guardia explota. Los clientes están tuiteando capturas de pantalla de páginas de error.
Acabás de aprender, de la peor manera, que ALTER TABLE ... ADD COLUMN ... NOT NULL DEFAULT en PostgreSQL puede ser un arma de destrucción masiva en producción.
Esta guía existe para que no tengas que aprender esa lección por las malas. Vamos a cubrir cada patrón, herramienta y gotcha involucrado en cambiar esquemas de PostgreSQL sin bajar tu aplicación. No es teoría. Son estrategias probadas en producción que funcionan en tablas con cientos de millones de filas.
Por qué las migraciones de esquema son peligrosas
La mayoría de los developers tratan las migraciones de base de datos como deploys simples de código. Escribir un ALTER TABLE, correrlo durante el deploy y seguir. Esto funciona bien en desarrollo, donde tu tabla users tiene 50 filas. En producción, donde tiene 50 millones, la misma migración puede tirar abajo toda tu aplicación.
Acá va el porqué:
El sistema de locks de PostgreSQL
Cada sentencia DDL en PostgreSQL adquiere locks. El problema no es que existan los locks, es la cola de locks. Cuando una sentencia DDL pide un ACCESS EXCLUSIVE lock y no lo puede obtener inmediatamente (porque queries activas tienen locks conflictivos), espera en la cola. Mientras espera, cada nueva query que necesita cualquier lock en esa tabla también se encola detrás.
Cronología del desastre:
00:00 Queries SELECT ejecutándose (tienen ACCESS SHARE locks)
00:01 ALTER TABLE pide ACCESS EXCLUSIVE lock → espera en la cola
00:02 Nuevo SELECT llega → se encola detrás del ALTER TABLE
00:03 Nuevo SELECT llega → se encola detrás del ALTER TABLE
00:04 Nuevo SELECT llega → se encola detrás del ALTER TABLE
... Todas las queries encoladas. La app parece congelada.
00:30 El SELECT original termina → ALTER TABLE adquiere el lock
00:31 ALTER TABLE se ejecuta (puede tardar minutos en tablas grandes)
02:00 ALTER TABLE completa → las queries encoladas finalmente se ejecutan
Eso es un outage de 2 minutos causado por una sola sentencia ALTER TABLE. Con una tabla con mucho tráfico, esto puede cascadear en agotamiento del pool de conexiones, crashes de la aplicación y fallos en cascada en todo tu sistema.
Las operaciones que te matan
No todos los cambios de esquema son igual de peligrosos. La matriz de riesgo:
| Operación | Tipo de Lock | Nivel de Riesgo | Duración en 50M filas |
|---|---|---|---|
ADD COLUMN (nullable, sin default) | ACCESS EXCLUSIVE | 🟢 Bajo | Milisegundos |
ADD COLUMN ... DEFAULT (PG 11+) | ACCESS EXCLUSIVE | 🟢 Bajo | Milisegundos |
ADD COLUMN ... NOT NULL DEFAULT (PG 11+) | ACCESS EXCLUSIVE | 🟡 Medio | Milisegundos (pero riesgo de cola de locks) |
DROP COLUMN | ACCESS EXCLUSIVE | 🟢 Bajo | Milisegundos |
ALTER COLUMN TYPE | ACCESS EXCLUSIVE | 🔴 Crítico | Minutos a horas (rewrite completo) |
ADD INDEX | SHARE lock | 🔴 Crítico | Minutos (bloquea writes) |
ADD INDEX CONCURRENTLY | Sin lock | 🟢 Bajo | Minutos (no bloqueante) |
ADD NOT NULL CONSTRAINT | ACCESS EXCLUSIVE | 🔴 Crítico | Minutos (scan completo) |
ADD FOREIGN KEY | SHARE ROW EXCLUSIVE | 🔴 Crítico | Minutos (scan completo) |
Las operaciones marcadas 🔴 son las que causan outages. Veamos cómo hacer que cada una sea segura.
El patrón Expand-Contract
El patrón expand-contract (también llamado "cambio paralelo") es la estrategia fundamental para migraciones sin downtime. La idea es simple: nunca hagás un cambio que rompa en un solo paso. En cambio, dividilo en múltiples pasos que no rompan.
┌─────────────────────────────────────────────────────────────┐
│ PATRÓN EXPAND-CONTRACT │
│ │
│ Fase 1: EXPAND (Expandir) │
│ ┌──────────────────────────────────────────────────┐ │
│ │ Agregar nueva columna/tabla junto a la vieja │ │
│ │ Deployar código que escriba en AMBAS │ │
│ │ Los lectores viejos siguen funcionando │ │
│ └──────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ Fase 2: MIGRATE (Migrar) │
│ ┌──────────────────────────────────────────────────┐ │
│ │ Backfill de datos existentes a la nueva │ │
│ │ Verificar consistencia de datos │ │
│ │ Cambiar lectores a la nueva columna/tabla │ │
│ └──────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ Fase 3: CONTRACT (Contraer) │
│ ┌──────────────────────────────────────────────────┐ │
│ │ Eliminar columna/tabla vieja │ │
│ │ Eliminar código de compatibilidad │ │
│ │ Limpieza │ │
│ └──────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────┘
Veamos esto aplicado a las migraciones más comunes y peligrosas.
Ejemplo: Renombrar una columna
Querés renombrar users.full_name a users.display_name. Un ALTER TABLE users RENAME COLUMN full_name TO display_name sin más rompería cada query que referencie full_name en el instante que se ejecute.
Step 1: Expand: Agregar la nueva columna
-- Deploy 1: Agregar nueva columna (instantáneo, sin rewrite) ALTER TABLE users ADD COLUMN display_name TEXT; -- Crear un trigger para mantener ambas columnas sincronizadas CREATE OR REPLACE FUNCTION sync_display_name() RETURNS TRIGGER AS $$ BEGIN IF TG_OP = 'INSERT' OR TG_OP = 'UPDATE' THEN IF NEW.display_name IS NULL AND NEW.full_name IS NOT NULL THEN NEW.display_name := NEW.full_name; ELSIF NEW.full_name IS NULL AND NEW.display_name IS NOT NULL THEN NEW.full_name := NEW.display_name; END IF; END IF; RETURN NEW; END; $$ LANGUAGE plpgsql; CREATE TRIGGER trigger_sync_display_name BEFORE INSERT OR UPDATE ON users FOR EACH ROW EXECUTE FUNCTION sync_display_name();
Step 2: Backfill: Copiar datos existentes
-- Backfill por lotes para evitar transacciones largas DO $$ DECLARE batch_size INT := 10000; rows_updated INT; BEGIN LOOP UPDATE users SET display_name = full_name WHERE id IN ( SELECT id FROM users WHERE display_name IS NULL AND full_name IS NOT NULL LIMIT batch_size FOR UPDATE SKIP LOCKED ); GET DIAGNOSTICS rows_updated = ROW_COUNT; EXIT WHEN rows_updated = 0; RAISE NOTICE 'Updated % rows', rows_updated; PERFORM pg_sleep(0.1); COMMIT; END LOOP; END $$;
Step 3: Cambiar lectores: Actualizar el código de la app para leer de display_name
Step 4: Contract: Eliminar la columna vieja y el trigger
-- Deploy 3: Después de que todo el código lea de display_name DROP TRIGGER trigger_sync_display_name ON users; DROP FUNCTION sync_display_name(); ALTER TABLE users DROP COLUMN full_name;
Cuatro deployments en vez de uno. Pero cero downtime.
Creación segura de índices
Crear índices en tablas grandes es una de las causas más comunes de outages en producción. Un CREATE INDEX estándar adquiere un SHARE lock, bloqueando todos los writes (INSERT, UPDATE, DELETE) durante toda la construcción del índice.
Siempre usá CONCURRENTLY
-- ❌ PELIGROSO: Bloquea todos los writes CREATE INDEX idx_users_email ON users(email); -- ✅ SEGURO: No bloqueante CREATE INDEX CONCURRENTLY idx_users_email ON users(email);
CREATE INDEX CONCURRENTLY construye el índice sin mantener un lock que bloquee writes. Lo hace escaneando la tabla dos veces: una para construir el índice inicial, y otra para capturar cambios que ocurrieron durante el primer scan.
Los gotchas de CONCURRENTLY
Hay cosas importantes que tenés que saber:
1. Puede fallar silenciosamente. Si CREATE INDEX CONCURRENTLY encuentra un error (ej: violación de constraint único), deja atrás un índice INVALID. Siempre verificá:
-- Verificar índices inválidos después de la creación SELECT indexname, indexdef FROM pg_indexes WHERE schemaname = 'public' AND indexname = 'idx_users_email'; -- Verificar validez SELECT pg_index.indisvalid FROM pg_index JOIN pg_class ON pg_index.indexrelid = pg_class.oid WHERE pg_class.relname = 'idx_users_email';
Si el índice es inválido, dropealo y volvé a intentar:
DROP INDEX CONCURRENTLY IF EXISTS idx_users_email; -- Después reintentá CREATE INDEX CONCURRENTLY
2. No puede correr dentro de una transacción. No podés wrappear CREATE INDEX CONCURRENTLY en un bloque BEGIN...COMMIT. La mayoría de las herramientas de migración corren cada migración en una transacción por defecto: necesitás desactivar esto para creación concurrente de índices.
3. Tarda más. Como escanea la tabla dos veces y no mantiene un lock exclusivo, la creación concurrente tarda 2-3x más que un CREATE INDEX regular. En una tabla de 100 millones de filas, esto puede significar 30+ minutos.
Agregar NOT NULL constraints de forma segura
Agregar un NOT NULL constraint a una columna existente es engañosamente peligroso. PostgreSQL debe escanear la tabla entera para verificar que no existen valores NULL, y mantiene un ACCESS EXCLUSIVE lock mientras lo hace.
El patrón seguro
-- Step 1: Agregar CHECK constraint con NOT VALID (instantáneo) ALTER TABLE users ADD CONSTRAINT users_email_not_null CHECK (email IS NOT NULL) NOT VALID; -- Step 2: Validar el constraint en una transacción aparte -- Escanea la tabla pero solo adquiere un -- SHARE UPDATE EXCLUSIVE lock (permite reads Y writes) ALTER TABLE users VALIDATE CONSTRAINT users_email_not_null; -- Step 3 (opcional): Convertir a NOT NULL propio -- En PostgreSQL 12+, si existe un CHECK constraint válido, -- agregar NOT NULL es instantáneo ALTER TABLE users ALTER COLUMN email SET NOT NULL; -- Step 4: Dropear el CHECK constraint redundante ALTER TABLE users DROP CONSTRAINT users_email_not_null;
¿Por qué funciona? NOT VALID le dice a PostgreSQL "prometo que este constraint se cumple para filas nuevas, pero no verifiques las existentes todavía". El paso VALIDATE CONSTRAINT después verifica las existentes con un lock más débil que no bloquea reads ni writes.
Agregar Foreign Keys de forma segura
La creación de foreign keys es otro asesino silencioso. Por defecto, ALTER TABLE ... ADD CONSTRAINT ... FOREIGN KEY escanea ambas tablas (la que referencia y la referenciada) mientras mantiene locks.
-- ❌ PELIGROSO: Lockea ambas tablas durante la validación ALTER TABLE orders ADD CONSTRAINT fk_orders_user_id FOREIGN KEY (user_id) REFERENCES users(id); -- ✅ SEGURO: Enfoque en dos pasos -- Step 1: Agregar constraint sin validar (instantáneo) ALTER TABLE orders ADD CONSTRAINT fk_orders_user_id FOREIGN KEY (user_id) REFERENCES users(id) NOT VALID; -- Step 2: Validar aparte (lock más débil) ALTER TABLE orders VALIDATE CONSTRAINT fk_orders_user_id;
El mismo truco de NOT VALID. Las filas nuevas se validan inmediatamente, y las existentes se validan en un paso aparte con un lock menos restrictivo.
Cambiar tipos de columna
Cambiar el tipo de una columna (ej: INT a BIGINT, o VARCHAR(50) a VARCHAR(255)) típicamente requiere un rewrite completo de la tabla. En una tabla con millones de filas, esto significa minutos de downtime.
El enfoque Expand-Contract
-- Step 1: Agregar nueva columna con el tipo deseado ALTER TABLE orders ADD COLUMN amount_v2 BIGINT; -- Step 2: Crear trigger de sincronización CREATE OR REPLACE FUNCTION sync_amount_v2() RETURNS TRIGGER AS $$ BEGIN NEW.amount_v2 := NEW.amount; RETURN NEW; END; $$ LANGUAGE plpgsql; CREATE TRIGGER trigger_sync_amount_v2 BEFORE INSERT OR UPDATE ON orders FOR EACH ROW EXECUTE FUNCTION sync_amount_v2(); -- Step 3: Backfill (por lotes) UPDATE orders SET amount_v2 = amount WHERE id BETWEEN 1 AND 1000000; -- ... repetir para todos los rangos -- Step 4: Cambiar el código de la app para usar amount_v2 -- Step 5: Limpiar DROP TRIGGER trigger_sync_amount_v2 ON orders; DROP FUNCTION sync_amount_v2(); ALTER TABLE orders DROP COLUMN amount; ALTER TABLE orders RENAME COLUMN amount_v2 TO amount;
La excepción: Aumento de longitud VARCHAR
Algunos cambios de tipo son seguros porque no requieren rewrite de tabla:
-- ✅ SEGURO: Aumentar longitud VARCHAR (sin rewrite) ALTER TABLE users ALTER COLUMN name TYPE VARCHAR(255); -- ¡Solo seguro al aumentar, no al reducir! -- ✅ SEGURO: Eliminar límite VARCHAR completamente ALTER TABLE users ALTER COLUMN name TYPE TEXT; -- ✅ SEGURO: Cambiar VARCHAR a TEXT -- TEXT y VARCHAR se almacenan igual en PostgreSQL
Lock Timeout: Tu red de seguridad
Cada migración debería tener un lock timeout configurado. Sin él, una migración va a esperar indefinidamente por un lock, encolando todas las queries detrás.
-- Configurar 5 segundos de lock timeout para esta sesión SET lock_timeout = '5s'; -- Ahora intentar la migración ALTER TABLE users ADD COLUMN bio TEXT; -- Si no puede adquirir el lock en 5 segundos, -- PostgreSQL lanza un error en vez de esperar para siempre
En tus scripts de migración, siempre configurá lock timeout:
-- Al inicio de cada archivo de migración SET lock_timeout = '5s'; SET statement_timeout = '30s'; -- Tu DDL de migración acá ALTER TABLE users ADD COLUMN bio TEXT; -- Resetear por seguridad RESET lock_timeout; RESET statement_timeout;
Lógica de reintentos
Cuando usás lock timeouts, necesitás lógica de reintentos. La migración puede fallar porque una query de larga duración tenía un lock conflictivo. Está bien, reintentá después de una breve pausa:
async function safeMigrate(sql: string, maxRetries = 5): Promise<void> { for (let attempt = 1; attempt <= maxRetries; attempt++) { try { await db.query('SET lock_timeout = \'5s\''); await db.query(sql); console.log(`Migration succeeded on attempt ${attempt}`); return; } catch (error) { if (error.code === '55P03' && attempt < maxRetries) { // Lock timeout - esperar y reintentar console.log( `Lock timeout on attempt ${attempt}, ` + `retrying in ${attempt * 2}s...` ); await sleep(attempt * 2000); } else { throw error; } } } }
Backfills por lotes: el arte de mover datos
Cuando necesitás actualizar millones de filas existentes (ej: backfill de una nueva columna), hacerlo en un solo UPDATE es peligroso. Crea una transacción masiva que:
- Mantiene row-level locks en todas las filas afectadas
- Genera un volumen masivo de WAL (Write-Ahead Log)
- Puede causar lag de replicación
- Hincha la tabla (dead tuples que necesitan vacuum)
El patrón de lotes
-- Backfill en chunks de 10,000 filas DO $$ DECLARE batch_size INT := 10000; last_id BIGINT := 0; max_id BIGINT; rows_updated INT; BEGIN SELECT MAX(id) INTO max_id FROM orders; WHILE last_id < max_id LOOP UPDATE orders SET amount_cents = amount * 100 WHERE id > last_id AND id <= last_id + batch_size AND amount_cents IS NULL; GET DIAGNOSTICS rows_updated = ROW_COUNT; last_id := last_id + batch_size; RAISE NOTICE 'Processed up to id %, updated % rows', last_id, rows_updated; -- Pausa breve para que las réplicas se pongan al día -- y autovacuum procese dead tuples PERFORM pg_sleep(0.05); -- Commitear cada batch por separado COMMIT; END LOOP; END $$;
Monitoreando tu backfill
Mientras corre un backfill, monitoreá estas métricas:
-- Verificar lag de replicación (crucial para read replicas) SELECT client_addr, state, sent_lsn, write_lsn, flush_lsn, replay_lsn, pg_wal_lsn_diff(sent_lsn, replay_lsn) AS replay_lag_bytes FROM pg_stat_replication; -- Verificar bloat de tabla (dead tuples acumulándose) SELECT relname, n_live_tup, n_dead_tup, round(n_dead_tup::numeric / GREATEST(n_live_tup, 1) * 100, 2) AS dead_pct, last_autovacuum FROM pg_stat_user_tables WHERE relname = 'orders'; -- Verificar queries de larga duración que podrían conflictuar SELECT pid, now() - query_start AS duration, state, left(query, 100) AS query_preview FROM pg_stat_activity WHERE state != 'idle' AND query_start < now() - interval '30 seconds' ORDER BY duration DESC;
Herramientas de migración para producción
pgroll: Herramienta de migración sin downtime
pgroll es una herramienta de migración de esquema diseñada específicamente para cambios sin downtime. Implementa automáticamente el patrón expand-contract:
{ "name": "add_display_name", "operations": [ { "rename_column": { "table": "users", "from": "full_name", "to": "display_name" } } ] }
pgroll se encarga de la fase de expansión (crear vistas, triggers y columnas temporales), la fase de migración, y la fase de contracción automáticamente. Crea vistas de esquema versionadas para que versiones viejas y nuevas de la aplicación puedan coexistir.
Reshape
Reshape sigue una filosofía similar: migraciones declarativas y sin downtime con expand-contract automático:
[[actions]] type = "alter_column" table = "users" column = "full_name" [actions.changes] name = "display_name"
sqitch + Scripts personalizados
Para equipos que prefieren más control, sqitch combinado con scripts personalizados ofrece una alternativa liviana:
# workflow de sqitch sqitch add rename-user-column \ -n "Rename full_name to display_name (phase 1: expand)" sqitch deploy sqitch verify
Herramientas específicas por framework
| Framework | Herramienta | Soporte Zero-Downtime |
|---|---|---|
| Rails | strong_migrations gem | Bloquea operaciones peligrosas, sugiere alternativas seguras |
| Django | django-pg-zero-downtime-migrations | Agrega lock timeouts y patrones seguros |
| Laravel | Sin soporte built-in | Patrones manuales requeridos |
| Node.js/TypeScript | graphile-migrate, node-pg-migrate | Buen control, patrones manuales |
| Go | goose, atlas | Atlas tiene migraciones declarativas con verificaciones de seguridad |
Playbook de migraciones del mundo real
Acá va un playbook completo y copy-paste para las migraciones de producción más comunes:
Playbook 1: Agregar una nueva columna requerida con default
-- Step 1: Agregar columna nullable (instantáneo) SET lock_timeout = '5s'; ALTER TABLE users ADD COLUMN subscription_tier TEXT; -- Step 2: Backfill de filas existentes DO $$ DECLARE batch_size INT := 5000; rows_updated INT; BEGIN LOOP UPDATE users SET subscription_tier = 'free' WHERE subscription_tier IS NULL AND id IN ( SELECT id FROM users WHERE subscription_tier IS NULL LIMIT batch_size FOR UPDATE SKIP LOCKED ); GET DIAGNOSTICS rows_updated = ROW_COUNT; EXIT WHEN rows_updated = 0; COMMIT; PERFORM pg_sleep(0.05); END LOOP; END $$; -- Step 3: Agregar NOT NULL constraint de forma segura ALTER TABLE users ADD CONSTRAINT users_subscription_tier_not_null CHECK (subscription_tier IS NOT NULL) NOT VALID; ALTER TABLE users VALIDATE CONSTRAINT users_subscription_tier_not_null; -- Step 4: Setear NOT NULL (instantáneo con CHECK constraint válido) ALTER TABLE users ALTER COLUMN subscription_tier SET NOT NULL; -- Step 5: Setear default para filas futuras ALTER TABLE users ALTER COLUMN subscription_tier SET DEFAULT 'free'; -- Step 6: Limpiar CHECK constraint ALTER TABLE users DROP CONSTRAINT users_subscription_tier_not_null;
Playbook 2: Crear índice en tabla grande
-- Step 1: Crear índice concurrently SET statement_timeout = '0'; CREATE INDEX CONCURRENTLY idx_orders_customer_created ON orders(customer_id, created_at DESC); -- Step 2: Verificar que el índice sea válido DO $$ BEGIN IF NOT EXISTS ( SELECT 1 FROM pg_index i JOIN pg_class c ON i.indexrelid = c.oid WHERE c.relname = 'idx_orders_customer_created' AND i.indisvalid = true ) THEN RAISE EXCEPTION 'Index is invalid! Drop and retry.'; END IF; END $$;
Playbook 3: Reemplazar una tabla (cambio de esquema completo)
-- Cuando los cambios son tan extensos que expand-contract -- en columnas individuales no tiene sentido -- Step 1: Crear nueva tabla con esquema deseado CREATE TABLE users_v2 ( id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, display_name TEXT NOT NULL, email TEXT NOT NULL UNIQUE, subscription_tier TEXT NOT NULL DEFAULT 'free', created_at TIMESTAMPTZ NOT NULL DEFAULT now() ); -- Step 2: Crear índices en la nueva tabla CREATE INDEX idx_users_v2_email ON users_v2(email); CREATE INDEX idx_users_v2_created ON users_v2(created_at); -- Step 3: Copiar datos en lotes (similar al patrón de backfill) -- Step 4: Crear trigger en la tabla vieja para sincronizar -- filas nuevas a users_v2 -- Step 5: Swapear tablas atómicamente BEGIN; ALTER TABLE users RENAME TO users_old; ALTER TABLE users_v2 RENAME TO users; COMMIT; -- Step 6: Actualizar sequences, foreign keys, etc. -- Step 7: Dropear tabla vieja después del período de verificación
El checklist pre-migración
Antes de correr cualquier migración en producción:
1. Verificar locks activos y queries de larga duración
-- Matar queries corriendo hace más de 5 minutos en la tabla target SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE query ILIKE '%your_table%' AND state != 'idle' AND query_start < now() - interval '5 minutes';
2. Testear en un dataset del tamaño de producción
Nunca testees migraciones solo en tu base de desarrollo. Creá un entorno de staging con datos a escala de producción.
3. Configurar timeouts
SET lock_timeout = '5s'; SET statement_timeout = '30m'; -- para backfills largos
4. Tener un plan de rollback
Cada migración debería tener un rollback testeado:
-- migration.sql ALTER TABLE users ADD COLUMN bio TEXT; -- rollback.sql ALTER TABLE users DROP COLUMN IF EXISTS bio;
5. Monitorear durante el deployment
-- Observar contención de locks en tiempo real SELECT blocked.pid AS blocked_pid, blocked.query AS blocked_query, blocking.pid AS blocking_pid, blocking.query AS blocking_query, now() - blocked.query_start AS blocked_duration FROM pg_stat_activity blocked JOIN pg_locks blocked_locks ON blocked.pid = blocked_locks.pid JOIN pg_locks blocking_locks ON blocked_locks.locktype = blocking_locks.locktype AND blocked_locks.database = blocking_locks.database AND blocked_locks.relation = blocking_locks.relation AND blocked_locks.pid != blocking_locks.pid JOIN pg_stat_activity blocking ON blocking_locks.pid = blocking.pid WHERE NOT blocked_locks.granted;
Errores comunes y cómo evitarlos
Error 1: Correr migraciones durante pico de tráfico
Programá las migraciones de esquema para tu ventana de menor tráfico. Incluso las migraciones "seguras" se benefician de menor concurrencia.
Error 2: No testear el rollback
Cada rollback de migración debería estar testeado. "Simplemente dropeamos la columna" es un plan de rollback que destruye datos. Considerá si necesitás preservar datos durante el rollback.
Error 3: Olvidarte del ORM
Tu ORM puede generar SQL que referencie columnas por nombre. Cuando usás expand-contract, asegurate de que tu versión del ORM pueda manejar el estado transicional (ambas columnas, vieja y nueva, existiendo).
Error 4: Ignorar lag de replicación
Si usás read replicas, los cambios de esquema se propagan vía replicación. Un backfill que escribe 10 millones de filas puede causar lag significativo, haciendo que tus replicas devuelvan datos desactualizados.
Solución: Monitoreá pg_stat_replication durante los backfills y hacé throttling si el lag supera tu umbral:
async function throttledBackfill(batchSize: number) { while (true) { const lag = await getReplicationLag(); if (lag > MAX_LAG_BYTES) { console.log(`Replication lag ${lag} bytes, pausing...`); await sleep(5000); continue; } const updated = await updateBatch(batchSize); if (updated === 0) break; await sleep(50); } }
Error 5: Deployar código de la app y migración simultáneamente
El código de la app y la migración deben deployarse en pasos separados. Deployá la migración primero. Verificá que fue exitosa. Después deployá el código que usa el nuevo esquema. Este desacople es esencial para rollbacks seguros.
Checklist de seguridad de migración
Antes de cada migración en producción:
- Lock timeout configurado (
SET lock_timeout = '5s') - Statement timeout configurado para operaciones largas
- Migración testeada en dataset de tamaño producción
- Script de rollback escrito y testeado
- Sin deploys concurrentes ni ventanas de mantenimiento
- Monitoreo de lag de replicación activo
- Backfill usa updates por lotes (no un solo UPDATE)
- Índices creados con CONCURRENTLY
- NOT NULL constraints agregados via CHECK + VALIDATE
- Foreign keys agregadas con NOT VALID + VALIDATE
- Código de la app es backward-compatible con el esquema viejo
- Ingeniero de guardia notificado de la migración
- Tráfico en su punto más bajo (si es posible)
Las migraciones de esquema no tienen por qué dar miedo. Los patrones de esta guía fueron testeados en batalla en bases de datos que sirven millones de requests por día. El insight clave es simple: nunca hagas un cambio que rompa en un solo paso. Expandí primero, contraé después. Configurá timeouts. Backfilleá por lotes. Monitoreá todo.
Tu base de datos es la base de tu aplicación. Tratá los cambios de esquema con el mismo cuidado que le darías a una cirugía a corazón abierto: planificación cuidadosa, ejecución precisa y monitoreo constante.
Explora herramientas relacionadas
Prueba estas herramientas gratuitas de Pockit