Zero-Downtime Migrations: Como Alterar Colunas e Tabelas Sem Derrubar Sua Aplicação
O problema real: um ALTER TABLE que derruba tudo
Uma migration que adiciona uma coluna NOT NULL sem default a uma tabela com 5 milhões de linhas adquire um ACCESS EXCLUSIVE lock no PostgreSQL. Enquanto o banco reescreve cada linha para validar a constraint, toda query que toca aquela tabela entra em fila. Se a operação leva 30 segundos, são 30 segundos de requests empilhando, health checks falhando e load balancers removendo instâncias.
O ponto cego é que a migration funciona perfeitamente no ambiente de staging com 200 linhas. O problema só aparece em produção, com volume real.
Este post cobre o padrão expand-contract e as técnicas operacionais para alterar schema em produção sem interromper o serviço. O foco é PostgreSQL com Prisma, mas os princípios se aplicam a qualquer banco relacional.
Expand-Contract: o modelo mental
A ideia é dividir qualquer migration destrutiva em fases seguras:
- Expand: adicione a estrutura nova sem remover a antiga. A aplicação continua funcionando com o schema antigo.
- Migrate data: preencha a estrutura nova com dados. Pode ser um backfill assíncrono.
- Transition: atualize o código da aplicação para ler e escrever na estrutura nova.
- Contract: remova a estrutura antiga quando nenhum deploy antigo estiver rodando.
Cada fase é um deploy separado. Cada fase é reversível. Se algo quebra na fase 2, você volta o código da fase 1 e a estrutura antiga ainda está lá.
Se você usa rolling updates ou blue-green deploy, esse modelo é obrigatório: durante a janela de transição, instâncias antigas e novas coexistem. O schema precisa ser compatível com ambas.
Operações perigosas e suas alternativas seguras
| Operação | Risco | Alternativa zero-downtime |
|---|---|---|
ADD COLUMN ... NOT NULL (sem default) | ACCESS EXCLUSIVE lock + rewrite da tabela inteira | Adicionar com default, depois remover o default |
ALTER COLUMN TYPE (ex: varchar → integer) | Rewrite completo da tabela | Criar coluna nova, backfill, trocar no código, remover antiga |
RENAME COLUMN | Quebra queries em andamento | Criar coluna nova, manter ambas, migrar código, remover antiga |
DROP COLUMN | Quebra código antigo ainda em execução | Remover do código primeiro, depois dropar a coluna |
ADD INDEX | SHARE lock bloqueia writes | CREATE INDEX CONCURRENTLY |
ADD CONSTRAINT ... NOT NULL (retroativa) | Valida todas as linhas com lock | NOT VALID + VALIDATE CONSTRAINT separado |
Adicionando coluna NOT NULL com segurança
A operação mais comum e mais perigosa. O caminho seguro tem três migrations separadas.
Fase 1: adicionar coluna nullable com default
-- migration_001_add_status_column.sql
-- Adicionar com DEFAULT não reescreve a tabela no PostgreSQL 11+.
-- O banco armazena o default no catálogo e aplica on-read.
ALTER TABLE orders ADD COLUMN status TEXT DEFAULT 'pending';
Fase 2: backfill de dados existentes
-- migration_002_backfill_status.sql
-- Atualizar em batches para não segurar lock por tempo demais.
-- O WHERE evita reescrever linhas que já têm valor correto.
UPDATE orders
SET status = 'pending'
WHERE status IS NULL
AND id BETWEEN 1 AND 100000;
UPDATE orders
SET status = 'pending'
WHERE status IS NULL
AND id BETWEEN 100001 AND 200000;
Fase 3: adicionar constraint NOT NULL sem rewrite
-- migration_003_add_not_null_constraint.sql
-- NOT VALID diz ao PostgreSQL para não validar linhas existentes agora.
-- Isso adquire um lock brevíssimo (milissegundos).
ALTER TABLE orders ADD CONSTRAINT orders_status_not_null
CHECK (status IS NOT NULL) NOT VALID;
-- VALIDATE CONSTRAINT verifica linhas existentes sem ACCESS EXCLUSIVE lock.
-- Usa apenas SHARE UPDATE EXCLUSIVE, que não bloqueia reads nem writes.
ALTER TABLE orders VALIDATE CONSTRAINT orders_status_not_null;
Esse padrão de três fases é o que o [post sobre migrations seguras com Prisma](/b
Leia o artigo completo em https://www.vivodecodigo.com.br/backend/zero-downtime-migrations-alterar-colunas-tabelas-sem-derrubar-aplicacao