StackPractices
advanced Por Mathias Paulenko

Migración de Datos: Estrategias Zero-Downtime que Funcionan

Planifica, duplica escrituras, rellena históricos y valida una migración de datos sin interrupción — con evolución de esquemas y rollback.

Visión general

Un despliegue malo se revierte en minutos. Una migración de datos mala, no — una vez que transformaste o borraste filas, la única vuelta atrás es otra migración, o una restauración que pierde todo lo escrito desde entonces. Por eso los equipos que lo hacen bien tratan la migración como un proceso de semanas, no como un script que corres un viernes por la noche.

Esta guía recorre el patrón que sobrevive a producción: expandir el esquema, escribir en ambos sistemas, rellenar en lotes reanudables, validar como si no confiaras en tu propio código, leer en sombra y después cortar el tráfico detrás de un feature flag. En el camino: Python y SQL reales para las partes donde todos fallan.

Mermaid flowchart LR diagram

Cuándo usarla

Usa estos patrones cuando:

  • Cambias de base de datos (MySQL → PostgreSQL, on-prem → cloud)
  • Reestructuras tablas o normalizas/desnormalizas datos
  • Incorporas un nuevo datastore (Elasticsearch, Redis, un data warehouse)
  • Shardas una base de datos existente
  • Migras un sistema legado a una plataforma moderna
  • Divides o fusionas servicios que poseen sus propios datastores

Para la parte operativa — checklists, plan de comunicación, sign-off — la plantilla de runbook de migración convierte esta guía en un documento rellenable.

Conceptos clave

ConceptoDescripción
Doble escrituraEscribir en el sistema viejo y el nuevo simultáneamente
BackfillPoblar el nuevo datastore con los datos históricos
Lectura en sombraLeer del nuevo sistema comparando con el viejo
CutoverCambiar lecturas y escrituras del viejo al nuevo
Ventana de rollbackEl tiempo durante el que puedes revertir sin perder datos
IdempotenciaCorrer la misma migración dos veces da el mismo resultado

Migración sin interrupción, paso a paso

1. Planifica la migración

Documenta cada paso antes de tocar datos de producción:

## Plan de Migración: Normalización de la tabla Users

**Objetivo:** dividir `users` en `users` + `user_profiles`
**Plazo:** 3 semanas
**Ventana de rollback:** 48 horas tras el cutover

### Fase 1: Cambios de esquema (semana 1)
- [ ] Añadir tabla `user_profiles`
- [ ] Añadir foreign key `users.profile_id`
- [ ] Desplegar código que escriba en ambas tablas
- [ ] Verificar que ambas escrituras funcionan

### Fase 2: Backfill (semanas 1-2)
- [ ] Correr el script de backfill en lotes (1000 filas/lote)
- [ ] Vigilar progreso y tasa de error
- [ ] Verificar completitud con conteos y checksums

### Fase 3: Lecturas en sombra (semana 2)
- [ ] Habilitar lectura de `user_profiles` en paralelo
- [ ] Comparar resultados viejo vs nuevo (registrar discrepancias)
- [ ] Corregir discrepancias de datos

### Fase 4: Cutover (semana 3)
- [ ] Cambiar lecturas a `user_profiles`
- [ ] Vigilar tasas de error 24 horas
- [ ] Eliminar el código de doble escritura
- [ ] Borrar columnas viejas (tras la ventana de rollback)

### Checklist de validación
- [ ] Conteos iguales: `SELECT COUNT(*) FROM users` == `SELECT COUNT(*) FROM user_profiles`
- [ ] Muestra de datos: 100 usuarios aleatorios comparados campo a campo
- [ ] Tests de integración de la app
- [ ] Tests de rendimiento (las nuevas queries responden a tiempo)

### Plan de rollback
- [ ] Problemas antes de 48h: volver las lecturas al esquema viejo
- [ ] Problemas después: escribir una migración de corrección (rollback imposible)

2. Implementa la doble escritura

Escribe en ambos sistemas durante la transición — el viejo sigue siendo la fuente de verdad hasta el cutover:

