Migrations de banco: evoluir o schema sem quebrar produção
Como versionar o schema, escrever migrations reversíveis e aplicar mudanças de estrutura em produção sem downtime, com o padrão expand-contract explicado.
Neste artigo
O schema de um banco de dados nunca fica pronto. Requisitos mudam, campos novos aparecem, colunas antigas perdem sentido, tabelas se dividem. A pergunta não é se você vai alterar a estrutura em produção, mas como fazer isso sem derrubar o sistema, sem perder dados e sem transformar cada mudança numa madrugada de tensão. É para isso que existem as migrations: alterações de schema versionadas, ordenadas e reproduzíveis, aplicadas de forma disciplinada em vez de comandos ad hoc digitados direto no banco de produção.
O problema central das migrations é que elas mexem em algo vivo. Diferente de código de aplicação, que você substitui inteiro a cada deploy, o banco carrega estado — os dados dos usuários — que precisa sobreviver à mudança. Uma migration mal feita não causa só um bug; ela pode corromper ou apagar dados irrecuperáveis, ou travar tabelas grandes por tempo suficiente para deixar o sistema inteiro fora do ar. E, ao contrário de um deploy de código, reverter uma migration nem sempre é possível: você pode recolocar a versão antiga do código, mas se a migration já apagou uma coluna, os dados dela se foram.
Este artigo trata dos princípios que tornam migrations seguras: versionamento e reprodutibilidade, reversibilidade, e o padrão que permite mudanças estruturais sem downtime — o expand-contract. São ideias que se aplicam independente da ferramenta que você use para gerenciá-las.
Versionar o schema como se versiona o código#
A base de migrations sãs é tratar o schema como parte do código versionado: cada mudança é um arquivo ordenado, comitado no repositório, aplicado na mesma sequência em todos os ambientes.
A alternativa — alterar o banco à mão, direto no servidor — é a origem de metade dos desastres de produção. Quando as mudanças não são versionadas, os ambientes divergem silenciosamente. O banco de desenvolvimento tem uma coluna que produção não tem. O banco de teste tem um índice que ninguém lembra de onde veio. Ninguém consegue recriar o schema do zero de forma confiável, e a cada deploy paira a dúvida sobre se produção está no estado que o código espera. Esse descompasso entre o que o playbook diz e o que a máquina realmente tem é uma fonte clássica de incidentes.
Migrations versionadas resolvem isso impondo um histórico ordenado. Cada migration tem uma identidade e uma posição na sequência. O banco guarda um registro de quais migrations já foram aplicadas, então a ferramenta sabe exatamente o que falta rodar em cada ambiente. O fluxo correto é sempre o mesmo: a migration nasce em desenvolvimento, é testada localmente, vai para o controle de versão, é aplicada em homologação, e só então em produção — pela mesma pipeline, nunca por mão humana no servidor. Produção deve ser imutável a edições manuais; ela recebe apenas o artefato promovido do repositório. Isso garante que o schema de produção seja exatamente o que o histórico versionado descreve, sem drift.
Um detalhe fácil de esquecer: a migration precisa realmente rodar pela pipeline. Ter os arquivos de migration no repositório não adianta nada se o processo de deploy não os executa. Um sistema em que as migrations existem mas nunca são aplicadas automaticamente pode derrubar toda uma API no dia em que o código novo espera uma coluna que a migration esquecida nunca criou.
Reversibilidade: toda subida tem uma descida#
Uma migration bem escrita vem em par: a operação de subida (up), que aplica a mudança, e a de descida (down), que a desfaz. A descida é a sua rede de segurança quando algo dá errado após aplicar.
Escrever o down obriga você a pensar em como reverter antes de aplicar, e não no meio de um incidente às três da manhã. Se o up adiciona uma coluna, o down a remove. Se o up cria uma tabela, o down a apaga. Se o up cria um índice, o down o derruba. Ter esse caminho de volta testado significa que, se a migration causar um problema inesperado em produção, você tem um botão de reversão confiável em vez de improvisar sob pressão.
O ponto espinhoso é que nem toda mudança é logicamente reversível. Uma migration que remove uma coluna é destrutiva: o down pode recriar a coluna vazia, mas os dados que estavam nela se foram para sempre. Aqui está uma das lições mais importantes das migrations: separe mudanças aditivas de mudanças destrutivas, e trate as destrutivas com cuidado redobrado. Adicionar uma coluna, uma tabela ou um índice é seguro e facilmente reversível. Remover ou renomear algo que contém dados é irreversível na prática e deve ser feito só depois que você tem certeza absoluta de que ninguém mais depende daquilo — o que, como veremos, é o motivo de a remoção ser sempre o último passo, nunca o primeiro.
Testar o down é parte do trabalho, não um extra. Uma migration cujo caminho de reversão nunca foi executado é uma rede de segurança de existência duvidosa. O hábito de aplicar o up, depois o down, depois o up de novo em ambiente de teste confirma que os dois lados funcionam antes de a migration chegar perto de produção.
Mudanças sem downtime: o padrão expand-contract#
O desafio mais delicado é aplicar uma mudança estrutural enquanto o sistema está no ar, com código antigo e código novo possivelmente convivendo durante o deploy. A resposta é o padrão expand-contract (também chamado de expand-migrate-contract, ou paralelo às vezes de parallel change).
A ideia central é que uma mudança que quebra compatibilidade não pode ser feita de uma vez. Se você renomeia uma coluna num único passo, existe um instante em que o banco já tem o nome novo mas ainda há instâncias da aplicação rodando o código antigo que procuram o nome velho — e elas quebram. O expand-contract elimina esse instante quebrando a mudança em fases que preservam compatibilidade o tempo todo.
A fase expand: adicione, sem remover#
Na fase de expansão, você faz apenas adições compatíveis com o código antigo e o novo. Quer renomear a coluna nome_completo para nome? Não renomeie. Adicione uma nova coluna nome, mantendo nome_completo intacta. Nesse ponto, o código antigo continua funcionando com a coluna velha, e o banco ganhou a coluna nova sem que nada quebre. Adições são seguras porque não tiram nada de quem depende do estado anterior.
A fase migrate: preencha e passe a escrever nos dois#
Com a estrutura nova no lugar, você faz o código passar a escrever nos dois lugares — toda gravação atualiza tanto nome_completo quanto nome — e roda um processo de retaguarda que copia os dados existentes da coluna velha para a nova. Ao fim dessa fase, as duas colunas contêm os mesmos dados e permanecem sincronizadas. Só então você faz o deploy do código que lê da coluna nova. Como os dados já estão lá e sincronizados, essa troca de leitura é segura.
A fase contract: remova o que sobrou#
Só depois que nenhuma parte do sistema mais usa a estrutura antiga é que você a remove. Com todo o código já lendo e escrevendo em nome, uma migration final derruba nome_completo. Esse é o passo destrutivo, e ele vem por último exatamente porque, a essa altura, você tem certeza de que ninguém depende mais da coluna velha. Se algo der errado antes desse ponto, a coluna antiga ainda está lá para servir de reversão.
O expand-contract é mais trabalhoso que uma migration única — são vários deploys em sequência, coordenados com mudanças de código. Mas é o preço de mudar estrutura sem janela de manutenção, sem derrubar o sistema e sem correr o risco de código antigo e novo brigarem por um schema que mudou embaixo deles. Para renomeações, mudanças de tipo, quebra de uma coluna em várias, ou divisão de tabelas, é o padrão que mantém tudo no ar.
Cuidados operacionais em tabelas grandes#
Além da lógica, há o custo físico da operação. Certas mudanças de schema, em tabelas grandes, podem travar a tabela inteira enquanto rodam, bloqueando leituras e escritas por segundos ou minutos — o suficiente para o sistema parecer fora do ar. Adicionar uma coluna com valor padrão que precisa ser gravado em cada linha existente, ou criar um índice sem os cuidados adequados, são exemplos de operações que parecem inofensivas e podem congelar produção.
A defesa é conhecer quais operações do seu banco travam e quais não travam, e preferir as versões não-bloqueantes quando existem — por exemplo, criar índices de forma concorrente, que não segura a tabela, mesmo que demore mais. Migrations pesadas em tabelas grandes muitas vezes precisam ser divididas em lotes, atualizando as linhas em porções para não segurar uma transação gigantesca que trava tudo e ainda incha o log de transações. O princípio é o mesmo das transações em geral: não segure travas amplas por muito tempo.
Migrations e dados são coisas separadas#
Vale distinguir dois tipos de migration que às vezes se misturam: as de estrutura (criar coluna, índice, tabela) e as de dados (preencher, transformar, corrigir valores). Elas têm riscos diferentes. Migrations de dados sobre grandes volumes precisam de lotes e de idempotência — poder rodar de novo sem duplicar efeito — porque podem falhar no meio e precisar recomeçar. Tratar as duas com o mesmo descuido é como a migration de dados travar por horas ou deixar o banco num estado parcial se interrompida.
Fechando o raciocínio#
Evoluir o schema de um banco em produção é uma das operações mais arriscadas da engenharia, porque mexe em estado vivo que não pode ser recriado. A disciplina que a torna segura tem três pilares. Versionar toda mudança como código, aplicada pela pipeline e nunca à mão, elimina o drift entre ambientes e garante reprodutibilidade. Escrever migrations reversíveis, separando o aditivo do destrutivo e testando o caminho de volta, dá uma rede de segurança real. E o padrão expand-contract permite mudanças que quebrariam compatibilidade sejam feitas sem downtime, mantendo o sistema compatível a cada passo.
O erro que atravessa quase todos os desastres é a pressa: querer fazer numa migration só o que deveria ser três, remover antes de ter certeza de que ninguém usa, ou rodar direto no servidor pulando o teste. Migrations recompensam a paciência. Quem trata a evolução do schema com o mesmo rigor que trata o código transforma uma fonte crônica de incidentes numa operação rotineira e entediante — que é exatamente o que operações de produção deveriam ser.