Schema migration strategy — Postgres zero-downtime¶
Estrategia para evolución del schema sin downtime. Patterns: expand/contract, online migrations, partitioning, índice concurrente. Tooling: golang-migrate + scripts custom. Cobertura M1-M4.
Premisa¶
Workflow engine en producción no puede tener downtime para migraciones. Postgres permite zero-downtime migrations si seguís reglas. La clave: expand → migrate → contract en 3 deploys.
Categorías de migración por riesgo¶
| Tipo | Lock requerido | Downtime | Estrategia |
|---|---|---|---|
ADD COLUMN (nullable) |
AccessShareLock (read) | 0 | Direct |
ADD COLUMN (default no-null en PG 11+) |
AccessShareLock | 0 | Direct |
DROP COLUMN |
AccessExclusiveLock | bloquea writes | Expand/contract |
RENAME COLUMN |
AccessExclusiveLock | bloquea writes | Expand/contract |
ALTER COLUMN TYPE |
AccessExclusiveLock + rewrite | bloquea writes prolongado | Expand/contract o online |
ADD INDEX |
ShareLock (bloquea writes) | bloquea writes | CONCURRENTLY |
DROP INDEX |
AccessExclusiveLock | breve | CONCURRENTLY |
ADD FOREIGN KEY |
ShareRowExclusiveLock | bloquea writes | NOT VALID + validate |
ADD CHECK CONSTRAINT |
AccessExclusiveLock | bloquea writes | NOT VALID + validate |
CREATE TABLE |
None | 0 | Direct |
DROP TABLE |
AccessExclusiveLock | brief | Expand/contract |
Regla: cualquier operación con AccessExclusiveLock puede bloquear writes (y reads en PG < 16) durante segundos a minutos. Bloquea writes ⇒ engine no puede procesar comandos.
Tooling: golang-migrate¶
Estructura:
migrations/
├── 000001_initial.up.sql
├── 000001_initial.down.sql
├── 000002_add_audit_trail.up.sql
├── 000002_add_audit_trail.down.sql
├── 000003_partition_command_log.up.sql
└── 000003_partition_command_log.down.sql
Reglas:
- Cada migración es forward-only en producción (down sólo para tests).
- Cada migración es idempotent (IF NOT EXISTS, etc.).
- Pre-flight check: cada migración tiene --lock-timeout/--statement-timeout.
-- 000004_add_priority_column.up.sql
SET lock_timeout = '5s';
SET statement_timeout = '60s';
ALTER TABLE jobs ADD COLUMN IF NOT EXISTS priority INTEGER NOT NULL DEFAULT 0;
Si la migración no puede adquirir lock en 5s, falla y se reintenta más tarde. Evita esperar indefinido.
Pattern: Expand / Migrate / Contract (E/M/C)¶
Para cambios que requerirían un lock pesado:
Fase 1: Expand (deploy 1)¶
Agregar nueva versión SIN romper la vieja.
-- 000010_expand_add_new_status.up.sql
ALTER TABLE jobs ADD COLUMN status_v2 TEXT; -- nullable, no FK
-- Trigger para mantener sincronizado
CREATE OR REPLACE FUNCTION sync_job_status() RETURNS trigger AS $$
BEGIN
NEW.status_v2 = CASE NEW.status
WHEN 'ACTIVATABLE' THEN 'pending'
WHEN 'ACTIVATED' THEN 'active'
WHEN 'COMPLETED' THEN 'completed'
...
END;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER sync_status_v2
BEFORE INSERT OR UPDATE ON jobs
FOR EACH ROW EXECUTE FUNCTION sync_job_status();
App code: lee de status (legacy), trigger pobla status_v2.
Fase 2: Backfill (offline o online)¶
-- Backfill in batches (online)
DO $$
DECLARE
batch_size INT := 10000;
rows_updated INT;
BEGIN
LOOP
UPDATE jobs
SET status_v2 = CASE status
WHEN 'ACTIVATABLE' THEN 'pending'
...
END
WHERE status_v2 IS NULL
AND key IN (
SELECT key FROM jobs WHERE status_v2 IS NULL LIMIT batch_size
);
GET DIAGNOSTICS rows_updated = ROW_COUNT;
EXIT WHEN rows_updated = 0;
PERFORM pg_sleep(0.1); -- yield
END LOOP;
END $$;
Throughput: ~10K rows / 100ms = 100K rows/s. 100M rows = 16min sin bloqueo.
Fase 3: Migrate (deploy 2)¶
App code: lee/escribe status_v2, mantiene status por compatibilidad.
// pseudo-code
func writeJob(j Job) {
db.Exec("INSERT INTO jobs (status, status_v2) VALUES ($1, $2)",
legacyStatus(j.Status), j.Status)
}
func readJob(key int64) Job {
var statusV2 sql.NullString
db.QueryRow("SELECT status_v2 FROM jobs WHERE key=$1", key).Scan(&statusV2)
if statusV2.Valid {
return Job{Status: statusV2.String}
}
// Fallback durante backfill
var statusLegacy string
db.QueryRow("SELECT status FROM jobs WHERE key=$1", key).Scan(&statusLegacy)
return Job{Status: mapLegacy(statusLegacy)}
}
Fase 4: Contract (deploy 3)¶
Eliminar el legacy.
-- 000012_contract_remove_legacy_status.up.sql
DROP TRIGGER sync_status_v2 ON jobs;
DROP FUNCTION sync_job_status();
ALTER TABLE jobs DROP COLUMN status;
ALTER TABLE jobs RENAME COLUMN status_v2 TO status;
ALTER TABLE jobs ALTER COLUMN status SET NOT NULL;
App code: usa sólo status.
Reglas¶
- Cada fase es deploy independiente. No mezclar.
- Entre fases, app code soporta ambos estados (compat).
- Si el deploy de la fase 2 falla, podés rollback al fase 1.
Patterns específicos¶
Add NOT NULL column¶
-- BIEN (PG 11+): default sin rewrite
ALTER TABLE x ADD COLUMN y TEXT NOT NULL DEFAULT 'default-value';
-- MAL (PG < 11): rewrite completo
-- Usar E/M/C en su lugar
Add foreign key¶
-- Fase 1: agregar sin validar
ALTER TABLE jobs ADD CONSTRAINT jobs_instance_fk
FOREIGN KEY (process_instance_key) REFERENCES process_instances(key)
NOT VALID;
-- Fase 2: validar online
ALTER TABLE jobs VALIDATE CONSTRAINT jobs_instance_fk;
VALIDATE toma ShareUpdateExclusiveLock (no bloquea writes). Puede tardar pero no down.
Add CHECK constraint¶
ALTER TABLE jobs ADD CONSTRAINT jobs_retries_positive CHECK (retries >= 0) NOT VALID;
ALTER TABLE jobs VALIDATE CONSTRAINT jobs_retries_positive;
Add index¶
-- BIEN
CREATE INDEX CONCURRENTLY idx_jobs_type_state ON jobs (type, state);
-- MAL
CREATE INDEX idx_jobs_type_state ON jobs (type, state); -- bloquea writes
CONCURRENTLY no se puede ejecutar en transaction; separar:
-- 000020.up.sql
COMMIT; -- finalizar tx implícita
CREATE INDEX CONCURRENTLY idx_jobs_type_state ON jobs (type, state);
BEGIN; -- reabrir para próximas operations
Si falla a mitad: hay un index inválido. Detectar:
SELECT relname FROM pg_class
WHERE relname = 'idx_jobs_type_state' AND NOT relisvalid;
-- Si retorna fila: drop y reintentar
DROP INDEX CONCURRENTLY IF EXISTS idx_jobs_type_state;
Drop column¶
-- E/M/C:
-- Fase 1: app deja de usar la columna.
-- Fase 2: app deploy.
-- Fase 3 (deploy 2): drop column (rápido, sólo cataloga).
ALTER TABLE jobs DROP COLUMN deprecated_field;
Rename table¶
-- BIEN: crear view legacy
CREATE VIEW jobs_old AS SELECT * FROM jobs_new;
-- App migrates progressively
-- Eventually: DROP VIEW
Partition existing table¶
-- Para tablas grandes (commands log, audit log)
-- 1. Crear partitioned table nueva
CREATE TABLE commands_partitioned (LIKE commands INCLUDING ALL) PARTITION BY RANGE (created_at);
-- 2. Crear partitions
CREATE TABLE commands_2026_05 PARTITION OF commands_partitioned
FOR VALUES FROM ('2026-05-01') TO ('2026-06-01');
-- 3. Copy data en batches
INSERT INTO commands_partitioned SELECT * FROM commands;
-- 4. Swap (atomic)
BEGIN;
ALTER TABLE commands RENAME TO commands_old;
ALTER TABLE commands_partitioned RENAME TO commands;
COMMIT;
-- 5. Drop old (después de verificación)
DROP TABLE commands_old;
Ver concepts/command-log-compaction para partitioning de command log.
Estrategia para tablas grandes (>100M rows)¶
Approach: logical replication + table swap.
- Crear nueva tabla con esquema target.
- Setup logical replication slot copiando data + WAL.
- Esperar replica esté al día (< 1s lag).
- Stop writes ~5s, swap (rename), restart.
Tools: pg_repack, pglogical.
Multi-tenant migration considerations¶
Si tabla es per-tenant (RLS o tenant_id):
-- Backfill por tenant para reducir lock pressure
DO $$
DECLARE
t_id TEXT;
BEGIN
FOR t_id IN SELECT DISTINCT tenant_id FROM jobs LOOP
UPDATE jobs SET status_v2 = ... WHERE tenant_id = t_id AND status_v2 IS NULL;
PERFORM pg_sleep(0.5); -- yield entre tenants
END LOOP;
END $$;
Citus / sharded environment (Phase 4+)¶
En Citus:
-- Ejecutar en coordinator; propaga a workers
SET citus.multi_shard_modify_mode TO 'sequential'; -- evita deadlocks
ALTER TABLE jobs ADD COLUMN priority INT DEFAULT 0;
Algunas operaciones (cambiar distribution column) requieren undistribute_table() + redistribute.
CI/CD integration¶
Validation pre-merge¶
# .github/workflows/migration-check.yml
- name: Lint migrations
run: |
migrate -path migrations -database "postgres://test" up
sqlfluff lint migrations/
- name: Check for risky operations
run: |
grep -E 'ALTER TABLE .* RENAME|DROP COLUMN' migrations/*.up.sql && \
echo "::warning::Risky operation detected, ensure E/M/C plan"
Deployment pipeline¶
1. PR merged → migration applied to staging
2. Staging smoke tests pass
3. Migration applied to prod via change-management approval
4. App deploy (matching app version) after migration succeeds
Migration y app deploy son deploys separados. Migration siempre primero.
Rollback¶
down.sqlsolo para dev/tests.- Producción: rollback es nuevo migration que revierta (forward-only).
- Para rollback de columna agregada: drop column (siempre safe).
- Para rollback de columna dropped: re-create + backfill (datos perdidos si no hay logical backup).
Observability¶
wf_db_migration_started_total{version}
wf_db_migration_completed_total{version, outcome="success|failed"}
wf_db_migration_duration_seconds{version}
wf_db_locks_held_seconds{lock_type}
Alerta: wf_db_locks_held_seconds > 5 durante deploy → potencial corruption.
Anti-patterns documentados¶
❌ ALTER TABLE ... TYPE en columna grande¶
-- MAL: rewrite completo de la tabla, bloquea horas
ALTER TABLE jobs ALTER COLUMN data TYPE BYTEA USING data::bytea;
Mejor: E/M/C con nueva columna data_bytea.
❌ Migration en transaction larga¶
Si la 50ª falla, las 49 se revierten. Lock dura todo el camino. Splittear en migrations independientes.
❌ Migration sin lock_timeout¶
Siempre SET lock_timeout.
❌ Backfill sin batching¶
-- MAL: lock pesado en una sola transacción
UPDATE jobs SET status_v2 = ... WHERE status_v2 IS NULL;
Checklist por migración¶
-
SET lock_timeout = '5s'al inicio. -
IF NOT EXISTS/IF EXISTSpara idempotencia. - Si DROP / RENAME / TYPE: planificado como E/M/C en 3 deploys.
- Índices con
CONCURRENTLY. - FKs y CHECKs con
NOT VALID+VALIDATE. - Backfill en batches con yield.
- Migration probada en staging con dataset proporcional.
- Runbook si falla mitad de camino.
- Verificación post-migration (índices válidos, row count, etc.).
Roadmap¶
- M1: golang-migrate setup, schema básico, RLS migrations.
- M2: Partitioning automation (
pg_partmanintegration). - M3: Migration linting + risk assessment automation.
- M4: Citus-aware migrations.
Referencias¶
- adrs/adr-002-postgresql-as-state-store — Postgres base
- adrs/adr-020-patroni-postgres-ha — HA durante migraciones
- adrs/adr-021-citus-horizontal-scaling — Citus considerations
- concepts/command-log-compaction — partitioning command log
- Strong migrations guide (Andrew Kane)
- GoCardless: Zero downtime Postgres migrations
- PostgreSQL docs: ALTER TABLE locks