Zero-Downtime Migrations: Como Alterar Colunas e Tabelas Sem Derrubar Sua Aplicação

Conteúdo técnico toda semana
Receba artigos sobre arquitetura, padrões de projeto e engenharia de software. Direto no seu e-mail, sem enrolação.
Sem spam. Cancele a qualquer momento com 1 clique.
Neste artigo
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 chama de "migration multi-step". A diferença aqui é que cada step é um deploy independente.
Renomeando uma coluna sem quebrar nada
Renomear coluna é a operação que mais pega gente de surpresa. Um ALTER TABLE ... RENAME COLUMN é instantâneo no PostgreSQL (só muda metadado), mas quebra todo código que referencia o nome antigo. Se você tem instâncias antigas rodando durante um rolling update, elas vão falhar.
Fase 1: criar coluna nova e trigger de sincronização
-- migration_001_add_new_column.sql
ALTER TABLE users ADD COLUMN full_name TEXT;
-- Trigger para manter ambas as colunas sincronizadas durante a transição.
-- Qualquer write no nome antigo propaga para o novo, e vice-versa.
CREATE OR REPLACE FUNCTION sync_user_name() RETURNS TRIGGER AS $$
BEGIN
IF TG_OP = 'INSERT' OR TG_OP = 'UPDATE' THEN
-- Se o código novo escreveu em full_name, propaga para name
IF NEW.full_name IS DISTINCT FROM OLD.full_name THEN
NEW.name := NEW.full_name;
-- Se o código antigo escreveu em name, propaga para full_name
ELSIF NEW.name IS DISTINCT FROM OLD.name THEN
NEW.full_name := NEW.name;
END IF;
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trg_sync_user_name
BEFORE INSERT OR UPDATE ON users
FOR EACH ROW EXECUTE FUNCTION sync_user_name();Fase 2: backfill
-- migration_002_backfill_full_name.sql
UPDATE users SET full_name = name WHERE full_name IS NULL AND id BETWEEN 1 AND 100000;
UPDATE users SET full_name = name WHERE full_name IS NULL AND id BETWEEN 100001 AND 200000;Fase 3: atualizar código da aplicação
Neste ponto, o deploy novo lê e escreve em full_name. O deploy antigo ainda funciona porque o trigger mantém name sincronizado.
Fase 4: remover coluna antiga e trigger
-- migration_004_drop_old_column.sql
-- Só execute quando NENHUMA instância antiga estiver rodando.
DROP TRIGGER trg_sync_user_name ON users;
DROP FUNCTION sync_user_name();
ALTER TABLE users DROP COLUMN name;Criando índices sem bloquear writes
-- migration_create_index.sql
-- CONCURRENTLY não adquire SHARE lock, então writes continuam normalmente.
-- Desvantagem: demora mais e não pode rodar dentro de uma transaction.
CREATE INDEX CONCURRENTLY idx_orders_status ON orders (status);No Prisma, migrations rodam dentro de uma transaction por padrão. Para usar CONCURRENTLY, você precisa de uma migration SQL manual. O guia de versionamento com TypeORM cobre como gerenciar migrations SQL puras em ORMs que esperam controle transacional.
Backfill em batches com script TypeScript
Para tabelas grandes, um script de backfill controlado é mais seguro que um UPDATE monolítico.
// scripts/backfill-order-status.ts
import { PrismaClient } from "@prisma/client";
const prisma = new PrismaClient();
const BATCH_SIZE = 5000;
// Intervalo entre batches para não saturar o banco.
// Ajuste conforme a carga de produção: 100ms para horário de pico, 10ms para madrugada.
const DELAY_MS = 100;
async function backfillOrderStatus(): Promise<void> {
let updatedCount = 0;
let totalUpdated = 0;
do {
// Usar updateMany com WHERE status IS NULL garante idempotência:
// rodar o script duas vezes não causa problema.
const result = await prisma.$executeRaw`
UPDATE orders
SET status = 'pending'
WHERE id IN (
SELECT id FROM orders
WHERE status IS NULL
LIMIT ${BATCH_SIZE}
FOR UPDATE SKIP LOCKED
)
`;
updatedCount = result;
totalUpdated += updatedCount;
console.log(`Batch concluído: ${updatedCount} linhas. Total: ${totalUpdated}`);
if (updatedCount > 0) {
await new Promise((resolve) => setTimeout(resolve, DELAY_MS));
}
} while (updatedCount > 0);
console.log(`Backfill finalizado. ${totalUpdated} linhas atualizadas.`);
}
backfillOrderStatus()
.catch(console.error)
.finally(() => prisma.$disconnect());O FOR UPDATE SKIP LOCKED é o detalhe que importa: se outra transação está mexendo em uma linha, o batch pula essa linha em vez de ficar esperando. Isso evita deadlocks e mantém a latência previsível.
O que NÃO fazer
Anti-pattern 1: migration destrutiva em um único deploy
-- ERRADO: tudo em uma migration, um deploy
ALTER TABLE orders ADD COLUMN status TEXT NOT NULL DEFAULT 'pending';
ALTER TABLE orders DROP COLUMN legacy_status;
CREATE INDEX idx_orders_status ON orders (status);Esse bloco faz três coisas perigosas de uma vez. O ADD COLUMN NOT NULL DEFAULT é seguro no PostgreSQL 11+, mas o DROP COLUMN quebra qualquer instância antiga que ainda referencia legacy_status. E o CREATE INDEX sem CONCURRENTLY bloqueia writes.
-- CORRETO: três deploys separados
-- Deploy 1: expand
ALTER TABLE orders ADD COLUMN status TEXT DEFAULT 'pending';
-- Deploy 2 (depois que todo código lê/escreve em status):
CREATE INDEX CONCURRENTLY idx_orders_status ON orders (status);
-- Deploy 3 (depois que nenhuma instância antiga roda):
ALTER TABLE orders DROP COLUMN legacy_status;Anti-pattern 2: backfill dentro da migration
// ERRADO: backfill dentro do migration runner do Prisma
// Isso roda dentro de uma transaction, segura lock, e pode dar timeout.
import { PrismaClient } from "@prisma/client";
const prisma = new PrismaClient();
// Dentro de prisma.$transaction (implícito no migrate deploy)
await prisma.order.updateMany({
where: { status: null },
data: { status: "pending" },
});O migration runner do Prisma executa cada migration em uma transaction. Um updateMany em 5 milhões de linhas dentro de uma transaction significa 5 milhões de linhas lockadas até o commit. O banco fica inacessível.
// CORRETO: backfill como script separado, fora do migration runner
// Use o script de batches mostrado na seção anterior.
// Execute manualmente ou via job queue após o deploy da migration.Se você precisa reverter uma migration que já rodou o backfill, o post sobre reverter migrations com segurança detalha como fazer sem perder dados.
Anti-pattern 3: confiar em feature flags para pular a fase expand
Usar feature flags para controlar qual coluna o código lê é válido durante a fase de transição. O erro é pular a fase expand e usar a flag para "ligar" a coluna nova que ainda não existe no banco. Se a flag ativa antes da migration rodar, a aplicação quebra.
Checklist de pré-deploy para migrations
Antes de rodar qualquer migration em produção, valide:
- A migration adquire
ACCESS EXCLUSIVElock? Se sim, por quanto tempo na tabela de produção? - Existe código antigo que vai quebrar com essa mudança de schema?
- O rollback é possível sem perda de dados?
- O backfill está separado da migration?
- Índices usam
CONCURRENTLY?
Você pode validar o tipo de lock de uma migration antes de rodar com:
-- Rode em uma transaction que você vai dar ROLLBACK.
-- pg_locks mostra exatamente qual lock a operação adquire.
BEGIN;
ALTER TABLE orders ADD COLUMN test_col TEXT;
SELECT locktype, relation::regclass, mode, granted
FROM pg_locks
WHERE pid = pg_backend_pid()
AND relation IS NOT NULL;
ROLLBACK;Automatizando a verificação com pg_stat_activity
Durante o deploy, monitore se a migration está bloqueando queries:
-- Mostra queries esperando por lock na tabela orders.
-- Se essa query retornar linhas, a migration está causando contenção.
SELECT
pid,
wait_event_type,
wait_event,
state,
left(query, 80) AS query_preview,
now() - query_start AS duration
FROM pg_stat_activity
WHERE wait_event_type = 'Lock'
AND query ILIKE '%orders%'
ORDER BY duration DESC;FAQ
Posso usar o Prisma Migrate para zero-downtime migrations?
O Prisma Migrate executa cada migration em uma transaction e não suporta CREATE INDEX CONCURRENTLY nativamente. Para operações que exigem zero-downtime, use migrations SQL manuais (prisma migrate diff gera o SQL, você ajusta e roda com prisma db execute). O guia de migrations seguras com Prisma detalha esse fluxo.
Qual o tamanho de tabela que justifica expand-contract?
Depende da tolerância a downtime e da carga. Tabelas com menos de 100k linhas e baixa concorrência (menos de 50 writes/segundo) toleram um ALTER TABLE direto sem impacto perceptível. Acima de 1 milhão de linhas ou com mais de 200 writes/segundo, expand-contract deixa de ser precaução e vira necessidade.
Como lidar com ORMs que geram migrations automáticas?
O ORM gera a migration. Você revisa o SQL gerado, identifica operações perigosas e substitui por versões seguras. Nenhum ORM do mercado (Prisma, TypeORM, Sequelize, Drizzle) gera automaticamente o padrão expand-contract. Isso é responsabilidade do engenheiro.
O trigger de sincronização não impacta performance?
Impacta. Cada write na tabela executa a função PL/pgSQL. Em tabelas com alto volume de escrita (acima de 1000 inserts/segundo), o overhead é mensurável: espere entre 5% e 15% de aumento na latência de writes. Por isso a fase de transição deve ser curta: dias, não semanas. Remova o trigger assim que todo o código migrar.
E se a migration já rodou e travou o banco?
Identifique o PID da migration com pg_stat_activity, avalie se é seguro cancelar (pg_cancel_backend(pid)) ou terminar (pg_terminate_backend(pid)), e rode a versão segura depois. Cancelar uma migration no meio pode deixar o schema em estado inconsistente, então tenha o plano de rollback pronto antes de apertar enter.
A posição que defendo
Zero-downtime migration não é uma técnica avançada reservada para empresas com SRE dedicado. É higiene básica de deploy. A partir do momento em que sua aplicação tem usuários reais e deploys frequentes, cada ALTER TABLE precisa passar pelo filtro: "isso funciona com duas versões do código rodando ao mesmo tempo?"
Se a resposta for "não", o caminho é expand-contract. Três deploys em vez de um. Mais trabalho? Sim. Mas a alternativa é acordar de madrugada porque um ALTER TABLE ADD COLUMN NOT NULL travou o banco por 45 segundos e o Kubernetes matou todos os pods por falha de liveness probe.

Escrito por
Marcos Soares
Fullstack Developer · CEO da Agência Poti
Fullstack Developer e CEO da Agência Poti. Mais de 20 anos construindo arquiteturas cloud-native com React, Next.js e sistemas distribuídos. Parceiro comercial do estúdio iellou design. Fundador do Vivo de Código.
Comentários
Participe da discussão
Seja o primeiro a comentar!
Continue Aprofundando
Conteúdo técnico toda semana
Receba artigos sobre arquitetura, padrões de projeto e engenharia de software. Direto no seu e-mail, sem enrolação.
Sem spam. Cancele a qualquer momento com 1 clique.


