Migraciones de base de datos sin tiempo de inactividad en producción
Ejecuta migraciones de esquema en producción sin downtime: expandir-contraer, cambios retrocompatibles, datos blue-green y comprobaciones de seguridad.

Por qué las migraciones rompen producción
Un ALTER TABLE directo puede bloquear una tabla durante minutos en conjuntos grandes de datos. Renombrar una columna rompe todas las consultas que referencian el nombre antiguo. Eliminar una columna mientras aún ejecuta la versión anterior de la aplicación causa errores 500. Las migraciones sin tiempo de inactividad requieren cambios compatibles hacia atrás en cada paso.
El patrón expandir-contraer
Cada cambio disruptivo se convierte en una serie de pasos no disruptivos: expandir (añadir la nueva estructura junto a la antigua), migrar (copiar los datos), contraer (eliminar la estructura antigua). En ningún momento la versión anterior de la aplicación deja de funcionar.
// BREAKING: Rename column in one step — old app crashes immediately
// ❌ ALTER TABLE users RENAME COLUMN name TO full_name;
// NON-BREAKING: Expand-contract over 3 deployments
// Step 1 (expand): Add new column, dual-write
// Step 2 (migrate): Backfill data, switch reads
// Step 3 (contract): Remove old column
interface MigrationStep {
phase: "expand" | "migrate" | "contract";
sql: string;
requiresDeployment: boolean;
backwardCompatible: boolean;
}
const renameColumnMigration: MigrationStep[] = [
{
phase: "expand",
sql: `
ALTER TABLE users ADD COLUMN full_name VARCHAR(255);
-- Trigger for dual-write: old writes update new column too
CREATE OR REPLACE FUNCTION sync_name_to_full_name()
RETURNS TRIGGER AS $$
BEGIN
NEW.full_name = COALESCE(NEW.full_name, NEW.name);
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trg_sync_name
BEFORE INSERT OR UPDATE ON users
FOR EACH ROW EXECUTE FUNCTION sync_name_to_full_name();
`,
requiresDeployment: false,
backwardCompatible: true,
},
{
phase: "migrate",
sql: `
-- Backfill existing rows in batches
UPDATE users SET full_name = name
WHERE full_name IS NULL
LIMIT 10000;
-- Run repeatedly until no rows remain
`,
requiresDeployment: true, // Deploy app reading from full_name
backwardCompatible: true,
},
{
phase: "contract",
sql: `
DROP TRIGGER trg_sync_name ON users;
DROP FUNCTION sync_name_to_full_name();
ALTER TABLE users DROP COLUMN name;
`,
requiresDeployment: false,
backwardCompatible: false, // Old app must no longer be running
},
];Adiciones seguras de columnas
Añadir una columna nullable sin un valor por defecto siempre es seguro. Añadir una columna con un valor por defecto puede bloquear la tabla en versiones antiguas de PostgreSQL. Usa el patrón seguro.
-- ❌ Locks table while rewriting all rows (PostgreSQL < 11)
ALTER TABLE orders ADD COLUMN priority INTEGER NOT NULL DEFAULT 0;
-- ✅ Two-step: add nullable, then set default separately
ALTER TABLE orders ADD COLUMN priority INTEGER;
ALTER TABLE orders ALTER COLUMN priority SET DEFAULT 0;
-- Backfill in batches (no table lock)
UPDATE orders SET priority = 0
WHERE priority IS NULL AND id IN (
SELECT id FROM orders WHERE priority IS NULL LIMIT 10000
);
-- After backfill complete, add NOT NULL constraint
ALTER TABLE orders ALTER COLUMN priority SET NOT NULL;// Batched backfill to avoid long-running transactions
async function backfillColumn(
db: Database,
table: string,
column: string,
defaultValue: unknown,
batchSize: number = 10_000
): Promise<{ totalUpdated: number }> {
let totalUpdated = 0;
while (true) {
const result = await db.query(
`UPDATE ${table} SET ${column} = $1
WHERE ${column} IS NULL
AND id IN (
SELECT id FROM ${table}
WHERE ${column} IS NULL
LIMIT $2
)`,
[defaultValue, batchSize]
);
totalUpdated += result.rowCount;
if (result.rowCount === 0) break;
// Small delay between batches to reduce load
await new Promise((r) => setTimeout(r, 100));
}
return { totalUpdated };
}Creación de índices sin bloqueos
El CREATE INDEX estándar bloquea la tabla para escrituras. Usa CONCURRENTLY para construir el índice en segundo plano.
-- ❌ Locks table for the duration of index creation
CREATE INDEX idx_orders_status ON orders (status);
-- ✅ Builds index without blocking writes
CREATE INDEX CONCURRENTLY idx_orders_status ON orders (status);// Migration runner that validates index creation
async function createIndexSafely(
db: Database,
indexName: string,
table: string,
columns: string[]
): Promise<void> {
const columnList = columns.join(", ");
await db.query(
`CREATE INDEX CONCURRENTLY IF NOT EXISTS ${indexName}
ON ${table} (${columnList})`
);
// Verify index is valid (CONCURRENTLY can produce invalid indexes)
const result = await db.query(
`SELECT indisvalid FROM pg_index
WHERE indexrelid = $1::regclass`,
[indexName]
);
if (!result.rows[0]?.indisvalid) {
// Drop invalid index and retry
await db.query(`DROP INDEX IF EXISTS ${indexName}`);
throw new Error(
`Index ${indexName} was created in invalid state — dropped and needs retry`
);
}
}Comprobaciones de seguridad en migraciones
Automatiza comprobaciones que detecten migraciones peligrosas antes de que lleguen a producción.
interface MigrationSafetyCheck {
name: string;
check: (sql: string) => { safe: boolean; reason?: string };
}
const safetyChecks: MigrationSafetyCheck[] = [
{
name: "no-drop-column-without-feature-flag",
check: (sql) => {
if (/DROP\s+COLUMN/i.test(sql)) {
return {
safe: false,
reason: "DROP COLUMN requires expand-contract pattern. " +
"Use feature flag to stop reading the column first.",
};
}
return { safe: true };
},
},
{
name: "no-rename-column",
check: (sql) => {
if (/RENAME\s+COLUMN/i.test(sql)) {
return {
safe: false,
reason: "RENAME COLUMN breaks running application. " +
"Use add-copy-drop pattern instead.",
};
}
return { safe: true };
},
},
{
name: "no-not-null-without-default",
check: (sql) => {
if (/SET\s+NOT\s+NULL/i.test(sql) && !/SET\s+DEFAULT/i.test(sql)) {
return {
safe: false,
reason: "SET NOT NULL on existing column requires backfill first. " +
"Ensure all rows have values before adding constraint.",
};
}
return { safe: true };
},
},
{
name: "index-concurrently",
check: (sql) => {
if (/CREATE\s+INDEX(?!\s+CONCURRENTLY)/i.test(sql)) {
return {
safe: false,
reason: "CREATE INDEX without CONCURRENTLY locks the table. " +
"Use CREATE INDEX CONCURRENTLY.",
};
}
return { safe: true };
},
},
];
function validateMigration(sql: string): {
safe: boolean;
violations: string[];
} {
const violations = safetyChecks
.map((check) => check.check(sql))
.filter((result) => !result.safe)
.map((result) => result.reason!);
return { safe: violations.length === 0, violations };
}Revertir migraciones
No todas las migraciones son reversibles. Planifica estrategias de rollback antes de ejecutar la migración.
interface MigrationWithRollback {
version: string;
description: string;
up: string;
down: string | null; // null = irreversible
preConditions: string[];
estimatedDuration: string;
}
const migration: MigrationWithRollback = {
version: "20251111_001",
description: "Add priority column to orders",
up: `
ALTER TABLE orders ADD COLUMN priority INTEGER;
ALTER TABLE orders ALTER COLUMN priority SET DEFAULT 0;
`,
down: `
ALTER TABLE orders DROP COLUMN IF EXISTS priority;
`,
preConditions: [
"No application code references 'priority' column yet",
"Table has fewer than 50M rows (for safe ALTER)",
],
estimatedDuration: "< 1 second (nullable column add)",
};Conclusiones clave
Las migraciones de base de datos sin tiempo de inactividad exigen que cada cambio sea compatible hacia atrás con la aplicación que está ejecutándose. Usa el patrón expandir-contraer: añade la nueva estructura, migra los datos y elimina la estructura antigua a lo largo de despliegues separados. Nunca renombres ni elimines columnas en un solo paso.
Añade primero columnas nullable, rellena los datos por lotes y luego añade las restricciones. Crea los índices con CONCURRENTLY para evitar bloqueos en la tabla. Automatiza comprobaciones de seguridad en CI para detectar patrones peligrosos antes de que lleguen a producción. Planifica estrategias de rollback antes de ejecutar las migraciones: algunos cambios son irreversibles, y saberlo de antemano cambia la forma de abordar la migración. La regla es simple: en cada paso, tanto la versión antigua como la nueva de la aplicación deben funcionar correctamente contra el esquema actual de la base de datos.