# Doble escritura durante la migración
import logging

logger = logging.getLogger(__name__)

class UserRepository:
    def __init__(self, old_db, new_db):
        self.old_db = old_db
        self.new_db = new_db

    def create_user(self, user_data):
        # El sistema viejo sigue siendo la fuente de verdad
        user_id = self.old_db.users.insert(user_data)

        # Sistema nuevo: best effort — un fallo aquí no debe tumbar la petición
        try:
            self.new_db.user_profiles.insert({
                'user_id': user_id,
                'display_name': user_data['name'],
                'bio': user_data.get('bio', ''),
                'created_at': user_data['created_at']
            })
        except Exception as e:
            logger.error("Falló la doble escritura", extra={
                'user_id': user_id,
                'error': str(e)
            })

        return user_id

    def get_user(self, user_id):
        # Fase de lectura en sombra: probar el nuevo, caer al viejo
        try:
            profile = self.new_db.user_profiles.find_by_user_id(user_id)
            if profile:
                return self._convert_profile_to_user(profile)
        except Exception:
            pass

        return self.old_db.users.find_by_id(user_id)
# Script de backfill: por lotes, reanudable, con freno
import time

class BackfillUsers:
    def __init__(self, old_db, new_db):
        self.old_db = old_db
        self.new_db = new_db
        self.batch_size = 1000

    def run(self):
        last_id = self._get_checkpoint()

        while True:
            batch = self.old_db.users.find_after_id(last_id, limit=self.batch_size)
            if not batch:
                break

            for user in batch:
                self._migrate_user(user)

            last_id = batch[-1]['id']
            self._save_checkpoint(last_id)

            # Frenar para no ahogar las consultas de producción
            time.sleep(0.1)

    def _migrate_user(self, user):
        """El upsert hace que re-ejecutar sea idempotente."""
        self.new_db.user_profiles.upsert(
            {'user_id': user['id']},
            {
                'display_name': user['name'],
                'bio': user.get('bio', ''),
                'created_at': user['created_at']
            }
        )

    def _get_checkpoint(self):
        row = self.old_db.execute(
            "SELECT last_id FROM migration_checkpoints WHERE migration = %s",
            ('users_to_profiles',)
        )
        return row['last_id'] if row else 0

    def _save_checkpoint(self, last_id):
        self.old_db.execute("""
            INSERT INTO migration_checkpoints (migration, last_id)
            VALUES ('users_to_profiles', %s)
            ON CONFLICT (migration) DO UPDATE SET last_id = EXCLUDED.last_id
        """, (last_id,))

La tabla de checkpoint es lo que hace que el script sea seguro de matar: si muere en la fila 500.000, al reiniciar retoma donde quedó en lugar de duplicar medio millón de filas.

Código complementario: el repo companion de la guía tiene una demo runnable en sqlite de todo este flujo — doble escritura, backfill reanudable, validación — más el SQL de expand-contract y una plantilla de plan de migración.

3. Valida la integridad de los datos

No confíes en que la migración funcionó porque terminó. Verifica a tres niveles:

class MigrationValidator:
    def __init__(self, old_db, new_db):
        self.old_db = old_db
        self.new_db = new_db

    def validate_counts(self):
        """Los conteos de filas deben coincidir exactamente."""
        old_count = self.old_db.execute("SELECT COUNT(*) AS c FROM users")['c']
        new_count = self.new_db.execute("SELECT COUNT(*) AS c FROM user_profiles")['c']
        assert old_count == new_count, f"Conteo no coincide: {old_count} != {new_count}"

    def validate_sample(self, sample_size=1000):
        """Comparación campo a campo sobre una muestra aleatoria."""
        users = self.old_db.execute(
            "SELECT * FROM users ORDER BY RANDOM() LIMIT %s", (sample_size,)
        )
        mismatches = 0
        for user in users:
            profile = self.new_db.user_profiles.find_by_user_id(user['id'])
            if not profile or user['name'] != profile['display_name']:
                mismatches += 1
        assert mismatches == 0, f"{mismatches} discrepancias en la muestra"

    def validate_checksums(self):
        """Los checksums agregados atrapan lo que la muestra se pierde."""
        old = self.old_db.execute("""
            SELECT MD5(string_agg(name || bio, ',' ORDER BY id)) AS checksum FROM users
        """)
        new = self.new_db.execute("""
            SELECT MD5(string_agg(display_name || bio, ',' ORDER BY user_id)) AS checksum
            FROM user_profiles
        """)
        assert old == new, "Checksum no coincide"

