Saltar al contenido
DedicatedPHP Contactar

Migraciones de esquema en tablas grandes sin bloquear escrituras

Planifica cambios de esquema en producción con PHP: despliega por fases, procesa datos por lotes y verifica la concurrencia antes de retirar lo anterior.

Diagrama de una migración gradual de esquema con backfill por lotes, control de escrituras concurrentes y validación antes de retirar la estructura antigua

Modificar una columna puede ser sencillo en una base de datos pequeña. En una tabla grande y activa, la misma operación puede requerir un bloqueo, reconstruir índices o competir por recursos con las consultas de producción. El efecto depende del motor, su versión, el tipo de cambio y la configuración: no conviene asumir que una instrucción será instantánea o no bloqueará.

Las migraciones de esquema en tablas grandes separan el cambio estructural de la transformación de los datos. El objetivo es mantener compatibles las versiones de la aplicación durante la transición, medir el impacto y poder detener el proceso. No hay una receta universal: comprueba las capacidades del motor y el comportamiento real de la aplicación.

Por qué un cambio pequeño puede bloquear operaciones

Por qué un cambio pequeño puede bloquear operaciones — guía visual de DedicatedPHP

Ampliar una columna, añadir una restricción o cambiar un tipo puede implicar trabajo proporcional al tamaño de la tabla. Según el motor, la operación podría mantener bloqueos, generar carga de disco, afectar réplicas o esperar a que terminen transacciones abiertas. Incluso una operación considerada online puede bloquear brevemente o tener limitaciones.

Evalúa tamaño y crecimiento de la tabla, carga de lectura y escritura, transacciones largas, índices y espacio disponible. Consulta la documentación de la versión concreta y prueba en un entorno representativo. Define límites operativos para latencia, bloqueos, almacenamiento y retraso de replicación.

Una actualización convencional del esquema modifica estructuras, por ejemplo al añadir una columna. Una migración de datos transforma o copia valores existentes, a menudo fila por fila. Pueden formar parte del mismo cambio funcional, pero tienen riesgos distintos y conviene ejecutarlas y monitorizarlas por separado.

Inventariar lectores, escritores y dependencias

Busca quién lee y escribe el dato: código PHP, consultas SQL, trabajos de cola, comandos programados, importaciones, informes y servicios externos. Revisa qué versiones pueden convivir durante un despliegue gradual. Las consultas dinámicas y los consumidores ajenos al repositorio también cuentan.

  • Documenta los formatos actual y objetivo, incluidos nulos, valores predeterminados y reglas de conversión.
  • Identifica índices, claves foráneas, restricciones y vistas dependientes.
  • Comprueba qué componentes actualizan campos parcialmente y cuáles escriben el dato.
  • Determina cómo detectar y reparar valores inválidos.

Este inventario determina el orden del despliegue. Una versión antigua puede fallar si se elimina una columna que aún consulta. Si no puedes identificar todos los consumidores, asume que el código antiguo podría seguir activo más tiempo.

Dividir el cambio en fases compatibles

Un patrón habitual es expandir y después contraer: añadir la estructura nueva sin retirar la anterior, desplegar código compatible, copiar el histórico y cambiar las lecturas. Solo después de verificar el resultado se elimina la estructura antigua.

  1. Expandir: añade la columna o tabla nueva sin romper versiones en producción. Valora crear índices y restricciones en una operación separada.
  2. Desplegar compatibilidad: publica lectores que toleren datos pendientes y escritores que mantengan ambas representaciones coherentes.
  3. Completar el histórico: ejecuta el backfill por lotes con seguimiento del avance.
  4. Cambiar el uso: lee principalmente la estructura nueva y observa errores, latencia y discrepancias.
  5. Contraer: retira primero la escritura antigua y, en un despliegue posterior, la estructura vieja.

Desplegar código no obliga a exponer la funcionalidad de inmediato. Confirma que cada versión funciona con el esquema de cada fase, incluso si es necesario revertir el código.

Ejecutar un backfill controlado desde PHP

Evita cargar toda la tabla en memoria o mantener una transacción global. Un comando PHP de consola permite controlar el tamaño del lote, registrar el avance y detener el proceso sin atarlo al ciclo de una petición web. Este patrón con PDO usa una versión optimista. progressStore representa un registro de progreso guardado en la misma base de datos y confirmado en la misma transacción que las filas del lote.

$limit = 200;
$maxAttempts = 5;
$cursor = (int) $progressStore->load('backfill');

// Límite superior inicial; no cubre por sí solo inserciones tardías.
$upperId = (int) $pdo->query('SELECT MAX(id) FROM records')->fetchColumn();

while ($cursor < $upperId) {
    $done = false;
    for ($attempt = 1; $attempt <= $maxAttempts; $attempt++) {
        $select = $pdo->prepare(
            'SELECT id, old_value, version FROM records
             WHERE id > :cursor AND id <= :upper_id
             ORDER BY id LIMIT ' . (int) $limit
        );
        $select->execute([':cursor' => $cursor, ':upper_id' => $upperId]);
        $rows = $select->fetchAll(PDO::FETCH_ASSOC);
        if (!$rows) {
            $cursor = $upperId;
            $done = true;
            break;
        }

        try {
            $pdo->beginTransaction();
            $update = $pdo->prepare(
                'UPDATE records SET new_value = :value, version = version + 1
                 WHERE id = :id AND version = :version'
            );
            foreach ($rows as $row) {
                $update->execute([
                    ':value' => transform($row['old_value']),
                    ':id' => $row['id'], ':version' => $row['version'],
                ]);
                if ($update->rowCount() !== 1) {
                    throw new VersionConflict('La fila cambió durante el backfill');
                }
            }
            $next = (int) end($rows)['id'];
            $progressStore->saveWithinTransaction('backfill', $next);
            $pdo->commit();
            $cursor = $next;
            $done = true;
            break;
        } catch (Throwable $e) {
            if ($pdo->inTransaction()) $pdo->rollBack();
            $retryable = $e instanceof VersionConflict
                || ($e instanceof PDOException && isRetryableDatabaseError($e));
            if (!$retryable || $attempt === $maxAttempts) throw $e;
            usleep(min(100000 * (2 ** ($attempt - 1)), 2000000));
            // Releer el lote con el mismo cursor; el progreso aún no avanzó.
        }
    }
    if (!$done) throw new RuntimeException('Lote pendiente; cursor sin avanzar.');
}

