A tabela de pedidos tem 40 milhões de linhas e o time precisa trocar a coluna status de varchar para um tipo enumerado. Em staging, com 200 mil registros, o ALTER TABLE roda em dois segundos. Em produção, a mesma instrução mantém um lock exclusivo pelo tempo que o banco levar para reescrever o arquivo inteiro — e toda requisição que toca a tabela fica na fila atrás dele. A diferença entre uma janela de manutenção de madrugada e uma migração invisível não está na instrução SQL: está em quebrar a mudança em etapas que nunca exigem que schema antigo e código novo existam ao mesmo tempo.
O problema não é o ALTER, é o lock que ele segura
Alterações de schema se dividem em duas famílias. As que só mexem no catálogo — adicionar coluna anulável sem default, renomear constraint, criar índice de forma concorrente — terminam em milissegundos porque não tocam nos dados. As que reescrevem a tabela — mudar tipo, adicionar coluna NOT NULL com valor calculado, alterar collation — precisam percorrer cada linha. Nessa segunda família o custo cresce com o volume, e o lock que o banco adquire bloqueia leitura, escrita ou ambas, dependendo do motor.
Há um agravante que costuma passar despercebido: o lock não começa quando o ALTER inicia, começa quando ele consegue entrar na fila. Se existe uma transação longa aberta sobre a tabela — um relatório que varre meses de histórico, um cursor esquecido por um job — o ALTER espera por ela segurando um pedido de lock exclusivo. Todas as consultas que chegam depois ficam atrás desse pedido. O resultado é uma fila que trava a aplicação antes mesmo de a migração ter começado a fazer qualquer trabalho.
Por isso a primeira decisão não é como executar a alteração, e sim se ela pode ser decomposta em passos que jamais precisem de um lock demorado. O padrão que resolve isso se chama expand-contract: expandir o schema para suportar as duas formas, migrar os dados e o código, depois contrair removendo a forma antiga.
As sete etapas do expand-contract
O roteiro abaixo vale para qualquer mudança destrutiva — troca de tipo, divisão de coluna, normalização de campo, renomeação. Cada etapa é um deploy independente, e nenhuma delas pode assumir que a anterior já rodou em todas as instâncias da aplicação.
- Adicionar a nova estrutura vazia. Coluna nova, anulável, sem default calculado. Essa operação é de catálogo na maioria dos bancos modernos e termina instantaneamente. Nada no código a usa ainda.
- Escrever nos dois lugares. Deploy da aplicação gravando em coluna velha e nova simultaneamente, lendo ainda da velha. A partir daqui todo registro novo já nasce correto; só falta o histórico.
- Backfill em lotes. Preencher o histórico com um job que processa faixas pequenas de chave primária, com pausa entre elas. Detalhado na próxima seção.
- Validar a equivalência. Consulta que conta divergências entre as duas colunas. Só avança quando o resultado é zero em duas execuções separadas por algumas horas — a segunda cobre registros que entraram durante a primeira.
- Ler da nova, escrever nas duas. Deploy que inverte a fonte de leitura mantendo a escrita dupla. Este é o único passo com risco real, e é também o único que reverte instantaneamente: basta voltar a versão anterior da aplicação, porque a coluna antiga continua atualizada.
- Parar de escrever na antiga. Deploy que remove a escrita dupla. A coluna velha congela, mas permanece no banco.
- Remover a coluna antiga. Só depois de o sistema rodar alguns dias lendo exclusivamente da nova. Em Postgres, DROP COLUMN é operação de catálogo e não reescreve a tabela; o espaço volta no vacuum.
São sete deploys onde havia um comando. O ganho não é elegância — é que qualquer etapa pode ser revertida sem perda de dado, e nenhuma delas segura lock por mais que alguns milissegundos.
Backfill: o lote que não derruba a réplica
O erro clássico do backfill é um único UPDATE sobre a tabela toda. Além de manter uma transação gigantesca, ele gera um volume de WAL que a réplica precisa aplicar sequencialmente — e o lag de replicação sobe até que leituras direcionadas à réplica passem a devolver dados defasados. O padrão correto é percorrer a chave primária em faixas fixas, confirmar cada lote e esperar.
-- roda em loop na aplicação ou em um job dedicado
UPDATE pedidos
SET status_enum = status::pedido_status
WHERE id >= :inicio AND id < :inicio + 5000
AND status_enum IS NULL;
Entre um lote e o seguinte, consultar o lag da réplica e dormir enquanto ele passar do limite aceitável. Se o lag não cair, o problema é o tamanho do lote — reduza pela metade em vez de aumentar a pausa.
O tamanho do lote depende do custo por linha, não do total de linhas. Uma coluna de conversão barata aceita faixas maiores; uma que dispara trigger, atualiza índice composto ou recalcula valor derivado exige faixas bem menores. A calibragem prática é começar conservador — faixas na casa do milhar — medir o tempo de cada lote e subir até que ele se aproxime do limite que a operação tolera, tipicamente algo abaixo de um segundo por lote.
Duas condições fazem o job ser seguro para reexecução: filtrar por IS NULL na coluna de destino, para que um lote repetido não faça trabalho duplicado, e persistir o último intervalo concluído fora da transação, para que uma queda no meio do processo retome de onde parou em vez de recomeçar.
Onde cada banco muda o roteiro
O padrão é o mesmo, mas o que cada motor considera operação barata varia — e é isso que determina quais etapas podem ser puladas.
| Operação | PostgreSQL | MySQL / InnoDB | SQL Server |
|---|---|---|---|
| Adicionar coluna anulável | Catálogo, instantâneo | Instantâneo a partir do 8.0 com ALGORITHM=INSTANT | Catálogo, instantâneo |
| Adicionar coluna NOT NULL com default | Instantâneo desde a versão 11 | Instantâneo em versões recentes do 8.0 | Instantâneo na edição Enterprise |
| Criar índice sem bloquear escrita | CREATE INDEX CONCURRENTLY | Online DDL por padrão | WITH (ONLINE = ON), Enterprise |
| Alterar tipo da coluna | Reescreve a tabela | Reescreve, mas permite escrita concorrente em parte dos casos | Reescreve |
| Remover coluna | Catálogo; espaço volta no vacuum | Reescreve a tabela | Catálogo; espaço volta na reconstrução |
A linha que mais muda decisão é a última. Em Postgres, o passo 7 é gratuito e pode acontecer assim que a equipe se sentir confortável. Em MySQL, remover a coluna reescreve a tabela inteira — o que significa que o passo final merece a mesma cautela do backfill e costuma ser executado por ferramenta externa de migração online, não por DDL direto.
As armadilhas que aparecem só em produção
Três falhas concentram a maior parte dos incidentes de migração, e nenhuma delas aparece em ambiente de teste.
A primeira é o timeout de lock ausente. Sem um limite explícito, o ALTER espera indefinidamente pela transação longa e leva a aplicação junto. Definir um timeout curto — poucos segundos — antes de qualquer DDL faz a migração falhar rápido em vez de travar o sistema. Falhar é o comportamento desejado: o job tenta de novo em um momento de menor concorrência.
A segunda é a ordem entre deploy e migração. Em rollout gradual, instâncias antigas e novas da aplicação convivem por alguns minutos. Se a migração remove algo que a versão antiga ainda usa, essas instâncias quebram. A regra que resolve: migração que adiciona vai antes do deploy, migração que remove vai depois — e o passo de remoção só entra no ciclo seguinte, nunca no mesmo.
A terceira é a constraint validada na criação. Adicionar chave estrangeira ou CHECK faz o banco varrer a tabela para conferir as linhas existentes, com lock durante a varredura. Postgres permite criar a constraint como NOT VALID e rodar VALIDATE CONSTRAINT depois, em lock mais fraco — dois comandos no lugar de um, pelo mesmo motivo de sempre.
O próximo passo concreto para quem tem uma alteração dessas na fila: pegar a migração pendente, classificar cada instrução como catálogo ou reescrita consultando a tabela acima, e reescrever as reescritas no formato de sete etapas. O que sobrar de instrução barata pode ir direto — desde que com timeout de lock definido.
Deixe um comentário