4. Ejecuta el cutover

## Checklist de cutover

### Antes del cutover
- [ ] Backfill completado al 100%
- [ ] Validación superada (conteos, muestras, checksums)
- [ ] Lecturas en sombra con <0.1% de discrepancias
- [ ] El sistema nuevo responde bajo carga
- [ ] Rollback documentado y probado
- [ ] Equipo de guardia durante la ventana de cutover

### Durante el cutover
1. Pausar escrituras no críticas (opcional, reduce riesgo)
2. Activar feature flag para enrutar lecturas al nuevo sistema
3. Vigilar tasas de error 15 minutos
4. Si suben los errores: apagar el flag (rollback instantáneo)
5. Si está estable: proceder al cutover de escrituras
6. Habilitar escrituras al sistema nuevo
7. Vigilar 1 hora

### Después del cutover
- [ ] Tasas de error normales durante 24 horas
- [ ] El sistema nuevo con el 100% del tráfico
- [ ] El viejo sigue recibiendo doble escritura (por seguridad)
- [ ] Ventana de rollback en marcha (48 horas)
# Cutover con feature flag — leer el flag en cada llamada, no al arrancar
class UserService:
    def __init__(self, config):
        self.config = config

    def get_user(self, user_id):
        # La lectura del config debe ser dinámica para que cambiar el flag
        # tenga efecto sin redesplegar — eso es lo que hace el rollback instantáneo.
        if self.config.get('use_new_user_schema'):
            return self._get_from_new_schema(user_id)
        return self._get_from_old_schema(user_id)

    def create_user(self, user_data):
        # Seguir escribiendo doble durante la ventana de migración
        old_id = self._create_in_old(user_data)
        self._create_in_new(user_data, old_id)
        return old_id

Patrones de evolución de esquemas

1. Expandir-contraer para columnas

-- Paso 1: añadir la columna nueva (nullable)
ALTER TABLE users ADD COLUMN email_normalized VARCHAR(255);

-- Paso 2: rellenar la columna nueva
UPDATE users SET email_normalized = LOWER(email) WHERE email_normalized IS NULL;

-- Paso 3: desplegar código que escriba en ambas columnas

-- Paso 4: hacer la columna nueva NOT NULL
ALTER TABLE users ALTER COLUMN email_normalized SET NOT NULL;

-- Paso 5: desplegar código que lea de la columna nueva

-- Paso 6: borrar la columna vieja (tras la ventana de rollback)
ALTER TABLE users DROP COLUMN email;

2. División de tablas

-- Paso 1: crear la tabla nueva
CREATE TABLE user_profiles (
    user_id BIGINT PRIMARY KEY REFERENCES users(id),
    bio TEXT,
    preferences JSONB,
    created_at TIMESTAMP DEFAULT NOW()
);

-- Paso 2: trigger de doble escritura
CREATE OR REPLACE FUNCTION sync_user_profile() RETURNS trigger AS $$
BEGIN
    INSERT INTO user_profiles (user_id, bio, preferences, created_at)
    VALUES (NEW.id, NEW.bio, NEW.preferences, NEW.created_at)
    ON CONFLICT (user_id) DO UPDATE SET
        bio = EXCLUDED.bio,
        preferences = EXCLUDED.preferences;
    RETURN NEW;
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER users_profile_sync
    AFTER INSERT OR UPDATE ON users
    FOR EACH ROW EXECUTE FUNCTION sync_user_profile();

