Pular para o conteúdo
ZYNQORALABS

Migrações no PostgreSQL sem downtime

Uma migração aparentemente simples pode travar uma tabela inteira em produção. Como evitar locks longos, usar expand/contract e criar índices e constraints com segurança.

Por ZynqoraLabs · · 3 min de leitura

CompartilharLinkedInXWhatsApp

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.

000_settings.sql
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:

  1. Expandir: adicionar a estrutura nova (coluna, tabela) sem remover a antiga.
  2. Escrever nas duas: a aplicação passa a gravar na estrutura nova e na antiga.
  3. Migrar os dados: preencher a estrutura nova em lotes.
  4. Ler da nova: a aplicação passa a ler apenas da estrutura nova.
  5. 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.

001_add_column.sql
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.
002_index.sql
-- 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:

003_not_null.sql
-- 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:

004_backfill.sql
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 batches

Um 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_timeout está 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.