Destino Dev

Blog

Migrações zero-downtime no PostgreSQL com Prisma: blue-green e backfill

Por John Pires · 20 de março de 2026

1) Quando e por que usar

Contexto do problema

Aplicações que rodam em produção com tráfego real enfrentam dois riscos primários quando uma migração altera esquema: perda de disponibilidade e corrupção de dados por compatibilidade quebrada entre código antigo e novo. Em sistemas com alta disponibilidade, janelas de manutenção longas são inaceitáveis. O objetivo é garantir que uma alteração no esquema do PostgreSQL e no prisma schema possa ser aplicada sem interromper requests e sem exigir rollback forçado.

Definição de zero-downtime para migrações

Zero-downtime aqui significa que nenhuma requisição legítima da aplicação deve falhar por conta da migração de esquema durante a implantação. Falhas ocasionais isoladas (ex.: timeouts breves tratáveis por retry) são toleráveis, mas não podemos exigir que clientes percebam uma janela de erro ou que se perca dados em transações concorrentes.

Para alcançar isso usamos duas abordagens combinadas: padrão blue-green (ou gradativo) do lado da aplicação e estratégia de backfill/alteração de esquema em passos compatíveis (expand-contract).

2) Comparação com alternativas

Opções comuns e trade-offs

Antes de escolher blue-green + backfill, avalie alternativas:

  • Migração direta (downtime): fazer ALTER TABLE e exigir manutenção — mais simples, mas interrupção clara.
  • Shadow table / reorder: criar tabela nova, copiar dados, trocar nomes — pode exigir bloqueios e é mais complexo para chaves estrangeiras e integrações.
  • Online schema change com ferramentas (pg_repack, gh-ost, pt-osc) — algumas não cobrem todos os cenários ou exigem configuração de réplica.
  • Blue-Green Deploy sem backfill: troca apenas do código, mas se o esquema mudou você terá erros a menos que seja compatível.

Por que combinar blue-green com backfill

Blue-green resolve o risco da troca do serviço (deploy), garantindo que você possa rolar para trás na camada de aplicação rapidamente. Backfill e mudanças em passos (expand -> fill -> contract) resolvem a compatibilidade do esquema. Juntos permitem mudanças complexas: adição de colunas, normalização, alteração de tipos e criação de constraints com validação incremental.

Dica: sempre modele migrações como uma série de mudanças compatíveis em tempo de execução, na ordem: add-nullable -> backfill em batches -> add-non-null/constraint -> drop-legacy.

3) Implementação progressiva

Pattern expand-fill-contract

O fluxo padrão que eu recomendo:

  1. Expand: Adicione nova coluna/índice/objeto sem impactar o código atual. Torne-a NULLABLE ou com valor default seguro.
  2. Fill (backfill): Preencha os dados em batches atômicos para evitar longas transações e para não bloquear scans de tabela grandes.
  3. Switch code: Atualize a aplicação para escrever em ambas as estruturas (dual write) ou somente na nova, mantendo leitura compatível.
  4. Contract: Adicione constraints NOT NULL/UNIQUE ou remova as colunas antigas uma vez verificado que não há mais dependências.

Esse padrão reduz risco porque cada passo é reversível ou seguro para continuar com código antigo por um período curto.

Exemplo prático: adicionar coluna calculada e torná-la obrigatória

Cenário: tabela users, queremos adicionar normalized_email e torná-la não-nula e indexed. Primeiro adicionamos a coluna nullable, backfill em batches, atualizamos serviços para gravar normalized_email, e só então aplicamos NOT NULL e criamos índice único.

Contexto do primeiro bloco: comando SQL para adicionar a coluna nullable. Isso é rápido e não bloqueia leituras extensas.

ALTER TABLE users ADD COLUMN normalized_email text;

O que acontece: a coluna é adicionada com valor NULL para todas as linhas. Em geral, adicionar coluna sem DEFAULT é um comando instantâneo em versões modernas do Postgres porque não precisa reescrever a tabela.

