Saltar al contenido

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.

4 min de lectura
Cronología de una migración de base de datos que muestra la fase de expansión añadiendo nuevas columnas mientras coexisten las versiones antigua y nueva de la aplicación

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.

tstypescript
// 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.

sqlsql
-- ❌ 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;
tstypescript
// 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.

sqlsql
-- ❌ 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);
tstypescript
// 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.

tstypescript
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.

tstypescript
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.

Wilfredo Rujel

Wilfredo Rujel

Ingeniero de Software Full Stack

Compartir esta publicaciónX