Migração de banco que não cabe numa janela: primeiro soma, depois tira
Renomeie coluna ou mude esquema sem derrubar quem está no ar: adicione o novo, publique código que grava nos dois, copie o dado em lotes e só então remova o antigo.
O deploy renomeou a coluna e publicou o código novo no mesmo minuto. Durante três minutos, as instâncias antigas, que ainda não tinham reiniciado, tentaram ler e gravar numa coluna que não existia mais. Pedidos falharam e alguns cadastros ficaram sem nome. Bastou. A resposta curta: nunca dependa de código e banco mudarem no mesmo instante. Primeiro adicione a estrutura nova sem remover a antiga. Publique código que grava nas duas e lê a nova com fallback. Copie o dado antigo em lotes. Passe a ler só a nova. E só quando nenhum código em execução usar a estrutura antiga, remova-a. Cada passo é seguro sozinho e pode ser desfeito sem voltar o banco.
Por que mudar tudo de uma vez quebra
Num deploy real, versões diferentes do código convivem. Instâncias reiniciam uma por vez, workers terminam o que estavam fazendo, um job agendado roda com a imagem antiga. Durante esse intervalo, o banco precisa servir aos dois. Um RENAME COLUMN serve só a um deles.
O mesmo vale para o rollback. Se o código novo der problema e você voltar a imagem, o código antigo encontra um banco que já não tem o que ele espera. Uma migração que só funciona com o código certo no segundo exato não é migração. É aposta, e a volta fica impossível sem restaurar backup.
Há ainda o bloqueio. No PostgreSQL, a maioria dos ALTER TABLE pede um bloqueio exclusivo na tabela, ainda que por um instante. Se existe uma transação longa usando a tabela, o ALTER fica esperando, e todas as consultas que chegam depois ficam na fila atrás dele. Um comando que levaria milissegundos para a tabela inteira por minutos.
Como renomear uma coluna em etapas
Exemplo em PostgreSQL: a tabela clientes tem a coluna nome, e o novo modelo quer nome_completo, obrigatória.
Passo 1: adicionar a coluna nova
SET lock_timeout = '5s';
ALTER TABLE clientes ADD COLUMN nome_completo text;
Coluna nova que aceita nulo é uma alteração só de catálogo: não reescreve a tabela. O lock_timeout faz o comando desistir em vez de travar todo mundo se não conseguir o bloqueio; nesse caso, tente de novo num momento mais calmo. O código em produção ignora a coluna, então esse passo vai sozinho, antes de qualquer deploy.
Passo 2: publicar código que grava nas duas e lê com fallback
await pool.query(
'UPDATE clientes SET nome = $1, nome_completo = $1 WHERE id = $2',
[nome, id]
);
const { rows } = await pool.query(
'SELECT id, COALESCE(nome_completo, nome) AS nome_completo FROM clientes WHERE id = $1',
[id]
);
Toda escrita passa a alimentar as duas colunas. Toda leitura prefere a nova e cai na antiga quando ela ainda está vazia. Se este deploy der problema, voltar a imagem anterior é seguro: ela continua usando nome, que segue atualizada.
Passo 3: copiar o dado antigo em lotes
UPDATE clientes
SET nome_completo = nome
WHERE id IN (
SELECT id FROM clientes
WHERE nome_completo IS NULL AND nome IS NOT NULL
ORDER BY id
LIMIT 5000
);
Rode esse comando repetidamente, com uma pausa curta entre execuções, até ele informar zero linhas atualizadas. Um único UPDATE na tabela inteira segura bloqueio de linha em milhões de registros por muito tempo, gera um volume enorme de log de transação de uma vez e atrasa as réplicas. Em lotes, cada transação é curta e o resto do sistema segue trabalhando.
Passo 4: tornar a coluna obrigatória sem varrer a tabela com bloqueio
SET lock_timeout = '5s';
ALTER TABLE clientes ADD CONSTRAINT clientes_nome_completo_nn CHECK (nome_completo IS NOT NULL) NOT VALID;
ALTER TABLE clientes VALIDATE CONSTRAINT clientes_nome_completo_nn;
ALTER TABLE clientes ALTER COLUMN nome_completo SET NOT NULL;
ALTER TABLE clientes DROP CONSTRAINT clientes_nome_completo_nn;
O NOT VALID cria a restrição só para dados novos, sem conferir os antigos. O VALIDATE confere os antigos com um bloqueio mais leve, que não impede leitura nem escrita. A partir do PostgreSQL 12, o SET NOT NULL aproveita uma restrição CHECK válida equivalente e pula a varredura da tabela. Depois disso, a CHECK é redundante e pode sair.
Passo 5: publicar código que lê e grava só a coluna nova
Remova o fallback e a escrita em nome. Antes de seguir, confirme que nenhum código em execução ainda usa a coluna antiga: instâncias, workers, jobs agendados, relatórios, integrações de outros times. Uma busca pelo nome da coluna no repositório inteiro ajuda; a lista de consultas lentas ou o pg_stat_statements, se você o usa, também.
Passo 6: remover a coluna antiga
SET lock_timeout = '5s';
ALTER TABLE clientes DROP COLUMN nome;
Este é o único passo irreversível. Por isso ele vai numa release separada, depois que o passo 5 ficou estável por um tempo combinado, e com backup feito imediatamente antes. Isso é o que torna possível voltar a versão anterior em qualquer etapa até aqui.
O mesmo raciocínio para outras mudanças
- Trocar o tipo de uma coluna: coluna nova com o tipo certo, escrita dupla, cópia em lotes, troca de leitura, remoção.
- Dividir uma tabela: tabela nova, escrita dupla, cópia, troca de leitura, remoção da coluna antiga.
- Criar índice em tabela grande:
CREATE INDEX CONCURRENTLY, que não bloqueia escrita. Ele não pode rodar dentro de uma transação, o que muitas ferramentas de migração fazem por padrão; confira como a sua desliga isso.
No MySQL, o comportamento de cada alteração depende da versão e do tipo de operação. A documentação de online DDL da sua versão diz quais mudanças são instantâneas, quais permitem escrita concorrente e quais copiam a tabela. Consulte antes, não depois.
Erros comuns
- Renomear a coluna no mesmo deploy do código. As instâncias antigas quebram durante a troca, e o rollback do código não funciona mais.
- Copiar tudo num único UPDATE. Transação gigante, réplicas atrasadas e bloqueio de linha que trava as escritas do sistema.
- ALTER sem
lock_timeout. O comando espera uma transação longa e enfileira todas as consultas da tabela atrás dele. - Esquecer o worker e o job agendado. O site já usa a coluna nova, mas o relatório noturno ainda lê a antiga e quebra depois do DROP.
- Remover a coluna antiga na mesma release que para de usá-la. Some a última chance de voltar o código sem restaurar backup.
- Coluna obrigatória criada direto com NOT NULL e sem valor padrão. Em tabela com dados, o comando falha; em versões e bancos que reescrevem a tabela, trava tudo durante a reescrita.
Checklist
- Nenhuma release depende de código e banco mudarem no mesmo instante.
- Cada migração tem
lock_timeoutdefinido. - A estrutura nova é adicionada antes e a antiga removida numa release posterior.
- Existe uma fase de escrita dupla com leitura tolerante.
- Cópias de dado rodam em lotes, com pausa entre eles.
- Antes do DROP, foi confirmado que nenhum processo usa a estrutura antiga.
- A cada passo, voltar a imagem anterior foi testado sem voltar o banco.
Na sua próxima migração, dá para voltar o deploy sem voltar o banco? Se não dá, o passo está grande demais.
Referência: https://www.postgresql.org/docs/current/ddl-alter.html