Ir para o conteúdo
DedicatedPHP Contato

Migrações de esquema em tabelas grandes sem bloquear gravações

Planeje alterações de esquema em produção com PHP: faça o deploy em etapas, processe os dados em lotes e verifique a concorrência antes de remover a estrutura antiga.

Diagrama de uma migração gradual de esquema com backfill em lotes, controle de gravações concorrentes e validação antes de remover a estrutura antiga

Modificar uma coluna pode ser simples em um banco de dados pequeno. Em uma tabela grande e ativa, a mesma operação pode exigir um bloqueio, reconstruir índices ou competir por recursos com as consultas de produção. O efeito depende do mecanismo, da versão, do tipo de alteração e da configuração: não convém presumir que uma instrução será instantânea ou não bloqueará.

As migrações de esquema em tabelas grandes separam a alteração estrutural da transformação dos dados. O objetivo é manter as versões da aplicação compatíveis durante a transição, medir o impacto e poder interromper o processo. Não existe uma receita universal: verifique os recursos do mecanismo e o comportamento real da aplicação.

Por que uma pequena alteração pode bloquear operações

Por que uma pequena alteração pode bloquear operações — guía visual de DedicatedPHP

Ampliar uma coluna, adicionar uma restrição ou alterar um tipo pode exigir trabalho proporcional ao tamanho da tabela. Dependendo do mecanismo, a operação pode manter bloqueios, gerar carga de disco, afetar réplicas ou aguardar o término de transações abertas. Mesmo uma operação considerada online pode bloquear brevemente ou ter limitações.

Avalie o tamanho e o crescimento da tabela, a carga de leitura e gravação, as transações longas, os índices e o espaço disponível. Consulte a documentação da versão específica e teste em um ambiente representativo. Defina limites operacionais para latência, bloqueios, armazenamento e atraso de replicação.

Uma atualização convencional do esquema modifica estruturas, por exemplo, ao adicionar uma coluna. Uma migração de dados transforma ou copia valores existentes, muitas vezes linha por linha. Elas podem fazer parte da mesma alteração funcional, mas envolvem riscos distintos e convém executá-las e monitorá-las separadamente.

Inventariar leitores, escritores e dependências

Identifique quem lê e grava os dados: código PHP, consultas SQL, tarefas em fila, comandos agendados, importações, relatórios e serviços externos. Verifique quais versões podem coexistir durante um deploy gradual. Consultas dinâmicas e consumidores fora do repositório também contam.

  • Documente os formatos atual e desejado, incluindo valores nulos, valores padrão e regras de conversão.
  • Identifique índices, chaves estrangeiras, restrições e views dependentes.
  • Verifique quais componentes atualizam campos parcialmente e quais gravam os dados.
  • Determine como detectar e corrigir valores inválidos.

Esse inventário determina a ordem do deploy. Uma versão antiga pode falhar se uma coluna que ainda consulta for removida. Se não for possível identificar todos os consumidores, presuma que o código antigo poderá continuar ativo por mais tempo.

Dividir a alteração em etapas compatíveis

Um padrão habitual é expandir e depois contrair: adicionar a nova estrutura sem remover a anterior, fazer o deploy de código compatível, copiar os dados históricos e mudar as leituras. A estrutura antiga só é removida depois de verificar o resultado.

  1. Expandir: adicione a nova coluna ou tabela sem interromper as versões em produção. Considere criar índices e restrições em uma operação separada.
  2. Implantar a compatibilidade: publique leitores que tolerem dados pendentes e escritores que mantenham as duas representações coerentes.
  3. Completar o histórico: execute o backfill em lotes e acompanhe o progresso.
  4. Mudar o uso: leia principalmente a nova estrutura e monitore erros, latência e discrepâncias.
  5. Contrair: interrompa primeiro a gravação na estrutura antiga e, em um deploy posterior, remova a estrutura antiga.

Fazer o deploy do código não obriga a ativar a funcionalidade imediatamente. Confirme que cada versão funciona com o esquema de cada etapa, inclusive se for necessário reverter o código.

Executar um backfill controlado com PHP

Evite carregar a tabela inteira na memória ou manter uma transação global. Um comando PHP de console permite controlar o tamanho do lote, registrar o progresso e interromper o processo sem vinculá-lo ao ciclo de uma requisição web. Este padrão com PDO usa uma versão otimista. progressStore representa um registro de progresso armazenado no mesmo banco de dados e confirmado na mesma transação que as linhas do lote.

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

// Limite superior inicial; não abrange, por si só, inserções confirmadas tardiamente.
$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('A linha foi alterada durante o 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));
            // Leia novamente o lote com o mesmo cursor; o progresso ainda não avançou.
        }
    }
    if (!$done) throw new RuntimeException('Lote pendente; cursor não avançou.');
}

