Rodar migration sem downtime
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.
1. Separe compatível de incompatível
Seção intitulada “1. Separe compatível de incompatível”| 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.
2. Deploy 1 — expandir
Seção intitulada “2. Deploy 1 — expandir”Adicione a estrutura nova. Nada é removido, e a versão antiga continua funcionando.
SET lock_timeout = '3s'; -- falhe rápido em vez de travar tudoALTER 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 antigaRollback deste deploy é trivial: a coluna nova fica lá, sem uso.
3. Preencha o histórico em lotes
Seção intitulada “3. Preencha o histórico em lotes”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 linhasUPDATE 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);# script de backfill com pausa e progressowhile 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 normaldoneBackfill 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.
4. Deploy 2 — inverter a leitura
Seção intitulada “4. Deploy 2 — inverter a leitura”Com o histórico preenchido, passe a ler da coluna nova, mantendo a escrita dupla.
def ler(cliente): return cliente.email_normalizadoIdealmente 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 seguirSELECT count(*) FROM clientes WHERE email_normalizado IS DISTINCT FROM lower(email); -- precisa ser 0Agora 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 escrita5. Deploy 3 — contrair
Seção intitulada “5. Deploy 3 — contrair”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 antesALTER TABLE clientes DROP COLUMN email;Este é o único passo irreversível. Não o junte com o anterior no mesmo deploy.
6. Teste o rollback em cada etapa
Seção intitulada “6. Teste o rollback em cada etapa”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.
Se der errado
Seção intitulada “Se der errado”| 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 |
Checklist de pronto
Seção intitulada “Checklist de pronto”- 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.