Aprenda a fazer alterações de esquema sem interrupção com o padrão expandir/contrair: adicionar colunas com segurança, backfill em lotes, deploy compatível e só remover caminhos antigos no final.

Tempo de indisponibilidade causado por uma mudança no banco nem sempre é um erro óbvio. Para os usuários pode parecer uma página que carrega para sempre, um checkout que falha ou um app que de repente mostra "algo deu errado." Para as equipes aparece como alertas, aumento de taxa de erros e um acúmulo de gravações falhas que precisam ser limpas.
Mudanças de esquema são arriscadas porque o banco de dados é compartilhado por todas as versões ativas do seu app. Durante um release você frequentemente tem código antigo e novo ao mesmo tempo (deploys graduais, múltiplas instâncias, jobs em background). Uma migração que parece correta pode ainda quebrar uma daquelas versões.
Modos comuns de falha incluem:
Mesmo quando o código está certo, releases são bloqueados porque o problema real é o tempo e a compatibilidade entre versões.
Alterações de esquema sem interrupção resumem-se a uma regra: todo estado intermediário deve ser seguro tanto para o código antigo quanto para o novo. Você altera o banco sem quebrar leituras e gravações existentes, envia código que lida com ambas as formas e só remove o caminho antigo quando nada mais depende dele.
Esse esforço extra vale a pena quando você tem tráfego real, SLAs rigorosos ou muitas instâncias e workers. Para uma ferramenta interna pequena com pouco uso, uma janela de manutenção planejada pode ser mais simples.
A maioria dos incidentes por mudanças no banco acontece porque o app espera que a mudança no banco seja instantânea, enquanto a mudança leva tempo. O padrão expandir/contrair evita isso ao dividir uma mudança arriscada em passos menores e seguros.
Por um curto período, seu sistema passa a suportar duas “dialetos” ao mesmo tempo. Você introduz a nova estrutura primeiro, mantém a antiga funcionando, move dados gradualmente e depois limpa.
O padrão é simples:
Isso funciona bem com deploys graduais. Se você atualiza 10 servidores um a um, haverá um breve período com código antigo e novo juntos. Expandir/contrair mantém ambos compatíveis com o mesmo banco durante essa sobreposição.
Também deixa rollbacks menos assustadores. Se uma release tiver bug, você pode reverter o app sem reverter o banco, porque as estruturas antigas ainda existem durante a janela de expand.
Exemplo: você quer dividir uma coluna PostgreSQL full_name em first_name e last_name. Você adiciona as novas colunas (expandir), envia código que pode ler e escrever ambos os formatos, preenche linhas antigas, e então derruba full_name quando tiver certeza de que nada mais a usa (contrair).
A fase de expandir é sobre adicionar novas opções, não remover as antigas.
Um movimento comum é adicionar uma nova coluna. No PostgreSQL, costuma ser mais seguro adicioná-la como nullable e sem default. Adicionar uma coluna não-nula com default pode forçar reescrita da tabela ou locks maiores, dependendo da versão do Postgres. Uma sequência mais segura é: adicionar nullable, enviar código tolerante, backfill, e só depois aplicar NOT NULL.
Índices também precisam de cuidado. Criar um índice normal pode bloquear gravações por mais tempo do que você espera. Quando possível, use criação de índice em modo concorrente para manter leituras e gravações fluindo. Leva mais tempo, mas evita locks que paralisam o release.
Expandir também pode significar adicionar tabelas novas. Se você está indo de uma coluna única para um relacionamento muitos-para-muitos, pode adicionar uma tabela de junção enquanto mantém a coluna antiga. O caminho antigo continua funcional enquanto a nova estrutura começa a coletar dados.
Na prática, expandir frequentemente inclui:
Após a fase de expandir, versões antigas e novas do app devem rodar ao mesmo tempo sem surpresas.
A maior parte da dor durante releases acontece no meio: alguns servidores rodam código novo, outros ainda rodam código antigo, enquanto o banco já está mudando. Seu objetivo é simples: qualquer versão durante o rollout deve funcionar com o esquema antigo e com o expandido.
Uma abordagem comum é o dual-write. Se você adiciona uma nova coluna, o app novo escreve tanto na coluna antiga quanto na nova. Versões antigas continuam escrevendo apenas na antiga, o que é seguro porque ela ainda existe. Mantenha a coluna nova opcional no começo e adie restrições rígidas até que todos os gravadores tenham sido atualizados.
Leituras normalmente mudam com mais cuidado do que gravações. Por um tempo, mantenha leituras na coluna antiga (a que você sabe estar totalmente populada). Depois do backfill e da verificação, mude as leituras para preferir a coluna nova, com fallback para a antiga caso a nova esteja ausente.
Também mantenha a saída da sua API estável enquanto o banco muda por baixo. Mesmo que você introduza um campo interno novo, evite mudar formatos de resposta até que todos os consumidores estejam prontos (web, mobile, integrações).
Um rollout amigável ao rollback geralmente parece com isto:
A ideia chave é que o primeiro passo irreversível é descartar a estrutura antiga, então você o deixa para o final.
Backfills são onde muitas "migrações sem downtime" dão errado. Você quer preencher a nova coluna para linhas existentes sem locks longos, consultas lentas ou picos de carga.
Lotes importam. Mire em batches que terminem rápido (segundos, não minutos). Se cada lote for pequeno, você pode pausar, retomar e ajustar o job sem travar releases.
Para acompanhar o progresso, use um cursor estável. No PostgreSQL isso costuma ser a chave primária. Processe linhas em ordem e armazene o último id completado, ou trabalhe em faixas de id. Isso evita scans caros de tabela inteira quando o job reinicia.
Aqui está um padrão simples:
UPDATE my_table
SET new_col = ...
WHERE new_col IS NULL
AND id > $last_id
ORDER BY id
LIMIT 1000;
Torne o update condicional (por exemplo, WHERE new_col IS NULL) para que o job seja idempotente. Reruns só tocam linhas que ainda precisam, reduzindo gravações desnecessárias.
Planeje para novos dados chegando durante o backfill. A ordem usual é:
Um bom backfill é entediante: constante, mensurável e fácil de pausar se o banco aquecer.
O momento mais arriscado não é adicionar a nova coluna. É decidir que você já pode contar com ela.
Antes de passar para o contract, prove duas coisas: os dados novos estão completos e a produção está lendo-os com segurança.
Comece com checagens de completude rápidas e repetíveis:
Se você faz dual-write, acrescente uma checagem de consistência para capturar bugs silenciosos. Por exemplo, rode uma query horária que encontre linhas onde old_value <> new_value e alerte se não for zero. Isso é muitas vezes a forma mais rápida de descobrir que um gravador ainda atualiza apenas a coluna antiga.
Observe sinais básicos de produção enquanto a migração roda. Se tempo de consulta ou esperas por locks subirem, até suas queries de verificação “seguras” podem estar adicionando carga. Monitore taxas de erro para qualquer caminho de código que leia a coluna nova, especialmente logo após deploys.
Por quanto tempo manter ambos os caminhos? Tempo suficiente para sobreviver a pelo menos um ciclo completo de release e uma rerun do backfill. Muitas equipes usam 1–2 semanas, ou até terem certeza de que nenhuma versão antiga ainda está rodando.
Contract é onde equipes ficam nervosas porque parece o ponto sem volta. Se o expand foi feito certo, contract é maiormente limpeza, e você ainda pode fazê-lo em passos pequenos e de baixo risco.
Escolha o momento com cuidado. Não drope nada imediatamente após terminar um backfill. Dê pelo menos um ciclo completo de release para que jobs atrasados e casos de borda apareçam.
Uma sequência segura de contract geralmente é:
Se possível, divida o contract em duas releases: uma que remove referências no código (com logging extra) e outra posterior que remove objetos do banco. Essa separação facilita rollback e troubleshooting.
Especificidades do PostgreSQL importam aqui. Dropar uma coluna é na maior parte uma mudança de metadados, mas ainda exige um lock ACCESS EXCLUSIVE por um breve período. Planeje para um momento mais calmo e mantenha a migração rápida. Se você criou índices extras, prefira removê-los com DROP INDEX CONCURRENTLY para evitar bloquear gravações (isso não pode rodar dentro de um bloco de transação, então sua ferramenta de migração precisa dar suporte).
Migrações sem downtime falham quando o banco e o app deixam de concordar sobre o que é permitido. O padrão só funciona se todo estado intermediário for seguro para o código antigo e o novo.
Erros que aparecem com frequência:
Um cenário realista: você começa a gravar full_name pela API, mas um job em background que cria usuários continua setando apenas first_name e last_name. Ele roda à noite, insere linhas com full_name = NULL e, depois, código assume que full_name sempre estará presente.
Trate cada passo como um release que pode durar dias:
Um checklist repetível impede que você envie código que só funciona em um estado do banco.
Antes de deploy, confirme que o banco já tem os pedaços expandidos no lugar (novas colunas/tabelas, índices criados de forma de baixo lock). Depois confirme que o app é tolerante: deve funcionar contra o esquema antigo, o expandido e um estado meio-backfilled.
Mantenha o checklist curto:
Uma migração só termina quando leituras usam os dados novos, gravações não mantêm mais os dados antigos, e você verificou o backfill com ao menos uma checagem simples (contagens ou amostragem).
Suponha que há uma tabela PostgreSQL customers com uma coluna phone que armazena valores inconsistentes. Você quer substituí-la por phone_e164, mas não pode bloquear releases nem tirar o app do ar.
Uma sequência limpa expand/contract é:
phone_e164 como nullable, sem default e sem constraints pesadas ainda.phone quanto phone_e164, mas mantenha leituras em phone para que nada mude para os usuários.phone_e164 primeiro e faça fallback para phone se estiver NULL.phone_e164, remova o fallback, drope phone e então adicione constraints mais rígidas, se necessário.O rollback permanece simples quando cada passo é compatível com versões anteriores. Se a troca de leitura causar problemas, reverta o app e o banco ainda terá ambas as colunas. Se o backfill causar picos de carga, pause o job, reduza o batch e continue depois.
Se quiser que a equipe permaneça alinhada, documente o plano em um lugar só: o SQL exato, qual release troca as leituras, como medir conclusão (porcentagem de phone_e164 não-NULL) e quem é dono de cada passo.
Expand/contract funciona melhor quando vira rotina. Escreva um runbook curto que sua equipe possa reaproveitar em toda mudança de esquema, idealmente uma página e específico o suficiente para que um colega novo consiga seguir.
Um template prático cobre:
Decida ownership desde o começo. “Todo mundo achou que alguém faria o contract” é como colunas antigas e feature flags ficam meses sem remoção.
Mesmo que o backfill rode online, escolha momentos de menor tráfego. É mais fácil manter batches pequenos, observar carga do banco e parar rapidamente se a latência subir.
Se você está construindo e fazendo deploy com Koder.ai (koder.ai), o Planning Mode pode ser uma forma útil de mapear fases e checkpoints antes de tocar a produção. As mesmas regras de compatibilidade valem, mas ter os passos escritos dificulta pular as partes chatas que evitam outages.
Porque o banco de dados é compartilhado por todas as versões rodando do seu app. Durante deploys graduais (rolling deploys) e execução de jobs em background, código antigo e novo podem estar ativos ao mesmo tempo, e uma migração que renomeia, remove colunas ou adiciona restrições pode quebrar qualquer versão que não espere aquele estado exato do esquema.
Significa projetar a migração de modo que todo estado intermediário do banco funcione tanto para o código antigo quanto para o novo. Você adiciona estruturas novas primeiro, executa com os dois caminhos por um tempo e só remove as estruturas antigas depois que nada mais depender delas.
Expandir adiciona colunas, tabelas ou índices novos sem remover nada que o app atual precise. Contrair é a fase de limpeza, quando você remove colunas antigas, leituras/escritas antigas e lógica temporária de sincronização depois de comprovar que o caminho novo funciona plenamente.
Adicionar uma coluna como nullable e sem default costuma ser o começo mais seguro, porque evita reescritas de tabela e locks pesados. Depois você envia código tolerante à ausência/NULL dessa coluna, preenche gradualmente (backfill) e só então aplica restrições mais rígidas como NOT NULL.
Use dual-write durante a transição quando a nova versão do app deve escrever tanto no campo antigo quanto no novo. Isso mantém os dados consistentes enquanto instâncias antigas e jobs ainda gravam apenas no campo antigo.
Faça o backfill em pequenos lotes que terminem rápido e torne cada lote idempotente, para que reruns só toquem linhas que ainda precisam de atualização. Monitore latência, esperas por locks e lag de replicação, e esteja pronto para pausar ou reduzir o tamanho do lote se o banco começar a esquentar.
Primeiro, verifique completude (por exemplo, quantas linhas ainda têm NULL na nova coluna). Depois, faça checagens de consistência comparando valores antigos e novos em amostras (ou continuamente, se for barato), e observe erros em produção logo após deploys para capturar caminhos de código ainda usando o esquema errado.
Colocar NOT NULL cedo demais, backfill de uma tabela grande numa única transação, presumir que defaults são sempre grátis (alguns defaults causam reescrita da tabela no Postgres) e trocar leituras para a coluna nova antes das escritas estarem populando-a são erros comuns. Também esqueça de outros escritores/leitores, como jobs, workers e consultas de relatório.
Só depois de parar de escrever no campo antigo, trocar leituras para o campo novo sem fallback, e aguardar tempo suficiente para garantir que nenhuma versão antiga do app ou worker ainda esteja rodando. Muitas equipes tratam o contract como uma release separada para manter o rollback simples.
Se você pode tolerar uma janela de manutenção e há pouco tráfego, uma migração direta pode bastar. Mas se tiver usuários reais, múltiplas instâncias, workers ou SLA, o padrão expand/contract costuma valer o esforço extra porque torna rollouts e rollbacks mais seguros; no Koder.ai Planning Mode, documentar fases e checagens ajuda a não pular as etapas “chatas” que evitam outages.