Evolución de Schema de Base de Datos
Evoluciona schemas de base de datos de forma segura con cambios backward-compatible, migraciones versionadas y operaciones DDL online en ambientes de producción.
Visión General
Los schemas de base de datos deben evolucionar a medida que las aplicaciones crecen, pero los cambios de schema son una causa principal de outages en producción. El patrón expand-contract, online DDL y migraciones backward-compatible permiten a los equipos agregar capacidades sin downtime. Este recurso cubre técnicas prácticas para evolucionar schemas en PostgreSQL, MySQL y bases de datos distribuidas manteniendo la integridad de datos y disponibilidad de aplicaciones.
Cuándo Usar
Usa este recurso cuando:
- Agregas columnas, índices o constraints a tablas con millones de filas
- Necesitas renombrar columnas o separar tablas sin romper aplicaciones en ejecución
- Ejecutas migraciones en un pipeline CI/CD que despliega múltiples veces al día
- Trabajas con bases de datos distribuidas donde los cambios de schema se propagan asíncronamente
Solución
Expand-Contract Pattern (PostgreSQL)
-- FASE 1: EXPAND - Agregar nueva columna sin romper código existente
ALTER TABLE users ADD COLUMN email_normalized VARCHAR(255);
CREATE INDEX CONCURRENTLY idx_users_email_normalized ON users(email_normalized);
-- Backfill en batches para evitar locking
UPDATE users
SET email_normalized = LOWER(email)
WHERE id BETWEEN 1 AND 10000;
-- FASE 2: DUAL WRITE - Aplicación escribe a ambas columnas
-- (Desplegar código que escribe a email y email_normalized)
-- FASE 3: CONTRACT - Remover columna vieja después de verificación
ALTER TABLE users DROP COLUMN email;
ALTER TABLE users RENAME COLUMN email_normalized TO email;
Online DDL con pt-online-schema-change (MySQL)
# Agregar un índice sin lockear la tabla
pt-online-schema-change \
--alter "ADD INDEX idx_created_at (created_at)" \
--execute \
--max-load Threads_running=25 \
--critical-load Threads_running=50 \
D=mydb,t=orders
Flyway Migration (Java/Spring)
// V1.2__Add_user_preferences.sql
CREATE TABLE user_preferences (
user_id UUID PRIMARY KEY REFERENCES users(id),
theme VARCHAR(20) DEFAULT 'light',
notifications_enabled BOOLEAN DEFAULT true,
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX idx_user_preferences_theme ON user_preferences(theme);
Explicación
El patrón expand-contract:
- Expand: Agregar nuevos elementos de schema (columnas, tablas) sin remover los viejos
- Migrate: Backfill de datos; correr dual-write durante la transición
- Verify: Asegurar que paths nuevos y viejos producen resultados idénticos
- Contract: Remover elementos deprecados una vez que todo el código usa el nuevo schema
Online vs. offline DDL:
| Base de Datos | Online DDL | Nivel de Lock |
|---|---|---|
| PostgreSQL | CREATE INDEX CONCURRENTLY | Ninguno |
| MySQL | ALGORITHM=INPLACE | Breve metadata |
| MySQL (tablas grandes) | pt-online-schema-change | Copy a nivel de fila |
| SQL Server | ONLINE=ON | Schema stability |
Variantes
| Enfoque | Ideal Para | Tooling |
|---|---|---|
| Expand-contract | Renames zero-downtime | Manual + cambios de aplicación |
| Online DDL | Cambios de índice en tablas grandes | pt-online-schema-change, gh-ost |
| Blue-green schema | Reestructuración mayor | Dos bases de datos + dual-write |
| Logical replication | Migración cross-version | pglogical, Debezium |
Lo que funciona
- Nunca dropees antes de agregar: Siempre agrega el reemplazo antes de remover el original
- Usa
IF EXISTSyIF NOT EXISTS: Previene fallas de migración en runs parciales - Batch backfills: Update 1,000-10,000 filas por transacción para evitar locks largos
- Testea migraciones en datos de tamaño producción:
pg_dump+ restore a staging no es suficiente - Versiona tus migraciones: Flyway, Liquibase o Atlas para tracking y rollback
Errores Comunes
- Migraciones big-bang: Ejecutar
ALTER TABLEen una tabla de 100M filas sinCONCURRENTLY - Sin testear rollback: Si el deploy falla, ¿puedes revertir el cambio de schema? Testea estrategias de despliegue.
- Falta de compatibilidad de aplicación: Nuevo schema rompe código viejo durante rolling deployments
- Ignorar timeouts de lock:
statement_timeoutde PostgreSQL aborta migraciones largas impredeciblemente. Consulta connection pooling. - Sin dry runs: Ejecutar migraciones directamente en producción sin
EXPLAINo validación de staging
Puntos Clave
- Aplica evolución de schema de base de datos cuando necesites una solución práctica para tu caso de uso.
- Monitorea el rendimiento después de implementar; mide latencia, errores y uso de recursos antes y después.
- Revisa la sección de Troubleshooting ante errores comunes; la mayoría tienen causa raíz documentada con solución.
- Mantén dependencias actualizadas y ejecuta tests en CI para prevenir regresiones en producción.
Buenas prácticas adicionales
- Usa
CREATE INDEX CONCURRENTLYen PostgreSQL. Esto evita bloquear escrituras pero no puede ejecutarse dentro de una transacción. Planifica tus scripts de migración accordingly. - Configura
lock_timeoutpara operaciones DDL. Esto previene que una migración espere indefinidamente por un lock:
SET lock_timeout = '5s';
ALTER TABLE users ADD COLUMN status VARCHAR(20);
- Usa
NOT VALIDpara check constraints. Agrega constraints comoNOT VALIDpara saltarte el escaneo de filas existentes, luego valida en un paso separado:
ALTER TABLE orders ADD CONSTRAINT chk_amount CHECK (amount > 0) NOT VALID;
ALTER TABLE orders VALIDATE CONSTRAINT chk_amount;
-
Documenta cada migración. Incluye la razón, duración esperada, plan de rollback y pasos de verificación en los comentarios o changelog de tu herramienta de migración.
-
Ejecuta migraciones en staging primero. Mide timing, comportamiento de locks y uso de recursos. Usa datos de tamaño producción para estimaciones precisas.
Errores comunes adicionales
- Agregar una columna con un default volátil. En PostgreSQL versiones anteriores a 11,
ADD COLUMN ... DEFAULT random()reescribe la tabla completa. Usa una columna nullable y backfill en su lugar. - No manejar valores NULL durante cambios de tipo. Al cambiar de
VARCHARaINTEGER, NULLs y strings no numéricos causarán errores. Limpia los datos primero. - Olvidar actualizar estadísticas. Después de backfills grandes, ejecuta
ANALYZEpara que el query planner tenga estadísticas precisas:
ANALYZE users;
- Ejecutar migraciones durante tráfico pico. Incluso migraciones zero-downtime añaden carga. Programa backfills durante horas valle para minimizar impacto.
- No tener un plan de rollback para cada migración. Cada migración debe tener un procedimiento de rollback documentado. Pruébalo en staging antes de desplegar.
Tips de Rendimiento
-
Batch backfills con
LIMITysleep. Procesa 1,000-10,000 filas por lote con una pausa corta para minimizar lag de replicación y contención de locks. -
Usa
CREATE INDEX CONCURRENTLYpara todos los índices en producción. Toma más tiempo pero no bloquea escrituras. Monitorea el progreso viapg_stat_progress_create_index. -
Ejecuta
ANALYZEdespués de cambios grandes de datos. El query planner necesita estadísticas actualizadas para elegir planes óptimos:
ANALYZE VERBOSE users;
- Configura
statement_timeoutpara sesiones de migración. Previene que DDL descontrolado bloquee la base de datos:
SET statement_timeout = '60s';
- Monitorea el lag de replicación durante backfills. Pausa el backfill si el lag de réplica excede tu umbral:
SELECT now() - pg_last_xact_replay_timestamp() AS replication_lag;
Troubleshooting
- Query is slow after an index change: check execution plans and cardinality estimates. Rebuild statistics and verify the index is being used.
- Replication lag grows: Split large writes and consider parallel replication.
- Connections exhausted: review connection pool size, idle timeouts, and leaked connections.
- Backup takes too long: enable compression, incremental backups, and off-peak scheduling.
- Deadlocks in high concurrency: access tables and rows in a consistent order.
Errores Comunes en Producción
- Copiar el ejemplo sin adaptarlo a volúmenes y modos de fallo reales.
- Saltar tests de carga e inyección de errores antes del primer despliegue productivo.
- Codificar valores fijos que deberían ser configurables por entorno.
- Olvidar agregar logging y monitoreo en cada paso.
- Desplegar sin plan de rollback ni estrategia de backup probada.
- Asumir que el ejemplo mínimo escalará sin agregar caché o procesamiento por lotes.
- No documentar la versión y configuración usadas en producción.
- Dejar la receta sin cambios cuando evolucionan las dependencias o la escala.
Preguntas frecuentes
Migración con Liquibase (XML)
<?xml version="1.0" encoding="UTF-8"?>
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-3.8.xsd">
<changeSet id="add-phone-column" author="team">
<addColumn tableName="users">
<column name="phone" type="VARCHAR(20)" />
</addColumn>
</changeSet>
<changeSet id="backfill-phone" author="team">
<sql>
UPDATE users SET phone = phone_number
WHERE phone IS NULL AND phone_number IS NOT NULL
</sql>
</changeSet>
<changeSet id="drop-old-phone" author="team">
<dropColumn tableName="users" columnName="phone_number" />
</changeSet>
</databaseChangeLog>
gh-ost para cambios de schema online en MySQL
# Agregar una columna sin lockear la tabla usando gh-ost de GitHub
gh-ost \
--host=localhost \
--user=root \
--password=pass \
--database=mydb \
--table=orders \
--alter="ADD COLUMN priority INT DEFAULT 0" \
--execute \
--max-load=Threads_running=25 \
--critical-load=Threads_running=50 \
--chunk-size=1000
Agregar un constraint NOT NULL de forma segura
-- Paso 1: Agregar columna como nullable
ALTER TABLE products ADD COLUMN sku VARCHAR(50);
-- Paso 2: Rellenar todas las filas
UPDATE products SET sku = CONCAT('SKU-', id) WHERE sku IS NULL;
-- Paso 3: Agregar constraint NOT NULL (rápido una vez que todas las filas están pobladas)
ALTER TABLE products ALTER COLUMN sku SET NOT NULL;
-- Paso 4: Agregar índice único concurrentemente
CREATE UNIQUE INDEX CONCURRENTLY idx_products_sku ON products(sku);
Separar una columna en dos
-- Expand: agregar nuevas columnas
ALTER TABLE users ADD COLUMN first_name VARCHAR(100);
ALTER TABLE users ADD COLUMN last_name VARCHAR(100);
-- Trigger de dual-write
CREATE OR REPLACE FUNCTION split_name() RETURNS TRIGGER AS $$
BEGIN
IF NEW.first_name IS DISTINCT FROM OLD.first_name
OR NEW.last_name IS DISTINCT FROM OLD.last_name THEN
NEW.full_name := NEW.first_name || ' ' || NEW.last_name;
ELSIF NEW.full_name IS DISTINCT FROM OLD.full_name THEN
NEW.first_name := split_part(NEW.full_name, ' ', 1);
NEW.last_name := split_part(NEW.full_name, ' ', 2);
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER users_split_name
BEFORE INSERT OR UPDATE ON users
FOR EACH ROW EXECUTE FUNCTION split_name();
-- Backfill
UPDATE users
SET first_name = split_part(full_name, ' ', 1),
last_name = split_part(full_name, ' ', 2)
WHERE first_name IS NULL AND full_name IS NOT NULL;
Recursos Relacionados
Paginación por Cursor en PostgreSQL (Keyset vs OFFSET)
Implementa paginación eficiente por cursor para datasets grandes en PostgreSQL, evitando la degradación de rendimiento de OFFSET con paginación keyset indexada y ordenamiento estable
RecipeGeneracion de UUID: v4, v7 y Comparacion con ULID
Compara UUID v4, v7, ULID y nanoid para generar identificadores unicos con diferentes tradeoffs en aleatoriedad, ordenamiento, rendimiento y localidad de indices de base de datos
RecipePool de Conexiones a Base de Datos
Configura y ajusta pools de conexiones para maximizar throughput y prevenir el agotamiento de conexiones.
RecipeReplicación de Bases de Datos
Configura y gestiona replicación de bases de datos para alta disponibilidad, escalado de lecturas y disaster recovery con arquitecturas primaria-réplica.
RecipeOptimizacion de Queries e Indexing en PostgreSQL
Analiza y optimiza queries lentas en PostgreSQL usando EXPLAIN, indexing apropiado, partial indexes y reescritura de queries para reducir tiempo de ejecucion
RecipeCaching con Redis
Cómo implementar caching de aplicaciones usando Redis para rendimiento y escalabilidad.