O problema do deploy atômico
No mundo ideal, você faria deploy de banco e aplicação ao mesmo tempo. Na prática, isso não existe.
Mesmo com deployment automatizado, há um período de transição onde a nova versão da aplicação e a antiga estão rodando simultaneamente. Se a nova versão espera uma coluna que ainda não existe, ou a migration remove uma coluna que a versão antiga ainda usa, você tem downtime ou erros em produção.
A solução é o expand-contract pattern: separa mudanças de schema em fases que são compatíveis com múltiplas versões da aplicação.
As três fases
Expand (adicionar, nunca quebrar): Adicione colunas opcionais, crie novas tabelas, adicione índices. A aplicação antiga continua funcionando porque nada foi removido.
Migrate: Preencha as novas estruturas com dados existentes. Atualize a aplicação para usar o novo schema.
Contract (limpar): Após confirmar que a nova versão está estável, remova as estruturas antigas. Isso só pode acontecer depois que nenhuma instância da versão antiga esteja rodando.
Renomear uma coluna
Parece simples, mas renomear diretamente é breaking change.
Errado:
-- Migration direta: quebra a versão antiga da app
ALTER TABLE users RENAME COLUMN name TO full_name;Correto:
Fase 1 (Expand): adiciona a coluna nova, mantém a antiga:
ALTER TABLE users ADD COLUMN full_name VARCHAR;
-- Backfill: preenche dados existentes
UPDATE users SET full_name = name WHERE full_name IS NULL;
-- Trigger para manter sincronizado durante a transição
CREATE OR REPLACE FUNCTION sync_user_name()
RETURNS TRIGGER AS $$
BEGIN
IF TG_OP = 'INSERT' OR NEW.name IS DISTINCT FROM OLD.name THEN
NEW.full_name := NEW.name;
END IF;
IF TG_OP = 'INSERT' OR NEW.full_name IS DISTINCT FROM OLD.full_name THEN
NEW.name := NEW.full_name;
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER sync_user_name_trigger
BEFORE INSERT OR UPDATE ON users
FOR EACH ROW EXECUTE FUNCTION sync_user_name();Fase 2 (Migrate): faz deploy da nova versão que usa full_name.
Fase 3 (Contract): remove a coluna antiga e o trigger:
DROP TRIGGER sync_user_name_trigger ON users;
DROP FUNCTION sync_user_name();
ALTER TABLE users DROP COLUMN name;Adicionar NOT NULL numa coluna existente
Adicionar NOT NULL diretamente em uma tabela com dados bloqueia a tabela inteira enquanto o PostgreSQL verifica todas as linhas.
Errado:
-- Bloqueia a tabela users inteira
ALTER TABLE users ALTER COLUMN email SET NOT NULL;Correto:
-- 1. Adiciona um check constraint sem validar dados existentes
-- Isso não bloqueia leituras
ALTER TABLE users
ADD CONSTRAINT users_email_not_null CHECK (email IS NOT NULL) NOT VALID;
-- 2. Valida em background (não bloqueia writes)
-- Bloqueia apenas leituras concorrentes brevemente
ALTER TABLE users VALIDATE CONSTRAINT users_email_not_null;
-- 3. Depois de validado, pode promover para NOT NULL
-- O PostgreSQL reconhece o check constraint e faz isso sem verificar dados novamente
ALTER TABLE users ALTER COLUMN email SET NOT NULL;
-- 4. Remove o check constraint (redundante agora)
ALTER TABLE users DROP CONSTRAINT users_email_not_null;A chave é NOT VALID: cria o constraint sem verificar dados existentes, permitindo fazer o processo sem lock longo.
Adicionar índice sem bloquear
CREATE INDEX padrão bloqueia writes na tabela durante a criação:
-- Bloqueia writes por minutos em tabelas grandes
CREATE INDEX idx_users_email ON users(email);Use CONCURRENTLY:
-- Não bloqueia writes, mas leva mais tempo
CREATE INDEX CONCURRENTLY idx_users_email ON users(email);Cuidado: CREATE INDEX CONCURRENTLY não pode rodar dentro de uma transação. Em migrations que usam transação por padrão, você precisa desabilitar:
// Com Knex:
await knex.raw("CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_users_email ON users(email)");
// Com migration runner que usa transação, execute o índice antes:
exports.up = async (knex) => {
// Fora da transação implícita
await knex.schema.raw("CREATE INDEX CONCURRENTLY ...");
};Remover uma coluna
Remover diretamente pode quebrar a versão antiga da aplicação que ainda lê essa coluna.
Fase 1: a nova versão da aplicação para de usar a coluna. Coloca para ignorar nos selects SELECT * (preferencialmente, selecione colunas explicitamente).
Fase 2: aguarda que a versão antiga seja totalmente substituída em todos os servidores.
Fase 3:
-- Marca a coluna como invisível (PostgreSQL 12+)
-- Aplicações que usam SELECT * não a veem mais, mas o dado ainda existe
ALTER TABLE users ALTER COLUMN deprecated_field SET INVISIBLE;
-- Depois de confirmar que nada quebrou:
ALTER TABLE users DROP COLUMN deprecated_field;Se seu PostgreSQL não suporta INVISIBLE, pule direto para o DROP após confirmar que nenhuma instância da versão antiga está rodando.
Backfill em tabelas grandes
Preencher uma nova coluna com UPDATE ... WHERE column IS NULL em tabela com 50M de linhas é perigoso: uma única transação gigante bloqueia locks por muito tempo.
Faça em lotes:
async function backfillInBatches(
tableName: string,
batchSize = 1000
): Promise<void> {
let lastId = 0;
while (true) {
const result = await db.raw(`
UPDATE ${tableName}
SET full_name = name
WHERE id > ? AND full_name IS NULL
ORDER BY id
LIMIT ?
RETURNING id
`, [lastId, batchSize]);
const updated = result.rows;
if (updated.length === 0) break;
lastId = updated[updated.length - 1].id;
// Pausa entre lotes para não sobrecarregar o banco
await new Promise((r) => setTimeout(r, 50));
console.log(`Backfill: ${lastId} processados`);
}
}Checklist antes de qualquer migration
Antes de rodar migrations em produção:
- Testou em banco com volume similar ao de produção?
- Mediu o tempo de execução esperado?
- A migration é reversível? (tem um
downfuncional?) - Tem um plano se a migration falhar no meio?
- A nova versão da app funciona com o schema atual e com o schema novo?
- Monitoramento configurado para detectar lentidão ou erros durante o deploy?
Conclusão
Zero-downtime migrations não são difíceis, mas exigem disciplina. O expand-contract pattern é o caminho: você nunca faz mudanças breaking em uma única migration. Separa em três fases e cada uma pode ir a produção independentemente.
Isso parece mais trabalho no início, mas evita o "vamos entrar em manutenção sábado de madrugada" que todo time de backend quer evitar.