A migração passou em todos os ambientes de teste. Em produção, um ALTER TABLE em uma tabela de 200 milhões de linhas trava escritas por minutos, as requisições acumulam, os pools de conexão esgotam e a aplicação cai. A migração estava correta. O problema era o lock.
Este artigo reúne as práticas que usamos para mudar esquemas no PostgreSQL com o sistema no ar.
O problema real: a fila de locks
Quase todo ALTER TABLE precisa de um lock ACCESS EXCLUSIVE, que bloqueia até leituras. Mesmo quando a alteração em si é instantânea, há uma armadilha: se uma consulta longa estiver usando a tabela, o ALTER espera por ela, e todas as consultas que chegam depois esperam atrás do ALTER. Uma alteração de milissegundos vira uma parada de minutos.
A defesa é um lock_timeout curto. Se o lock não for obtido rápido, a migração falha e pode ser tentada de novo, em vez de congelar o tráfego.
SET lock_timeout = '3s'; -- give up quickly instead of queueing all traffic behind us
SET statement_timeout = '15min';Expandir e contrair
Mudanças que alteram o contrato entre aplicação e banco (renomear, mudar tipo, dividir uma coluna) não podem acontecer em um passo, porque durante o deploy há versões antiga e nova da aplicação rodando ao mesmo tempo. O padrão expand/contract divide a mudança em etapas compatíveis:
- Expandir: adicionar a estrutura nova (coluna, tabela) sem remover a antiga.
- Escrever nas duas: a aplicação passa a gravar na estrutura nova e na antiga.
- Migrar os dados: preencher a estrutura nova em lotes.
- Ler da nova: a aplicação passa a ler apenas da estrutura nova.
- Contrair: remover a estrutura antiga, em um deploy posterior.
Renomear customer_name para full_name, por exemplo, nunca é um RENAME COLUMN direto em um sistema com deploys contínuos. São cinco passos, cada um seguro isoladamente.
Adicionar colunas
Desde o PostgreSQL 11, adicionar uma coluna com valor padrão não volátil (uma constante, por exemplo) é instantâneo: o valor fica registrado no catálogo e não exige reescrever a tabela. Um padrão volátil, como random() ou clock_timestamp(), ainda força a reescrita.
ALTER TABLE orders ADD COLUMN channel text NOT NULL DEFAULT 'web'; -- metadata-only on PG 11+Índices: sempre CONCURRENTLY
CREATE INDEX bloqueia escritas durante toda a construção. CREATE INDEX CONCURRENTLY permite escritas, ao custo de levar mais tempo. Dois cuidados:
- Ele não pode rodar dentro de uma transação. Muitas ferramentas de migração envolvem cada arquivo em uma transação, então é preciso desativar isso para esse passo.
- Se falhar no meio, deixa um índice marcado como inválido. Verifique e remova antes de tentar de novo.
-- migration tool: run outside a transaction
CREATE INDEX CONCURRENTLY IF NOT EXISTS orders_customer_created_idx
ON orders (customer_id, created_at DESC);
-- after a failed attempt, find leftovers:
-- SELECT indexrelid::regclass FROM pg_index WHERE NOT indisvalid;Constraints sem varrer a tabela com lock
Adicionar NOT NULL ou uma chave estrangeira valida todas as linhas existentes enquanto segura o lock. O caminho seguro separa a declaração da validação:
-- 1. Declare without checking existing rows (brief lock).
ALTER TABLE orders ADD CONSTRAINT orders_channel_not_null CHECK (channel IS NOT NULL) NOT VALID;
-- 2. Validate existing rows; this takes a weaker lock that allows reads and writes.
ALTER TABLE orders VALIDATE CONSTRAINT orders_channel_not_null;
-- 3. On PG 12+, SET NOT NULL reuses the validated check and skips the full scan.
ALTER TABLE orders ALTER COLUMN channel SET NOT NULL;
ALTER TABLE orders DROP CONSTRAINT orders_channel_not_null;Chaves estrangeiras seguem a mesma lógica: ADD CONSTRAINT ... FOREIGN KEY ... NOT VALID seguido de VALIDATE CONSTRAINT.
Preenchimento de dados em lotes
Um UPDATE único em milhões de linhas gera uma transação enorme, segura locks de linha por muito tempo e pode atrasar a replicação. Prefira lotes pequenos, com uma pausa entre eles, controlados por chave primária:
UPDATE orders
SET full_name = customer_name
WHERE id IN (
SELECT id FROM orders
WHERE full_name IS NULL
ORDER BY id
LIMIT 5000
);
-- repeat until 0 rows are updated, monitoring replication lag between batchesUm checklist antes de cada migração
- A versão atual e a nova da aplicação funcionam com o esquema resultante?
- Algum passo reescreve a tabela ou exige varrer todas as linhas com lock forte?
lock_timeoutestá definido?- Índices são criados com
CONCURRENTLY, fora de transação? - Constraints novas usam
NOT VALID+VALIDATE? - Existe um plano de reversão para cada etapa?
Resumo
Migrações seguras no PostgreSQL dependem menos do SQL e mais de entender os locks. Um lock_timeout curto evita filas que derrubam o tráfego, expand/contract mantém a compatibilidade durante o deploy, e índices concorrentes, constraints validadas em separado e preenchimento em lotes permitem mudar tabelas grandes sem parar o sistema.
Leituras relacionadas
- Engenharia de backend · 3 min de leitura
- Arquitetura de software · 3 min de leitura
- Arquitetura de software · 3 min de leitura