VersionConflict deve ser uma exceção própria, não uma exceção genérica que agrupe erros de transformação. Assim, falhas permanentes de transform() interrompem o comando para diagnóstico. isRetryableDatabaseError() deve reconhecer somente erros transitórios classificados para o mecanismo e o driver utilizados, como deadlocks ou timeouts. Outros erros de banco de dados interrompem o processo. O número máximo de tentativas e o backoff limitado evitam novas tentativas indefinidas; quando as tentativas se esgotam, o comando falha sem avançar o cursor. Verifique como a combinação de PDO e mecanismo utilizada informa o resultado de rowCount().

A proteção otimista exige que todos os escritores incrementem version na mesma transação em que atualizam os dados. Esse contrato inclui código PHP, filas, importações e serviços externos, e deve estar implementado e verificado antes do início do backfill. Se um escritor não o cumprir, a comparação pode não detectar a condição de corrida: atualize-o, direcione-o por um mecanismo comum, desative-o temporariamente ou use bloqueios adequados. Não considere a proteção válida antes de verificar o contrato de cada escritor.

O limite superior inicial reduz o trabalho, mas não garante a inclusão de inserções confirmadas tardiamente. Ao terminar a passagem, execute uma reconciliação que procure explicitamente linhas não transformadas ou divergentes, sem depender de que o ID delas seja maior que o cursor. Corrija e verifique novamente esses registros; repita a passagem até não haver pendências, segundo um critério verificável e com escritores que mantenham a compatibilidade. Se não for possível detectar nem reconciliar essas linhas de forma confiável, considere a captura de alterações ou uma janela de manutenção. Não declare o histórico concluído apenas porque o cursor alcançou o limite inicial.

A transformação deve ser idempotente e o progresso deve ser confirmado atomicamente com as linhas. Se o armazenamento do cursor não compartilhar uma transação com os dados, projete a retomada para repetir com segurança linhas já confirmadas. Adicione opções de pausa, limites de lotes, logs estruturados e um código de saída que reflita falhas; ajuste o tamanho com base em medições, não em suposições.

Evitar condições de corrida com gravações concorrentes

A gravação dupla, por si só, não evita todas as condições de corrida. O backfill pode ler um valor antigo; uma gravação concorrente pode atualizar os dois campos; e, depois, o backfill pode sobrescrever o campo novo com um resultado obsoleto. A condição de versão no exemplo impede essa atualização se a gravação concorrente tiver incrementado a versão; o conflito é preservado porque o cursor só avança depois da confirmação do lote completo.

Também é possível usar uma atualização condicional baseada no valor original ou bloqueios adequados, conforme as garantias oferecidas pelo mecanismo e a carga de trabalho. Cada alternativa tem custos e semânticas diferentes. Teste a estratégia no mecanismo específico com gravações intercaladas, deadlocks e timeouts, e verifique tanto as linhas afetadas quanto a reconciliação antes de afirmar que o processo concluiu corretamente o histórico.

Verificar antes de remover a estrutura antiga

O término do comando não comprova que os dados estejam corretos. Verifique se não há linhas pendentes, valide as restrições e compare os resultados com a transformação esperada. Revise valores nulos e casos extremos, e confirme que as leituras usam o novo campo sem piorar o comportamento funcional ou o desempenho.

Monitore também processos pouco frequentes, como relatórios ou tarefas periódicas. Mantenha a estrutura antiga enquanto houver leitores ou escritores dependentes: sua presença é uma medida de compatibilidade, não uma prova de que a migração foi concluída.

Definir pausas, reversão e janela de manutenção

Definir pausas, reversão e janela de manutenção — guía visual de DedicatedPHP

Defina sinais para pausar: erros ou latência acima do limite acordado, bloqueios, atraso excessivo das réplicas, pressão sobre os recursos ou discrepâncias crescentes. Determine quem interrompe o processo e como retomá-lo a partir do último lote confirmado. Monitore a duração dos lotes, os erros, a CPU, o disco e os bloqueios.

Reverter o código não equivale a reverter os dados. Depois de aceitar gravações apenas no novo formato, uma transformação inversa pode perder informações. A recuperação pode consistir em voltar para uma versão compatível e manter as duas estruturas, não em desfazer os dados. Uma janela de manutenção pode ser preferível se não for possível manter a coerência entre versões, se os bloqueios necessários forem inaceitáveis ou se não for possível verificar o resultado com a aplicação ativa.

  • O mecanismo e sua versão permitem a alteração com bloqueios aceitáveis?
  • Leitores e escritores antigos podem coexistir com o esquema intermediário?
  • O comando PHP pode ser pausado e retomado sem duplicar efeitos nem pular linhas?
  • Há uma estratégia testada para escritores concorrentes, erros transitórios e discrepâncias?
  • A integridade e o impacto serão medidos antes de remover a estrutura antiga?
Deseja aplicar essas ideias ao seu projeto?Vamos discutir sua plataforma PHP.
Veja os serviços relacionados