Migrações de Schema PostgreSQL Sem Downtime: O Guia Completo Para Mudar o Banco de Produção Sem Quebrar Nada
São 2 da manhã. Seu time acabou de deployar uma migração que adiciona uma coluna NOT NULL numa tabela com 50 milhões de linhas. A migração pegou um ACCESS EXCLUSIVE lock, bloqueando toda query naquela tabela. Os tempos de resposta da API dispararam de 50ms pra 30 segundos. O celular do engenheiro de plantão tá explodindo. Clientes tão tuitando prints de páginas de erro.
Você acabou de aprender, do pior jeito possível, que ALTER TABLE ... ADD COLUMN ... NOT NULL DEFAULT no PostgreSQL pode ser uma arma de destruição em massa em produção.
Esse guia existe pra você não precisar aprender essa lição na marra. Vamos cobrir cada padrão, ferramenta e armadilha envolvida em mudar schemas do PostgreSQL sem derrubar sua aplicação. Não é teoria. São estratégias testadas em produção que funcionam em tabelas com centenas de milhões de linhas.
Por que migrações de schema são perigosas
A maioria dos devs trata migrações de banco como deploys simples de código. Escreve um ALTER TABLE, roda durante o deploy, segue em frente. Isso funciona de boa em desenvolvimento, onde sua tabela users tem 50 linhas. Em produção, onde tem 50 milhões, a mesma migração pode derrubar toda sua aplicação.
O motivo:
O sistema de locks do PostgreSQL
Toda instrução DDL no PostgreSQL adquire locks. O problema não é que locks existem, é a fila de locks. Quando uma instrução DDL pede um ACCESS EXCLUSIVE lock e não consegue na hora (porque queries ativas têm locks conflitantes), ela espera na fila. Enquanto espera, cada nova query que precisa de qualquer lock naquela tabela também entra na fila atrás dela.
Cronologia do desastre:
00:00 Queries SELECT rodando (têm ACCESS SHARE locks)
00:01 ALTER TABLE pede ACCESS EXCLUSIVE lock → espera na fila
00:02 Novo SELECT chega → entra na fila atrás do ALTER TABLE
00:03 Novo SELECT chega → entra na fila atrás do ALTER TABLE
00:04 Novo SELECT chega → entra na fila atrás do ALTER TABLE
... Todas as queries na fila. App parece congelada.
00:30 SELECT original termina → ALTER TABLE pega o lock
00:31 ALTER TABLE executa (pode levar minutos em tabelas grandes)
02:00 ALTER TABLE completa → queries na fila finalmente executam
Isso é um outage de 2 minutos causado por uma única instrução ALTER TABLE. Numa tabela com muito tráfego, isso pode cascatear em esgotamento do pool de conexões, crashes da aplicação e falhas em cascata em todo o sistema.
As operações que te derrubam
Nem toda mudança de schema é igualmente perigosa. A matriz de risco:
| Operação | Tipo de Lock | Nível de Risco | Duração em 50M linhas |
|---|---|---|---|
ADD COLUMN (nullable, sem default) | ACCESS EXCLUSIVE | 🟢 Baixo | Milissegundos |
ADD COLUMN ... DEFAULT (PG 11+) | ACCESS EXCLUSIVE | 🟢 Baixo | Milissegundos |
ADD COLUMN ... NOT NULL DEFAULT (PG 11+) | ACCESS EXCLUSIVE | 🟡 Médio | Milissegundos (mas risco de fila de locks) |
DROP COLUMN | ACCESS EXCLUSIVE | 🟢 Baixo | Milissegundos |
ALTER COLUMN TYPE | ACCESS EXCLUSIVE | 🔴 Crítico | Minutos a horas (rewrite completo) |
ADD INDEX | SHARE lock | 🔴 Crítico | Minutos (bloqueia writes) |
ADD INDEX CONCURRENTLY | Sem lock | 🟢 Baixo | Minutos (não bloqueante) |
ADD NOT NULL CONSTRAINT | ACCESS EXCLUSIVE | 🔴 Crítico | Minutos (scan completo) |
ADD FOREIGN KEY | SHARE ROW EXCLUSIVE | 🔴 Crítico | Minutos (scan completo) |
As operações marcadas 🔴 são as que causam outages. Vamos ver como tornar cada uma delas segura.
O padrão Expand-Contract
O padrão expand-contract (também chamado de "mudança paralela") é a estratégia fundamental pra migrações sem downtime. A ideia é simples: nunca faça uma mudança que quebra num passo só. Em vez disso, divida em múltiplos passos que não quebram.
┌─────────────────────────────────────────────────────────────┐
│ PADRÃO EXPAND-CONTRACT │
│ │
│ Fase 1: EXPAND (Expandir) │
│ ┌──────────────────────────────────────────────────┐ │
│ │ Adicionar nova coluna/tabela junto da antiga │ │
│ │ Deployar código que escreve em AMBAS │ │
│ │ Leitores antigos continuam funcionando │ │
│ └──────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ Fase 2: MIGRATE (Migrar) │
│ ┌──────────────────────────────────────────────────┐ │
│ │ Backfill dos dados existentes pra nova │ │
│ │ Verificar consistência de dados │ │
│ │ Mudar leitores pra nova coluna/tabela │ │
│ └──────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ Fase 3: CONTRACT (Contrair) │
│ ┌──────────────────────────────────────────────────┐ │
│ │ Remover coluna/tabela antiga │ │
│ │ Remover código de compatibilidade │ │
│ │ Limpeza │ │
│ └──────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────┘
Vamos ver isso aplicado às migrações mais comuns e perigosas.
Exemplo: Renomear uma coluna
Você quer renomear users.full_name pra users.display_name. Um ALTER TABLE users RENAME COLUMN full_name TO display_name direto quebraria toda query que referencia full_name no instante que executa.
Step 1: Expand: Adicionar a nova coluna
-- Deploy 1: Adicionar nova coluna (instantâneo, sem rewrite) ALTER TABLE users ADD COLUMN display_name TEXT; -- Criar trigger pra manter as colunas sincronizadas CREATE OR REPLACE FUNCTION sync_display_name() RETURNS TRIGGER AS $$ BEGIN IF TG_OP = 'INSERT' OR TG_OP = 'UPDATE' THEN IF NEW.display_name IS NULL AND NEW.full_name IS NOT NULL THEN NEW.display_name := NEW.full_name; ELSIF NEW.full_name IS NULL AND NEW.display_name IS NOT NULL THEN NEW.full_name := NEW.display_name; END IF; END IF; RETURN NEW; END; $$ LANGUAGE plpgsql; CREATE TRIGGER trigger_sync_display_name BEFORE INSERT OR UPDATE ON users FOR EACH ROW EXECUTE FUNCTION sync_display_name();
Step 2: Backfill: Copiar dados existentes
-- Backfill em lotes pra evitar transações longas DO $$ DECLARE batch_size INT := 10000; rows_updated INT; BEGIN LOOP UPDATE users SET display_name = full_name WHERE id IN ( SELECT id FROM users WHERE display_name IS NULL AND full_name IS NOT NULL LIMIT batch_size FOR UPDATE SKIP LOCKED ); GET DIAGNOSTICS rows_updated = ROW_COUNT; EXIT WHEN rows_updated = 0; RAISE NOTICE 'Updated % rows', rows_updated; PERFORM pg_sleep(0.1); COMMIT; END LOOP; END $$;
Step 3: Mudar leitores: Atualizar código da app pra ler de display_name
Step 4: Contract: Remover coluna antiga e trigger
-- Deploy 3: Depois que todo código lê de display_name DROP TRIGGER trigger_sync_display_name ON users; DROP FUNCTION sync_display_name(); ALTER TABLE users DROP COLUMN full_name;
Quatro deploys em vez de um. Mas zero downtime.
Criação segura de índices
Criar índices em tabelas grandes é uma das causas mais comuns de outages em produção. Um CREATE INDEX padrão pega um SHARE lock, bloqueando todos os writes (INSERT, UPDATE, DELETE) durante toda a construção do índice.
Sempre use CONCURRENTLY
-- ❌ PERIGOSO: Bloqueia todos os writes CREATE INDEX idx_users_email ON users(email); -- ✅ SEGURO: Não bloqueante CREATE INDEX CONCURRENTLY idx_users_email ON users(email);
CREATE INDEX CONCURRENTLY constrói o índice sem manter um lock que bloqueia writes. Faz isso escaneando a tabela duas vezes: uma pra construir o índice inicial, e outra pra capturar mudanças que aconteceram durante o primeiro scan.
As pegadinhas do CONCURRENTLY
Tem coisas importantes que você precisa saber:
1. Pode falhar silenciosamente. Se CREATE INDEX CONCURRENTLY encontra um erro (ex: violação de constraint unique), deixa um índice INVALID pra trás. Sempre verifique:
-- Verificar índices inválidos após criação SELECT indexname, indexdef FROM pg_indexes WHERE schemaname = 'public' AND indexname = 'idx_users_email'; -- Verificar validade SELECT pg_index.indisvalid FROM pg_index JOIN pg_class ON pg_index.indexrelid = pg_class.oid WHERE pg_class.relname = 'idx_users_email';
Se o índice é inválido, drope e tente de novo:
DROP INDEX CONCURRENTLY IF EXISTS idx_users_email; -- Depois retente CREATE INDEX CONCURRENTLY
2. Não pode rodar dentro de transação. Você não pode colocar CREATE INDEX CONCURRENTLY num bloco BEGIN...COMMIT. A maioria das ferramentas de migração roda cada migração numa transação por padrão. Você precisa desabilitar isso pra criação concorrente de índices.
3. Demora mais. Como escaneia a tabela duas vezes e não mantém lock exclusivo, criação concorrente leva 2-3x mais que CREATE INDEX normal. Numa tabela de 100 milhões de linhas, pode significar 30+ minutos. Planeje.
Adicionando NOT NULL constraints com segurança
Adicionar um NOT NULL constraint numa coluna existente é enganosamente perigoso. O PostgreSQL precisa escanear a tabela inteira pra verificar que não existem valores NULL, e mantém um ACCESS EXCLUSIVE lock enquanto faz isso.
O padrão seguro
-- Step 1: Adicionar CHECK constraint com NOT VALID (instantâneo) ALTER TABLE users ADD CONSTRAINT users_email_not_null CHECK (email IS NOT NULL) NOT VALID; -- Step 2: Validar o constraint numa transação separada -- Escaneia a tabela mas só pega -- SHARE UPDATE EXCLUSIVE lock (permite reads E writes) ALTER TABLE users VALIDATE CONSTRAINT users_email_not_null; -- Step 3 (opcional): Converter pra NOT NULL próprio -- No PostgreSQL 12+, se um CHECK constraint válido existe, -- adicionar NOT NULL é instantâneo ALTER TABLE users ALTER COLUMN email SET NOT NULL; -- Step 4: Dropar o CHECK constraint redundante ALTER TABLE users DROP CONSTRAINT users_email_not_null;
Por que funciona? NOT VALID diz pro PostgreSQL "eu prometo que esse constraint vale pra linhas novas, mas não verifica as existentes ainda". O passo VALIDATE CONSTRAINT depois verifica as existentes com um lock mais fraco que não bloqueia reads nem writes.
Adicionando Foreign Keys com segurança
Criação de foreign key é outro assassino silencioso. Por padrão, ALTER TABLE ... ADD CONSTRAINT ... FOREIGN KEY escaneia as duas tabelas (a que referencia e a referenciada) enquanto mantém locks.
-- ❌ PERIGOSO: Locka as duas tabelas durante validação ALTER TABLE orders ADD CONSTRAINT fk_orders_user_id FOREIGN KEY (user_id) REFERENCES users(id); -- ✅ SEGURO: Abordagem em dois passos -- Step 1: Adicionar constraint sem validar (instantâneo) ALTER TABLE orders ADD CONSTRAINT fk_orders_user_id FOREIGN KEY (user_id) REFERENCES users(id) NOT VALID; -- Step 2: Validar separadamente (lock mais fraco) ALTER TABLE orders VALIDATE CONSTRAINT fk_orders_user_id;
O mesmo truque NOT VALID. Linhas novas são validadas imediatamente, e as existentes são validadas num passo separado com lock menos restritivo.
Mudando tipos de coluna
Mudar o tipo de uma coluna (ex: INT pra BIGINT, ou VARCHAR(50) pra VARCHAR(255)) tipicamente requer rewrite completo da tabela. Numa tabela com milhões de linhas, isso significa minutos de downtime.
A abordagem Expand-Contract
-- Step 1: Adicionar nova coluna com o tipo desejado ALTER TABLE orders ADD COLUMN amount_v2 BIGINT; -- Step 2: Criar trigger de sincronização CREATE OR REPLACE FUNCTION sync_amount_v2() RETURNS TRIGGER AS $$ BEGIN NEW.amount_v2 := NEW.amount; RETURN NEW; END; $$ LANGUAGE plpgsql; CREATE TRIGGER trigger_sync_amount_v2 BEFORE INSERT OR UPDATE ON orders FOR EACH ROW EXECUTE FUNCTION sync_amount_v2(); -- Step 3: Backfill (em lotes) UPDATE orders SET amount_v2 = amount WHERE id BETWEEN 1 AND 1000000; -- ... repetir pra todos os ranges -- Step 4: Mudar código da app pra usar amount_v2 -- Step 5: Limpar DROP TRIGGER trigger_sync_amount_v2 ON orders; DROP FUNCTION sync_amount_v2(); ALTER TABLE orders DROP COLUMN amount; ALTER TABLE orders RENAME COLUMN amount_v2 TO amount;
A exceção: Aumento de tamanho VARCHAR
Algumas mudanças de tipo são seguras porque não precisam de rewrite da tabela:
-- ✅ SEGURO: Aumentar tamanho VARCHAR (sem rewrite) ALTER TABLE users ALTER COLUMN name TYPE VARCHAR(255); -- Só seguro pra aumentar, não pra diminuir! -- ✅ SEGURO: Remover limite VARCHAR completamente ALTER TABLE users ALTER COLUMN name TYPE TEXT; -- ✅ SEGURO: Mudar VARCHAR pra TEXT -- TEXT e VARCHAR são armazenados de forma idêntica no PostgreSQL
Lock Timeout: Sua rede de segurança
Toda migração deveria ter lock timeout configurado. Sem ele, uma migração vai esperar indefinidamente por um lock, enfileirando todas as queries atrás.
-- Configurar 5 segundos de lock timeout pra essa sessão SET lock_timeout = '5s'; -- Agora tentar a migração ALTER TABLE users ADD COLUMN bio TEXT; -- Se não conseguir o lock em 5 segundos, -- PostgreSQL lança erro em vez de esperar infinitamente
Nos seus scripts de migração, sempre configure lock timeout:
-- No topo de cada arquivo de migração SET lock_timeout = '5s'; SET statement_timeout = '30s'; -- Sua DDL de migração aqui ALTER TABLE users ADD COLUMN bio TEXT; -- Resetar por segurança RESET lock_timeout; RESET statement_timeout;
Lógica de retry
Quando usa lock timeouts, você precisa de lógica de retry. A migração pode falhar porque uma query de longa duração segurava um lock conflitante. Tudo bem. Espera um pouco e tenta de novo:
async function safeMigrate(sql: string, maxRetries = 5): Promise<void> { for (let attempt = 1; attempt <= maxRetries; attempt++) { try { await db.query('SET lock_timeout = \'5s\''); await db.query(sql); console.log(`Migration succeeded on attempt ${attempt}`); return; } catch (error) { if (error.code === '55P03' && attempt < maxRetries) { // Lock timeout - esperar e retentar console.log( `Lock timeout on attempt ${attempt}, ` + `retrying in ${attempt * 2}s...` ); await sleep(attempt * 2000); } else { throw error; } } } }
Backfills em lotes: a arte de mover dados
Quando precisa atualizar milhões de linhas existentes (ex: backfill de nova coluna), fazer num único UPDATE é perigoso. Cria uma transação massiva que:
- Segura row-level locks em todas as linhas afetadas
- Gera volume massivo de WAL (Write-Ahead Log)
- Pode causar lag de replicação
- Incha a tabela (dead tuples que precisam de vacuum)
O padrão de lotes
-- Backfill em chunks de 10,000 linhas DO $$ DECLARE batch_size INT := 10000; last_id BIGINT := 0; max_id BIGINT; rows_updated INT; BEGIN SELECT MAX(id) INTO max_id FROM orders; WHILE last_id < max_id LOOP UPDATE orders SET amount_cents = amount * 100 WHERE id > last_id AND id <= last_id + batch_size AND amount_cents IS NULL; GET DIAGNOSTICS rows_updated = ROW_COUNT; last_id := last_id + batch_size; RAISE NOTICE 'Processed up to id %, updated % rows', last_id, rows_updated; -- Pausa breve pras réplicas acompanharem -- e autovacuum processar dead tuples PERFORM pg_sleep(0.05); -- Commitar cada batch separadamente COMMIT; END LOOP; END $$;
Monitorando seu backfill
Enquanto o backfill roda, monitore essas métricas:
-- Verificar lag de replicação (crucial pra read replicas) SELECT client_addr, state, sent_lsn, write_lsn, flush_lsn, replay_lsn, pg_wal_lsn_diff(sent_lsn, replay_lsn) AS replay_lag_bytes FROM pg_stat_replication; -- Verificar bloat da tabela (dead tuples acumulando) SELECT relname, n_live_tup, n_dead_tup, round(n_dead_tup::numeric / GREATEST(n_live_tup, 1) * 100, 2) AS dead_pct, last_autovacuum FROM pg_stat_user_tables WHERE relname = 'orders'; -- Verificar queries de longa duração que podem conflitar SELECT pid, now() - query_start AS duration, state, left(query, 100) AS query_preview FROM pg_stat_activity WHERE state != 'idle' AND query_start < now() - interval '30 seconds' ORDER BY duration DESC;
Ferramentas de migração pra produção
pgroll: Ferramenta de migração sem downtime
pgroll é uma ferramenta de migração de schema projetada especificamente pra mudanças sem downtime. Implementa automaticamente o padrão expand-contract:
{ "name": "add_display_name", "operations": [ { "rename_column": { "table": "users", "from": "full_name", "to": "display_name" } } ] }
pgroll cuida da fase de expansão (criar views, triggers e colunas temporárias), da fase de migração e da fase de contração automaticamente. Cria views de schema versionadas pra que versões antigas e novas da aplicação possam coexistir.
Reshape
Reshape segue uma filosofia parecida: migrações declarativas e sem downtime com expand-contract automático:
[[actions]] type = "alter_column" table = "users" column = "full_name" [actions.changes] name = "display_name"
sqitch + Scripts personalizados
Pra times que preferem mais controle, sqitch combinado com scripts personalizados é uma alternativa leve:
# workflow do sqitch sqitch add rename-user-column \ -n "Rename full_name to display_name (phase 1: expand)" sqitch deploy sqitch verify
Ferramentas por framework
| Framework | Ferramenta | Suporte Zero-Downtime |
|---|---|---|
| Rails | strong_migrations gem | Bloqueia operações perigosas, sugere alternativas seguras |
| Django | django-pg-zero-downtime-migrations | Adiciona lock timeouts e padrões seguros |
| Laravel | Sem suporte built-in | Padrões manuais necessários |
| Node.js/TypeScript | graphile-migrate, node-pg-migrate | Bom controle, padrões manuais |
| Go | goose, atlas | Atlas tem migrações declarativas com verificações de segurança |
Playbook de migrações do mundo real
Um playbook completo e copy-paste pras migrações de produção mais comuns:
Playbook 1: Adicionar nova coluna obrigatória com default
-- Step 1: Adicionar coluna nullable (instantâneo) SET lock_timeout = '5s'; ALTER TABLE users ADD COLUMN subscription_tier TEXT; -- Step 2: Backfill das linhas existentes DO $$ DECLARE batch_size INT := 5000; rows_updated INT; BEGIN LOOP UPDATE users SET subscription_tier = 'free' WHERE subscription_tier IS NULL AND id IN ( SELECT id FROM users WHERE subscription_tier IS NULL LIMIT batch_size FOR UPDATE SKIP LOCKED ); GET DIAGNOSTICS rows_updated = ROW_COUNT; EXIT WHEN rows_updated = 0; COMMIT; PERFORM pg_sleep(0.05); END LOOP; END $$; -- Step 3: Adicionar NOT NULL constraint com segurança ALTER TABLE users ADD CONSTRAINT users_subscription_tier_not_null CHECK (subscription_tier IS NOT NULL) NOT VALID; ALTER TABLE users VALIDATE CONSTRAINT users_subscription_tier_not_null; -- Step 4: Setar NOT NULL (instantâneo com CHECK constraint válido) ALTER TABLE users ALTER COLUMN subscription_tier SET NOT NULL; -- Step 5: Setar default pra linhas futuras ALTER TABLE users ALTER COLUMN subscription_tier SET DEFAULT 'free'; -- Step 6: Limpar CHECK constraint ALTER TABLE users DROP CONSTRAINT users_subscription_tier_not_null;
Playbook 2: Criar índice em tabela grande
-- Step 1: Criar índice concurrently SET statement_timeout = '0'; CREATE INDEX CONCURRENTLY idx_orders_customer_created ON orders(customer_id, created_at DESC); -- Step 2: Verificar que o índice é válido DO $$ BEGIN IF NOT EXISTS ( SELECT 1 FROM pg_index i JOIN pg_class c ON i.indexrelid = c.oid WHERE c.relname = 'idx_orders_customer_created' AND i.indisvalid = true ) THEN RAISE EXCEPTION 'Index is invalid! Drop and retry.'; END IF; END $$;
Playbook 3: Substituir uma tabela (mudança de schema completa)
-- Quando as mudanças são tão extensas que expand-contract -- em colunas individuais não faz sentido -- Step 1: Criar nova tabela com schema desejado CREATE TABLE users_v2 ( id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, display_name TEXT NOT NULL, email TEXT NOT NULL UNIQUE, subscription_tier TEXT NOT NULL DEFAULT 'free', created_at TIMESTAMPTZ NOT NULL DEFAULT now() ); -- Step 2: Criar índices na nova tabela CREATE INDEX idx_users_v2_email ON users_v2(email); CREATE INDEX idx_users_v2_created ON users_v2(created_at); -- Step 3: Copiar dados em lotes (similar ao padrão de backfill) -- Step 4: Criar trigger na tabela antiga pra sincronizar -- linhas novas pra users_v2 -- Step 5: Trocar tabelas atomicamente BEGIN; ALTER TABLE users RENAME TO users_old; ALTER TABLE users_v2 RENAME TO users; COMMIT; -- Step 6: Atualizar sequences, foreign keys, etc. -- Step 7: Dropar tabela antiga depois do período de verificação
O checklist pré-migração
Antes de rodar qualquer migração em produção:
1. Verificar locks ativos e queries de longa duração
-- Matar queries rodando há mais de 5 minutos na tabela alvo SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE query ILIKE '%your_table%' AND state != 'idle' AND query_start < now() - interval '5 minutes';
2. Testar num dataset do tamanho de produção
Nunca teste migrações só no banco de desenvolvimento. Crie um ambiente de staging com dados em escala de produção.
3. Configurar timeouts
SET lock_timeout = '5s'; SET statement_timeout = '30m'; -- pra backfills longos
4. Ter um plano de rollback
Toda migração precisa ter rollback testado:
-- migration.sql ALTER TABLE users ADD COLUMN bio TEXT; -- rollback.sql ALTER TABLE users DROP COLUMN IF EXISTS bio;
5. Monitorar durante o deploy
-- Observar contenção de locks em tempo real SELECT blocked.pid AS blocked_pid, blocked.query AS blocked_query, blocking.pid AS blocking_pid, blocking.query AS blocking_query, now() - blocked.query_start AS blocked_duration FROM pg_stat_activity blocked JOIN pg_locks blocked_locks ON blocked.pid = blocked_locks.pid JOIN pg_locks blocking_locks ON blocked_locks.locktype = blocking_locks.locktype AND blocked_locks.database = blocking_locks.database AND blocked_locks.relation = blocking_locks.relation AND blocked_locks.pid != blocking_locks.pid JOIN pg_stat_activity blocking ON blocking_locks.pid = blocking.pid WHERE NOT blocked_locks.granted;
Erros comuns e como evitar
Erro 1: Rodar migrações durante pico de tráfego
Agende migrações de schema pro horário de menor tráfego. Até migrações "seguras" se beneficiam de menor concorrência.
Erro 2: Não testar o rollback
Todo rollback de migração precisa ser testado. "Só dropa a coluna" é um plano de rollback que destrói dados. Considere se precisa preservar dados durante rollback.
Erro 3: Esquecer do ORM
Seu ORM pode gerar SQL que referencia colunas por nome. Quando usar expand-contract, garanta que sua versão do ORM consegue lidar com o estado de transição (ambas colunas, antiga e nova, existindo).
Erro 4: Ignorar lag de replicação
Se usa read replicas, mudanças de schema propagam via replicação. Um backfill que escreve 10 milhões de linhas pode causar lag significativo, fazendo suas réplicas retornarem dados desatualizados.
Solução: Monitore pg_stat_replication durante backfills e faça throttling se o lag passar do limite:
async function throttledBackfill(batchSize: number) { while (true) { const lag = await getReplicationLag(); if (lag > MAX_LAG_BYTES) { console.log(`Replication lag ${lag} bytes, pausing...`); await sleep(5000); continue; } const updated = await updateBatch(batchSize); if (updated === 0) break; await sleep(50); } }
Erro 5: Deployar código da app e migração ao mesmo tempo
O código da app e a migração devem ser deployados em passos separados. Deploy da migração primeiro. Verifique que deu certo. Depois deploy do código que usa o novo schema. Essa separação é essencial pra rollbacks seguros.
Checklist de segurança de migração
Antes de cada migração em produção:
- Lock timeout configurado (
SET lock_timeout = '5s') - Statement timeout configurado pra operações longas
- Migração testada em dataset do tamanho de produção
- Script de rollback escrito e testado
- Sem deploys simultâneos nem janelas de manutenção
- Monitoramento de lag de replicação ativo
- Backfill usa updates em lotes (não um único UPDATE)
- Índices criados com CONCURRENTLY
- NOT NULL constraints adicionados via CHECK + VALIDATE
- Foreign keys adicionadas com NOT VALID + VALIDATE
- Código da app é backward-compatible com o schema antigo
- Engenheiro de plantão notificado sobre a migração
- Tráfego no ponto mais baixo (se possível)
Migrações de schema não precisam dar medo. Os padrões desse guia foram testados em batalha em bancos de dados que servem milhões de requisições por dia. O insight chave é simples: nunca faça uma mudança que quebra num passo só. Expanda primeiro, contraia depois. Configure timeouts. Faça backfill em lotes. Monitore tudo.
Seu banco de dados é a base da sua aplicação. Trate mudanças de schema com o mesmo cuidado que daria pra uma cirurgia cardíaca: planejamento cuidadoso, execução precisa e monitoramento constante.
Explore ferramentas relacionadas
Experimente estas ferramentas gratuitas do Pockit