Migração de banco sem tela de manutenção: entendendo o padrão expand-and-contract

Ponte em operação ao lado de uma estrutura digital em expansão

Uma alteração de banco parece simples enquanto cabe numa linha de SQL. Renomear uma coluna, adicionar uma restrição ou mudar o formato de um dado vira outro problema quando duas versões da aplicação podem rodar ao mesmo tempo. Durante um deploy gradual, o código antigo ainda espera o schema antigo enquanto o novo já tenta usar o próximo.

O padrão expand-and-contract, também chamado de parallel change, troca a virada única por etapas compatíveis. Primeiro o sistema aprende o formato novo sem perder o antigo. Depois os dados e consumidores migram. Só quando ninguém depende da estrutura anterior ela é removida.

O deploy perigoso é o tudo ou nada

Imagine uma tabela customers com a coluna name. O novo produto quer separar given_name e family_name. Renomear ou apagar name no mesmo deploy que altera o código cria uma janela frágil. Se uma instância antiga receber tráfego, ela quebra. Se for necessário rollback, a versão anterior encontra um banco que não entende mais.

A compatibilidade temporária é o que preserva o rollback. Enquanto o schema aceita os dois contratos, a equipe consegue recuar a aplicação sem precisar desfazer uma migração destrutiva às pressas.

1. Expandir

Na expansão, adicione a nova estrutura de forma compatível. Colunas novas costumam começar aceitando NULL ou um valor seguro, porque os registros existentes ainda não foram migrados. O código antigo continua usando name.

ALTER TABLE customers ADD COLUMN given_name text;
ALTER TABLE customers ADD COLUMN family_name text;

O impacto operacional do comando depende do banco, da versão, do tamanho da tabela e da alteração. Algumas operações exigem locks ou reescrita. Verifique o plano na documentação do seu SGBD e ensaie com um volume próximo ao real; “é só um ALTER TABLE” já derrubou deploy suficiente para merecer respeito.

2. Migrar

A próxima versão passa a escrever nos campos antigos e novos, ou usa uma camada de compatibilidade centralizada. Depois um backfill copia registros históricos em lotes pequenos. Métricas acompanham quantos registros ainda não possuem o formato novo e se as duas representações divergem.

Dual write parece trivial, mas pode falhar pela metade. Se duas gravações independentes forem feitas fora da mesma transação, uma pode confirmar e a outra não. Quando possível, mantenha a atualização atômica no banco. Se a migração atravessa serviços ou armazenamentos diferentes, trate reconciliação e idempotência como partes do desenho.

Quando o backfill termina, o código novo começa a ler as colunas novas, ainda mantendo a escrita compatível por um período. Essa fase de observação permite comparar resultados, testar rollback e encontrar consumidores esquecidos, como jobs, relatórios e integrações.

3. Contrair

A contração acontece somente depois que telemetria e busca no código mostram que ninguém lê ou escreve o campo antigo. Primeiro remova o caminho antigo da aplicação. Em outro deploy, retire a coluna. Restrições como NOT NULL podem ser aplicadas quando os dados já foram validados.

Essa distância entre “parou de usar” e “apagou” parece lenta, mas é o preço barato. A alternativa é descobrir um consumidor oculto depois que a informação já não existe.

O que observar em cada fase

  • Tempo de lock e crescimento de latência durante mudanças de schema.
  • Taxa e duração do backfill, com pausa possível em caso de pressão.
  • Divergências entre representações antigas e novas.
  • Versões da aplicação ainda acessando o contrato antigo.
  • Plano de rollback válido antes de cada passo destrutivo.

Zero downtime não significa risco zero

Sistemas enormes usam snapshots, replicação e troca de tráfego para mover dados sem interrupção perceptível. Um CRUD comum não precisa copiar a arquitetura da Stripe, mas pode aproveitar o princípio: separar preparação, cópia, validação e corte torna a mudança observável e reversível.

O padrão também serve para APIs, eventos e mudanças de formato. Sempre que produtores e consumidores não atualizam no mesmo instante, expandir o contrato antes de remover o antigo reduz coordenação e sustos.

A tela de manutenção continua válida quando o contexto aceita parada e o custo de engenharia não se justifica. Expand-and-contract entra quando disponibilidade, múltiplas instâncias ou rollback importam. Ele não faz a migração desaparecer; transforma uma aposta grande em uma sequência de passos verificáveis.

Backfill precisa de checkpoint e limite de pressão

Não atualize a tabela inteira numa transação. Processe lotes por chave estável, confirme cada lote e grave o último checkpoint. Limite concorrência e pause quando réplica, I/O ou latência ultrapassarem o orçamento.

UPDATE customers
SET normalized_email = lower(email)
WHERE id > :last_id
  AND id <= :next_id
  AND normalized_email IS NULL;

O processo deve poder repetir o mesmo intervalo sem corromper resultado. Essa idempotência operacional permite retomar depois de deploy, timeout ou falha do worker. Para efeitos distribuídos, a disciplina se conecta à transactional outbox e à idempotência da API.

Cada fase precisa de uma saída de emergência diferente

  • Expand: remover o código novo e deixar a coluna sem uso costuma ser reversível.
  • Dual write: desligar a escrita nova exige saber se o dado antigo continua completo.
  • Leitura nova: voltar à leitura antiga só funciona enquanto os dois formatos permanecem coerentes.
  • Contract: depois de apagar coluna ou restrição antiga, rollback pode exigir restauração ou nova migração.

Use uma feature flag para mover a leitura por percentuais quando a aplicação permite, mas não use a flag para esconder uma migração destrutiva. Antes de contrair, confirme que nenhum binário antigo, job, relatório ou consumidor externo ainda lê o contrato anterior.

Registre contagem de linhas antigas e novas, divergências, duração dos lotes e atraso de réplica. A fase termina por evidência, não porque “já passou uma semana sem reclamação”.

Fontes