¿Cómo ejecutas una migración de esquema en una tabla grande de producción sin downtime?
Una migración que bloquea una tabla de 200 millones de filas para reescribirla completa no es un cambio de esquema, es un incidente — la solución es nunca dejar incompatibles la forma vieja y la nueva al mismo tiempo.
APRENDE HACIENDOPon a prueba tu intuición3 pasos
¿Cómo ejecutas una migración de esquema en una tabla grande de producción sin downtime?
Usa los ejemplos y la explicación de la lección para comprobar tu idea.
Reflexión personal. No se envía a ningún servicio de IA.#Por qué la migración ingenua es peligrosa
El enfoque ingenuo para "renombrar esta columna" o "hacer este campo obligatorio" es una sola migración que cambia la columna y un solo deploy que cambia la aplicación para que coincida. El problema es que un cambio de esquema y un deploy de aplicación nunca son atómicos entre sí: durante un deploy progresivo (rolling), los pods viejos de la aplicación (que esperan el esquema viejo) y los pods nuevos (que esperan el esquema nuevo) corren contra la misma base de datos simultáneamente durante un rango que va de segundos a minutos. Si la migración ya eliminó la columna vieja, los pods viejos empiezan a fallar en cada request hasta que son reemplazados por completo. En una tabla grande, es peor incluso antes de llegar a ese punto — una operación como ALTER TABLE ... ALTER COLUMN ... TYPE ... o agregar una columna NOT NULL con un valor por defecto, en versiones antiguas de Postgres, puede requerir un lock ACCESS EXCLUSIVE y una reescritura completa de la tabla, bloqueando toda lectura y escritura sobre esa tabla durante toda la duración, lo cual en cientos de millones de filas puede ser de minutos a horas.
#El patrón expand/contract
La solución estándar es dividir una migración "riesgosa" en varios pasos pequeños, individualmente seguros y desplegables por separado, de forma que en todo momento el esquema soporte tanto el código viejo como el nuevo de la aplicación al mismo tiempo. Esto se suele llamar expand/contract (o "cambio paralelo"):
Concretamente, "renombrar email a email_address en una tabla users de 300M de filas":
-- Paso 1 (EXPAND): agregar la columna nueva, nullable — instantáneo, cambio solo de metadatosALTER TABLE users ADD COLUMN email_address TEXT;-- Paso 2 (BACKFILL): poblarla en lotes pequeños para evitar una transacción-- larga y esperas de lock prolongadas contra escritores concurrentesUPDATE usersSET email_address = emailWHERE id BETWEEN 1 AND 10000 AND email_address IS NULL;-- repetir con el siguiente rango de id, con throttling, p. ej. vía un job en segundo plano
# Un job de backfill por lotes — transacciones pequeñas, una pausa corta entre# lotes para no competir con el tráfico de producción por I/O y locks.BATCH_SIZE = 10_000def backfill_email_address(db):last_id = 0while True:rows = db.execute("""UPDATE usersSET email_address = emailWHERE id > %s AND email_address IS NULLORDER BY idLIMIT %sRETURNING id""",(last_id, BATCH_SIZE),)if not rows:breaklast_id = rows[-1]["id"]time.sleep(0.1) # cede el paso al tráfico OLTP concurrente
Luego la aplicación se despliega en dos etapas: primero una versión que escribe en ambas columnas (email y email_address) en cada insert/update (para que las filas nuevas se mantengan consistentes con el backfill), y una vez que todos los pods corren esa versión y el backfill está confirmado como completo y verificado, una segunda versión que lee y escribe solo email_address. Solo después de que ese segundo deploy esté completamente desplegado — y típicamente tras un período de espera, por si hay que revertir — se ejecuta el paso de contracción: ALTER TABLE users DROP COLUMN email;, que en la mayoría de los motores es una operación barata de solo catálogo una vez que nada referencia la columna.
#Por qué cada paso es individualmente barato
Todo el sentido de descomponer la migración es que cada sentencia SQL, aislada, es rápida y de bajo bloqueo:
- Agregar una columna nullable sin default es solo metadatos en Postgres y MySQL 8+ (DDL instantáneo de InnoDB) — sin reescritura de tabla, casi instantáneo sin importar el tamaño de la tabla.
- Agregar una columna con default solía forzar una reescritura completa en versiones antiguas de Postgres; Postgres 11+ almacena el default como metadato para un valor constante, haciéndolo también instantáneo — pero un default calculado/no constante sigue reescribiendo, así que verifica la versión de tu motor en vez de asumir.
- El backfill son solo sentencias UPDATE normales en lotes pequeños, indistinguibles del tráfico normal de escritura de la aplicación para el gestor de locks de la base de datos — sin locks prolongados, sin transacciones largas.
- Eliminar una columna que nadie lee es solo remover metadatos de catálogo, no una reescritura.
La versión peligrosa de un solo paso falla porque intenta hacer los pasos 1, 2 y 6 como una única operación bloqueante, manteniendo un lock durante todo lo que tarde la reescritura de la tabla completa, en vez de un lock que solo dura el cambio de metadatos.
#Rollback y seguridad
La razón para detenerse en cada etapa en vez de apresurarse es que la escritura dual te da una ruta real de rollback: si la columna nueva resulta tener un bug, puedes revertir la aplicación para que lea la columna vieja sin perder datos, porque la columna vieja nunca se eliminó y se sigue manteniendo sincronizada. Este es el trade-off central del patrón: más deploys y más tiempo de calendario, a cambio de cero downtime y reversibilidad en cada paso. Herramientas como gh-ost y pt-online-schema-change para MySQL automatizan una versión similar, basada en tabla-sombra más triggers, de esta misma idea para cambios (como un cambio de tipo de dato) que no se pueden expresar como una columna aditiva en absoluto.