-- Paso 3: backfill
INSERT INTO user_profiles (user_id, bio, preferences, created_at)
SELECT id, bio, preferences, created_at FROM users
ON CONFLICT (user_id) DO NOTHING;

-- Paso 4: pasar las lecturas a user_profiles
-- Paso 5: borrar las columnas migradas de users (tras la ventana de rollback)

Lo que funciona

  • Prueba sobre una copia de producción. Staging rara vez iguala el volumen ni los casos raros, y una migración de 2 horas en staging puede tardar 20 en vivo.
  • Haz cada script idempotente. Si muere a mitad, reiniciar no debe crear duplicados — upserts y checkpoints, siempre.
  • Frena el backfill. Correr a máxima velocidad asfixia las consultas de producción; un time.sleep entre lotes es un seguro barato.
  • Valida a tres niveles. Los conteos solos no detectan un bio corrupto — añade muestras y checksums.
  • Mantén la ventana de rollback abierta. 24-72 horas mínimo antes de borrar lo viejo. Los despliegues blue-green aplican el mismo instinto al código; aplícalo también a los datos.
  • Avisa a todos. Las migraciones afectan a cada equipo que toca la base de datos — analytics incluido.

Errores comunes

  • Sin plan de rollback. Una vez borradas las columnas viejas, “revertir” es una migración nueva bajo la presión de un incidente.
  • Correr en horas pico. Los backfills van en ventanas de poco tráfico.
  • Olvidar las foreign keys. Migrar la tabla padre sin actualizar las referencias de las hijas rompe constraints — o peor, deja filas huérfanas sin ruido.
  • Fiarse del exit code. Un script puede terminar sin errores y aun así producir datos malos. Para eso existe el paso 3.
  • Borrar demasiado pronto. El expandir-contraer existe porque a veces necesitarás volver atrás.
  • Subestimar la duración. Extrapola desde una muestra real, no desde un dataset de juguete.

Variantes

  • Herramientas de cambio de esquema online: pt-online-schema-change y gh-ost para MySQL; pg_repack para PostgreSQL — hacen el baile de triggers por ti
  • Migración por CDC: Debezium transmite los cambios al sistema nuevo en tiempo real, sin doble escritura en la app
  • Dump y restore: pg_dump/pg_restore para bases más pequeñas que admiten una ventana de mantenimiento
  • Servicios gestionados: AWS DMS, Azure Database Migration Service, Google DMS para saltos entre clouds

Ver también

Lectura adicional

Conclusión

Una migración de datos no es un evento, es un proceso: planificar, expandir, escribir doble, rellenar, validar, leer en sombra, cortar el tráfico, contraer. Saltarse cualquiera de esos pasos es apostar con el único activo que no se puede redesplegar.

Preguntas frecuentes

¿Cuánto tiempo debo conservar el esquema viejo tras el cutover?

48 horas mínimo en migraciones de bajo riesgo, hasta dos semanas en las de alto riesgo. Borrar pronto convierte un mal rollout en un incidente.

¿Qué pasa si la migración falla a la mitad?

Si es idempotente con checkpoints, la reinicias y ya. Si no lo es, restaura el backup y arregla el script primero — por eso la idempotencia no es opcional.

¿Cómo migro sin poder hacer doble escritura?

Change Data Capture (Debezium lee los cambios del WAL) o snapshot + sincronización incremental. Más infraestructura, pero cero cambios en la aplicación.

¿Puedo migrar con carga pesada?

Sí, con un backfill frenado — time.sleep o pg_sleep entre lotes, horas valle, y un ojo en el lag de replicación.

¿Cuándo es mejor CDC que la doble escritura?

Cuando no puedes tocar el código de la app, cuando varias apps escriben en las mismas tablas, o cuando necesitas que el sistema nuevo se mantenga en sync continuo y no solo alcance al viejo una vez.

¿Cómo trato las foreign keys que apuntan a la tabla que migro?

Actualiza las hijas primero o mantén estable la clave del padre. Un user_profiles.user_id que referencia users.id sigue siendo válido tras la división — por eso el ejemplo usa user_id como clave y no una surrogate key nueva.