Pular para o conteúdo

Rodar migration sem downtime

Avançado16 min de leituradados

Vamos renomear a coluna email para email_normalizado numa tabela com milhões de linhas, sem parar o serviço e sem impedir o rollback da aplicação. O mesmo roteiro serve para trocar tipo, dividir tabela ou mudar formato de dado.

A restrição que rege tudo: durante qualquer deploy sem downtime, duas versões da aplicação rodam ao mesmo tempo contra um único banco.

Compatível (um deploy) Incompatível (três deploys)
Adicionar tabela Remover tabela ou coluna
Adicionar coluna anulável Renomear coluna
Adicionar índice concorrente Estreitar tipo
Ampliar tipo Adicionar NOT NULL sem default
Constraint NOT VALID, validada depois Trocar chave primária

Renomear é incompatível. Portanto, três deploys.

Adicione a estrutura nova. Nada é removido, e a versão antiga continua funcionando.

SET lock_timeout = '3s'; -- falhe rápido em vez de travar tudo
ALTER TABLE clientes ADD COLUMN email_normalizado text;

E faça a aplicação escrever nas duas colunas, lendo ainda da antiga:

def salvar(cliente):
cliente.email = valor
cliente.email_normalizado = valor.lower() # escrita dupla
db.commit()
def ler(cliente):
return cliente.email # leitura ainda na antiga

Rollback deste deploy é trivial: a coluna nova fica lá, sem uso.

Um UPDATE sem WHERE numa tabela grande segura lock, infla o WAL e pode derrubar o banco. Faça em lotes, fora do pico, com pausa entre eles.

-- repita até afetar 0 linhas
UPDATE clientes SET email_normalizado = lower(email)
WHERE email_normalizado IS NULL
AND id IN (SELECT id FROM clientes WHERE email_normalizado IS NULL LIMIT 5000);
Janela do terminal
# script de backfill com pausa e progresso
while true; do
n=$(psql "$URL" -tAc "UPDATE clientes SET email_normalizado = lower(email)
WHERE id IN (SELECT id FROM clientes
WHERE email_normalizado IS NULL LIMIT 5000)
RETURNING 1" | wc -l)
echo "atualizadas: $n"
[ "$n" -eq 0 ] && break
sleep 1 # respire: dê espaço para a carga normal
done

Backfill não é migration: é job de dados, com progresso, retomada e limite de taxa. Não o coloque no caminho do deploy.

Monitore durante a execução: replicação atrasando, WAL crescendo, latência de escrita subindo. Se qualquer um disparar, pause.

Com o histórico preenchido, passe a ler da coluna nova, mantendo a escrita dupla.

def ler(cliente):
return cliente.email_normalizado

Idealmente atrás de uma feature flag, para reverter a leitura sem deploy. Observe por dias, não por minutos — divergência aparece em caso raro.

-- verificação de consistência antes de seguir
SELECT count(*) FROM clientes
WHERE email_normalizado IS DISTINCT FROM lower(email); -- precisa ser 0

Agora que a coluna está completa, você pode endurecer as regras:

ALTER TABLE clientes ADD CONSTRAINT email_norm_nn CHECK (email_normalizado IS NOT NULL) NOT VALID;
ALTER TABLE clientes VALIDATE CONSTRAINT email_norm_nn; -- não bloqueia escrita

Só depois de dias de estabilidade, pare de escrever na coluna antiga e, em um deploy seguinte, remova-a.

def salvar(cliente):
cliente.email_normalizado = valor.lower() # escrita simples
-- com backup verificado imediatamente antes
ALTER TABLE clientes DROP COLUMN email;

Este é o único passo irreversível. Não o junte com o anterior no mesmo deploy.

A pergunta a responder antes de cada deploy: se eu reverter a aplicação agora, o esquema atual atende à versão anterior?

Etapa Rollback da aplicação Rollback do esquema
Após expandir seguro desnecessário
Após backfill seguro desnecessário
Após inverter leitura seguro (escrita dupla ainda ativa) desnecessário
Após contrair não — a versão antiga usa a coluna removida impossível recuperar dado

Ensaie a migration em uma cópia de produção com volume e índices reais, medindo tempo e lock. Uma migration que leva 200 ms em desenvolvimento pode levar minutos com lock em produção.

Sintoma Causa provável
Aplicação inteira travada ALTER TABLE segurando lock exclusivo; use lock_timeout
Réplica muito atrasada Backfill agressivo; reduza o lote e aumente a pausa
Disco enchendo durante a migração WAL acumulando; pause e deixe drenar
Erros após o deploy Escrita dupla ausente em algum caminho do código
DROP COLUMN derrubou a aplicação Alguma versão antiga ainda em execução
Migration rodando várias vezes Executada em cada réplica; use Job único ou advisory lock
  • Mudança classificada como compatível ou incompatível.
  • Cada deploy contém apenas mudança compatível com a versão anterior.
  • Backfill em lotes, fora do pico, com progresso e retomada.
  • Consistência verificada antes de inverter a leitura.
  • Contração feita dias depois, em deploy próprio.
  • Backup verificado antes de qualquer passo destrutivo.
  • Migration ensaiada em cópia de produção, com tempo e lock medidos.
  • Plano de recuperação escrito, em três linhas, para quem estiver de plantão.