VersionConflict debe ser una excepción propia, no una excepción genérica que agrupe errores de transformación. Así, fallos permanentes de transform() detienen el comando para diagnóstico. isRetryableDatabaseError() debe reconocer solo errores transitorios clasificados para el motor y controlador utilizados, como interbloqueos o tiempos de espera. Otros fallos de base de datos detienen el proceso. El máximo de intentos y el backoff acotado evitan reintentos indefinidos; agotados los intentos, el comando falla sin avanzar el cursor. Comprueba cómo informa rowCount() tu combinación de PDO y motor.

La protección optimista exige que todos los escritores incrementen version en la misma transacción en que actualizan los datos. El contrato incluye código PHP, colas, importaciones y servicios externos, y debe estar implantado y comprobado antes de iniciar el backfill. Si un escritor no lo cumple, la comparación puede no detectar la carrera: actualízalo, enrútalo mediante un mecanismo común, desactívalo temporalmente o usa bloqueos adecuados. No des por válida la protección hasta comprobar el contrato de cada escritor.

El límite superior inicial reduce el trabajo, pero no garantiza que incluya inserciones que se confirmen tarde. Al terminar la pasada, ejecuta una reconciliación que busque explícitamente filas sin transformar o discrepantes, sin depender de que su ID sea mayor que el cursor. Repara y vuelve a verificar esos registros; repite la pasada hasta que no queden pendientes, bajo un criterio verificable y con escritores que mantengan la compatibilidad. Si no puedes detectar ni reconciliar esas filas con fiabilidad, considera captura de cambios o una ventana de mantenimiento. No declares completado el histórico solo porque el cursor alcanzó el límite inicial.

La transformación debe ser idempotente y el progreso debe confirmarse atómicamente con las filas. Si el almacenamiento del cursor no comparte transacción con los datos, diseña la reanudación para repetir de forma segura filas ya confirmadas. Añade opciones de pausa, límites de lotes, registros estructurados y un código de salida que refleje fallos; ajusta el tamaño con mediciones, no con suposiciones.

Evitar carreras con escrituras concurrentes

La doble escritura por sí sola no evita todas las carreras. El backfill puede leer un valor antiguo; una escritura concurrente puede actualizar ambos campos; y después el backfill podría sobrescribir el campo nuevo con un resultado obsoleto. La condición de versión en el ejemplo impide esa actualización si la escritura concurrente incrementó la versión; el conflicto se conserva porque el cursor solo avanza después de confirmar el lote completo.

También se puede usar una actualización condicional basada en el valor original, o bloqueos adecuados, según las garantías que ofrezca el motor y la carga de trabajo. Cada alternativa tiene costes y semánticas distintas. Prueba la estrategia en el motor concreto con escrituras intercaladas, interbloqueos y tiempos de espera, y verifica tanto las filas afectadas como la reconciliación antes de afirmar que el proceso completa correctamente el histórico.

Verificar antes de retirar la estructura antigua

Que el comando termine no demuestra que los datos sean correctos. Comprueba que no queden filas pendientes, valida restricciones y compara los resultados con la transformación esperada. Revisa nulos y casos límite, y confirma que las lecturas usan el campo nuevo sin empeorar el comportamiento funcional o el rendimiento.

Observa también procesos poco frecuentes, como informes o tareas periódicas. Conserva la estructura anterior mientras existan lectores o escritores dependientes: su presencia es una medida de compatibilidad, no prueba de que la migración haya finalizado.

Definir pausas, reversión y ventana de mantenimiento

Definir pausas, reversión y ventana de mantenimiento — guía visual de DedicatedPHP

Define señales para pausar: errores o latencia por encima del umbral acordado, bloqueos, retraso excesivo de réplicas, presión de recursos o discrepancias crecientes. Determina quién detiene el proceso y cómo se reanuda desde el último lote confirmado. Monitoriza duración de lotes, errores, CPU, disco y bloqueos.

Revertir el código no equivale a revertir los datos. Tras aceptar escrituras solo en el formato nuevo, una transformación inversa puede perder información. La recuperación podría consistir en volver a una versión compatible y mantener ambas estructuras, no en deshacer los datos. Una ventana de mantenimiento puede ser preferible si no se puede mantener coherencia entre versiones, si los bloqueos necesarios no son aceptables o si no puedes verificar el resultado con la aplicación activa.

  • ¿El motor y su versión permiten el cambio con bloqueos aceptables?
  • ¿Lectores y escritores antiguos pueden convivir con el esquema intermedio?
  • ¿El comando PHP puede pausarse y reanudarse sin duplicar efectos ni saltarse filas?
  • ¿Hay una estrategia probada para escritores concurrentes, errores transitorios y discrepancias?
  • ¿Se medirán integridad e impacto antes de retirar lo anterior?
¿Quieres aplicar estas ideas a tu proyecto?Hablemos de tu plataforma PHP.
Ver servicio relacionado