Contexto do segundo bloco: backfill em batches via SQL puro, usando id increments (assumindo coluna id bigserial). O script atualiza 10000 linhas por vez para evitar transações longas.

-- Backfill batch script (SQL). Ajuste batch_size conforme I/O do seu ambiente.
DO $$
DECLARE
  batch_size INT := 10000;
  rows INT := 1;
BEGIN
  WHILE rows > 0 LOOP
    WITH cte AS (
      SELECT id FROM users WHERE normalized_email IS NULL ORDER BY id LIMIT batch_size
    )
    UPDATE users u
    SET normalized_email = lower(trim(u.email))
    FROM cte
    WHERE u.id = cte.id
    RETURNING 1 INTO rows;
    -- Opcional: pause entre batches
    PERFORM pg_sleep(0.1);
  END LOOP;
END$$;

O que acontece: cada batch atualiza um subconjunto de linhas; entre batches você pode monitorar carga e ajustar. Evita locks pesados e longas transações que causem bloat ou autovacuum atrasado.

Dual-write e feature flags

Durante a transição, habilite dual-write no código: escreva nos dois campos (antigo e novo). Use feature flags para ativar leituras preferenciais da nova coluna somente quando o backfill estiver completo.

Contexto do terceiro bloco: exemplo em Node.js usando Prisma Client para backfill em batches. O script demonstra loop com transações e advisory lock para garantir que apenas uma instância execute o backfill.

// Node.js + Prisma: backfill em batches com advisory lock
// Requer: npm install @prisma/client
const { PrismaClient } = require('@prisma/client');
const prisma = new PrismaClient();

async function acquireLock(key) {
  const r = await prisma.$queryRaw`SELECT pg_try_advisory_lock(${key}) AS ok`;
  return r[0].ok;
}

async function releaseLock(key) {
  await prisma.$queryRaw`SELECT pg_advisory_unlock(${key})`;
}

async function backfill(batchSize=10000) {
  const lockKey = 123456; // derive from migration id
  const got = await acquireLock(lockKey);
  if (!got) { console.log('Lock not acquired, another process is running'); return; }
  try {
    let updated = 1;
    while (updated > 0) {
      const res = await prisma.$executeRawUnsafe(`
        WITH cte AS (
          SELECT id FROM users WHERE normalized_email IS NULL ORDER BY id LIMIT ${batchSize}
        )
        UPDATE users u
        SET normalized_email = lower(trim(u.email))
        FROM cte
        WHERE u.id = cte.id
        RETURNING 1
      `);
      updated = res; // number of rows updated depends on driver
      console.log('updated', updated);
      await new Promise(r => setTimeout(r, 100));
    }
  } finally {
    await releaseLock(lockKey);
  }
}

backfill().then(()=>process.exit()).catch(e=>{console.error(e); process.exit(1);});

O que acontece: o script garante que só uma instância faça backfill via advisory lock; processa em batches, permitindo cancelar ou pausar conforme métrica de latência.

4) Integração com ecossistema real

Prisma migrations e deployment CI/CD

Prisma Migrate gera SQL que você pode revisar. Em ambientes críticos, não rode prisma migrate deploy automaticamente na produção sem revisão. Recomendação prática:

  • Gerar migração com prisma migrate dev ou diff.
  • Revisar SQL e separar comandos imediatos (DDL instantâneo) dos que precisam ser manuais (backfill em batches, criação de índices, constraints que bloqueiam).
  • Integrar no pipeline: deploy app -> executar backfill job (job controlado) -> habilitar feature flag -> aplicar constraint final.

Prisma tem limitações para expressar operações longas (por exemplo, criar índices CONCURRENTLY). Você precisa complementar o migration.sql com comandos manuais e marcar no migration_state que foram aplicados.

Indice CONCURRENTLY e outras operações seguras

Criação de índices grandes deve usar CREATE INDEX CONCURRENTLY para evitar bloqueios de escrita. Prisma não tem suporte nativo para CONCURRENTLY dentro de migrations automatizadas porque elas rodam dentro de transação. Uma abordagem é:

  1. Gerar comando CREATE INDEX CONCURRENTLY manualmente.
  2. Executar fora de transação via CLI ou job manual.

