Migrações de banco de dados sem downtime: o padrão expand e contract
Mudar o schema de um banco de dados em produção é uma das operações mais tensas da engenharia. Não porque seja conceitualmente difícil, e sim porque o banco guarda o estado real do negócio, e um passo errado não gera só um bug: pode travar a aplicação inteira, corromper dados ou forçar uma janela de manutenção que os usuários vão sentir. Em um SaaS que roda o tempo todo, "vamos parar tudo no domingo de madrugada" deixou de ser aceitável. A migração precisa acontecer com o sistema no ar.
Este guia mostra como evoluir o schema sem downtime. O padrão central se chama expand and contract, e ele resolve o problema aparentemente impossível de mudar a estrutura dos dados enquanto duas versões do código convivem em produção. O foco é a mecânica segura da mudança de schema, um tema distinto de escalar o banco com índices e particionamento.
Por que migração é perigosa
O risco de uma migração vem de duas fontes que muitas vezes agem juntas.
A primeira é o lock. Certas alterações de schema travam a tabela enquanto rodam. Em uma tabela pequena, o lock dura milissegundos e ninguém percebe. Em uma tabela com dezenas de milhões de linhas, o mesmo comando pode travar a tabela por minutos, e durante esse tempo toda a aplicação que depende dela fica parada, esperando. Um lock longo em produção é, na prática, um downtime.
A segunda é a incompatibilidade entre código e schema. Durante um deploy sem downtime, a versão antiga e a nova do código rodam ao mesmo tempo por alguns instantes. Se a migração muda o schema de um jeito que a versão antiga não entende, essa versão quebra enquanto ainda está atendendo usuários. A migração não pode assumir que todo o código já foi atualizado.
A base: migrations versionadas
Antes de qualquer técnica avançada, uma disciplina inegociável: migrations versionadas. Toda mudança de schema é um arquivo de migração, numerado, versionado no Git e aplicado de forma automática e ordenada pelo pipeline. Nunca se altera o schema de produção na mão, por um cliente de banco.
Migrations versionadas trazem o mesmo rigor do código para o schema:
- Histórico completo de como o banco chegou ao estado atual.
- Reprodutibilidade: qualquer ambiente atinge o mesmo schema aplicando as mesmas migrations na ordem.
- Revisão: a mudança de schema passa por pull request, como qualquer código.
Sem essa base, nada mais funciona de forma confiável. Com ela, chegamos ao padrão que torna a mudança segura.
Expand and contract, passo a passo
A ideia central é nunca fazer uma mudança destrutiva de uma vez. Em vez disso, você a quebra em fases compatíveis, garantindo que, em cada momento, as duas versões do código consigam funcionar. O padrão tem três fases: expand, migrate e contract.
Vamos a um caso concreto: renomear a coluna nome_completo para nome. Renomear direto é o pior caminho, porque quebra na hora a versão do código que ainda usa o nome antigo.
Fase 1: expand (expandir)
Adicione o novo elemento de forma aditiva e compatível, sem remover nada. No exemplo, você cria a coluna nova nome, sem tocar em nome_completo. Adicionar uma coluna nova e opcional é uma operação leve, que não quebra a versão antiga do código, pois ela simplesmente ignora a coluna que não conhece.
Se a aplicação escreve dados, faça o código novo escrever nos dois lugares durante a transição (double write), mantendo a coluna antiga e a nova em sincronia.
Fase 2: migrate (migrar os dados e o código)
Agora há duas tarefas. Primeiro, copiar os dados existentes de nome_completo para nome (o backfill, detalhado adiante). Segundo, fazer o deploy do código novo, que passa a ler e escrever na coluna nome. Como a fase de expand manteve tudo compatível, esse deploy acontece sem quebrar nada.
Fase 3: contract (contrair)
Só depois que nenhuma versão do código em produção usa mais a coluna antiga, você a remove em uma migração posterior. A remoção é a única etapa destrutiva, e ela acontece no fim, quando já é seguro. Se algo der errado antes disso, a coluna antiga ainda está lá para um rollback tranquilo.
| Fase | O que faz | Reversível? |
|---|---|---|
| Expand | Adiciona o novo, mantém o antigo | Sim, é só aditivo |
| Migrate | Backfill dos dados e deploy do novo código | Sim, o antigo ainda existe |
| Contract | Remove o antigo, já sem uso | Ponto de não retorno |
O princípio que atravessa as três fases: separe a mudança de schema da mudança de código, e nunca remova nada que ainda esteja em uso.
Backfill em lotes
Copiar dados de uma coluna para outra em uma tabela gigante com um único UPDATE é um erro clássico. Esse comando trava a tabela inteira e gera uma transação enorme, que pressiona o banco e pode estourar. A técnica correta é o backfill em lotes: processar as linhas em blocos (por exemplo, mil de cada vez), com uma pequena pausa entre os blocos.
Assim, cada lote é uma transação curta, que segura locks por pouquíssimo tempo e deixa o banco respirar entre um bloco e outro. O backfill roda em segundo plano, ao longo de minutos ou horas, sem nunca prejudicar o tráfego normal da aplicação. É lento de propósito, e é essa lentidão controlada que preserva a disponibilidade.
Cuidados com locks e índices
Alguns detalhes fazem a diferença entre uma migração invisível e um incidente:
- Crie índices de forma concorrente. Muitos bancos permitem construir um índice sem travar as escritas da tabela (no PostgreSQL,
CREATE INDEX CONCURRENTLY). Um índice criado do modo padrão em uma tabela grande pode travá-la por muito tempo. - Evite mudanças que reescrevem a tabela toda. Alterar o tipo de uma coluna ou adicionar uma coluna com valor padrão volumoso pode forçar a reescrita de tudo. Prefira caminhos aditivos e backfill.
- Defina timeouts de lock. Configure o banco para desistir de uma migração que não consegue o lock em poucos segundos, em vez de deixá-la travar a fila de todas as outras queries.
- Teste a migração em dados de tamanho realista. Uma migração que voa em desenvolvimento com mil linhas pode travar em produção com cinquenta milhões. Ensaie no volume real.
Rollback: sempre tenha a volta
Toda migração precisa de um plano de volta pensado antes de aplicá-la. O grande valor do expand and contract é justamente manter o rollback barato: enquanto você não chega ao contract, o estado antigo continua íntegro, e reverter é voltar o deploy do código. Depois do contract, o rollback exige uma nova migração, então essa etapa só acontece quando a confiança é total. Nunca aplique uma mudança destrutiva sem ter verificado, com calma, que a nova versão está saudável em produção.
Migração é engenharia, não coragem
O erro que derruba produção quase nunca é técnico puro; é a pressa de fazer numa tacada só o que deveria ser feito em fases. Evoluir um banco em produção sem downtime não depende de coragem para apertar o botão, e sim de método: migrations versionadas, mudanças aditivas primeiro, backfill em lotes, remoção destrutiva por último e um rollback sempre à mão. Com esse método, mudar o schema deixa de ser um evento tenso de madrugada e vira uma rotina segura, feita em plena luz do dia, com os usuários online sem perceber nada.
Você adia mudanças de schema com medo de derrubar produção? Na ConstructorTI, tratamos migração de banco como engenharia disciplinada, com expand and contract, backfill em lotes e rollback garantido, para evoluir o schema sem janela de manutenção. Se o seu banco virou uma área que ninguém quer tocar, fale com a nossa engenharia e receba um diagnóstico do seu processo de migração.