Exemplo (após backfill completado):

-- Create index concurrently (run outside transactions)
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_users_normalized_email ON users(normalized_email);

O que acontece: o índice é criado sem bloquear gravações. Se o índice falhar por reinício, você pode reexecutar o comando. Só depois da criação segura você aplica UNIQUE/NOT NULL com checks.

Blue-green na prática (deploy e switch)

Blue-green para migrações limita risco durante deploy. Existem duas abordagens:

  • App-level blue-green: coloque nova versão do app atrás de load-balancer, conduza saúde, então corte tráfego da antiga para a nova.
  • DB blue-green (mais raro): criar réplica do banco com novo esquema e trocar conexões — é caro e complexo para dados escritos durante o cutover.

Prático: combine app blue-green com esquema compatível. Implante nova versão que dá dual-write; faça backfill; apenas então remova legacy code e finalize schema.

5) Checklist de qualidade para produção

Checklist operacional

Use esta checklist antes de qualquer migração em produção:

  • Revisar SQL gerado pelo Prisma; separar comandos que executam dentro/fora de transação.
  • Planejar backfill com batch size e janelas de carga.
  • Adicionar advisory lock em jobs de backfill para evitar concorrência.
  • Configurar métricas: latência P95/P99, erro 5xx, locks, replication lag.
  • Ter playbook de rollback (feature flag off, parar backfill, reverter migration se possível).

Checklist de testes

Antes de rodar em produção:

  1. Testar em staging com volume parecido (ou simular volume via geração de dados).
  2. Testar backfill scripts e medir tempo por batch.
  3. Testar criação de índices CONCURRENTLY e checar I/O e latência.
  4. Verificar comportamento em falhas: aborts, interrupção de job, reinício de pod.

Erros comuns e armadilhas

Erros que vejo com frequência:

  • Executar comandos CONCURRENTLY dentro de transação: falha com "CREATE INDEX CONCURRENTLY cannot run inside a transaction block". Solução: executar fora de migrator transacional.
  • Backfill sem limit/ORDER BY: pode causar deadlocks ou longas transações. Sempre batch e ordene por PK.
  • Não usar advisory locks: múltiplos workers podem competir e duplicar trabalho ou causar carga excessiva.
  • Remover colunas cedo demais: serviços legados ainda podem depender da coluna. Mantenha compatibilidade até confirmar zero dependências.
Prático: scripts de backfill devem ser idempotentes — você deve poder reexecutar sem efeitos nocivos.

Exemplo de verificação idempotente (SQL): atualizar somente linhas que ainda não têm valor, como vimos anteriormente com WHERE normalized_email IS NULL.

Conclusão prática

Migrações zero-downtime com PostgreSQL e Prisma exigem disciplina: desenhar mudanças em etapas, separar operações instantâneas de operações longas, usar backfill em batches, índices CONCURRENTLY e prática de deploy blue-green. Combine isso com feature flags e dual-write para controlar o corte gradual.

O ponto-chave: migrações seguras não são um comando unicó; são um fluxo coordenado. Automatize o que for repetitivo (scripts de backfill, locks, checagens), mas mantenha revisões manuais para comandos que são fora de transação.

Ferramentas: use Prisma para gerar e documentar mudanças, mas esteja pronto para complementar com SQL manual para CREATE INDEX CONCURRENTLY, backfills controlados e locks. Monitore latência e replicação o tempo todo durante a operação.

Adote este checklist e os patterns expand-fill-contract e blue-green e você terá migrações previsíveis, reversíveis e de baixa perturbação para os usuários.

Para referência rápida: os três scripts/três passos essenciais que você deve ter antes do corte final — (1) adicionar coluna nullable, (2) backfill em batches com advisory lock, (3) criar índice concurrently e aplicar constraint — formam a espinha dorsal de um processo de migração zero-downtime confiável.

Assuntos: PostgreSQL, Prisma, migrations, zero-downtime, backfill

Leia também