Pular para o conteúdo
Voltar

Nunca apague uma coluna de uma vez... Faça em seis!

TL;DR — Mudar algo de que outras coisas dependem, num único passo irreversível, é receita para ficar de plantão. A saída é expand → contract: adicione a forma nova, rode a antiga e a nova lado a lado, migre em passos pequenos e reversíveis, e remova a antiga por último — só quando puder provar que nada mais precisa dela. Uma coluna de BD é o exemplo mais claro, mas o mesmo padrão resolve mudanças de API, rollouts arriscados e redirects.

São 23:32 de uma sexta e meu celular está vibrando loucamente, a ponto de cair do criado-mudo. Naquela tarde eu tinha dado merge no que parecia uma limpeza inofensiva. Uma linha:

ALTER TABLE users DROP COLUMN legacy_email;

O CI estava verde. O app não usava mais legacy_email — eu tinha conferido. O que eu não tinha conferido era que existia um job noturno de exportação, escrito há dois anos por um time que nem existe mais, que ainda dava SELECT nessa coluna. Às 23h ela rodou, falhou e começou a chamar quem estava de plantão. E o que transformou um bug em incidente foi isto: era uma ação que não tinha como desfazer. Reverter o deploy traria o código de volta, mas a coluna — e cada byte dentro dela 🥲 — tinha sumido. O conserto de verdade foi uma restauração de backup à meia-noite.

O erro não foi apagar a coluna. Foi apagá-la de uma vez só, num único passo irreversível, enquanto eu ainda “chutava” quem dependia dela.

Por que isso assusta mais do que parece

Uma mudança de schema não é apenas mais uma mudança. É algo com o qual três coisas precisam concordar no mesmo instante: o banco, o código que escreve nele e tudo que lê dele — outros serviços, tarefas, réplicas, aquela exportação de que ninguém é dono… E não são só os outros sistemas: durante um deploy gradual, o seu próprio app roda o código antigo e o novo lado a lado por alguns minutos, então as duas versões precisam funcionar com o mesmo schema ao mesmo tempo. E aquele “é só subir a migração e o código juntos” nunca acontece no mesmo instante: sempre sobra uma janela de alguns segundos entre os dois. É nessa janela que mora o perigo — tem código lendo uma coluna que já sumiu, ou gravando numa que ainda nem existe.

O padrão: expandir, depois contrair

O conserto é um padrão velho o bastante pra ter vários nomes — expand/contract ou parallel change. A ideia toda é nunca deixar a forma antiga e a nova serem mutuamente exclusivas em nenhum instante. Em vez de trocar tudo de uma vez, você mantém as duas rodando lado a lado e atravessa em passos pequenos, cada um implantável por conta própria:

flowchart LR
  A[1. Adiciona coluna nova] --> B[2. Preenche em lotes]
  B --> C[3. Escreve nas duas]
  C --> D[4. Lê da nova]
  D --> E[5. Para de escrever na antiga]
  E --> F[6. Apaga coluna antiga]

Repare como, em cada passo, as duas formas são válidas — então nada é forçado a estar em sincronia:

1. Expandir: adicione a coluna nova. Puramente aditivo. Aceita NULL, sem valor padrão que reescreva a tabela inteira. Nada lê, nada quebra — um deploy seguro e sem graça.

ALTER TABLE users ADD COLUMN email_verified boolean;

2. Backfill, em lotes. Preencha as linhas existentes a partir do dado antigo. Duas armadilhas aqui. Primeira: um UPDATE sem limite trava milhões de linhas e sua “migração rápida” vira 40 minutos de indisponibilidade — itere por faixas de id pra cada transação ficar pequena. Segunda: deixe o backfill fácil de retomar — guarde o último id processado, pra que, se ele morrer na metade, você recomece sem refazer trabalho nem pular linhas.

UPDATE users SET email_verified = legacy_verified_at IS NOT NULL
WHERE id BETWEEN $1 AND $2; -- um pedaço por vez

3. Escreva nas duas. Suba código que escreve na coluna nova e na antiga. Esse é o passo que as pessoas pulam, e é o que torna tudo seguro: como o código antigo e o novo rodam ao mesmo tempo durante o deploy, o antigo ainda cria linhas, e você precisa delas certas nos dois lugares.

4. Leia da coluna nova. Redirecione as leituras para ela — de preferência atrás de uma flag, pra esse passo sozinho reverter em segundos. A coluna antiga ainda é escrita e ainda tem o dado, então se o caminho novo estiver errado, você volta na hora.

5. Pare de escrever na coluna antiga. Nada depende de legacy_email agora, mas o dado dela continua ali. Esse é seu último ponto seguro.

6. Contrair: apague a coluna antiga — por último. O único passo irreversível da sequência, então faça por merecer. Não chute que a coluna morreu; prove. Adicione um log ou um contador que dispara sempre que algo a lê, observe as estatísticas de query do banco em busca de SELECT’s perdidos, e deixe rodando assim por um tempo de segurança. Quando ele estiver zerado faz tempo o bastante pra apagar a coluna não dar mais nenhum frio na barriga, pode apagar. O objetivo é esse: que seja sem graça.

O DROP de uma vez só tinha exatamente um momento, e era um abismo. O expand/contract tem seis momentos, cinco dos quais você desfaz com um git revert ou um toque na flag — e isola o único ponto sem volta pro fim, quando você tem mais informação e menos dúvida. Sem ligação às 23h 😴.

De uma coluna a um subsistema inteiro

O caso da coluna é o mais simples que existe: uma coluna, um DROP. Agora veja o mesmo padrão carregando peso de verdade. Num produto em que trabalhei, os menus de navegação ficavam no banco de dados (tínhamos nossas razões 😏) — cada linha de menu tinha seu rótulo fixo, num único idioma. Até que veio a necessidade de traduzir esses menus para vários idiomas. A tentação é o big-bang: introduzir uma camada de tradução e religar toda leitura num único release. É o DROP de sexta à noite de novo, só que numa escala bem maior. Em vez disso, expand/contract — e o velho rótulo fixo ainda presta um último serviço antes de ser removido.

flowchart LR
  A[1. Adicionar coluna translate_key] --> B[2. Preencher as chaves a partir dos rótulos]
  B --> C[3. Semear o idioma de origem, traduzir o resto]
  C --> D[4. Escrever rótulo e chave]
  D --> E[5. Ler pela chave, rótulo como fallback]
  E --> F[6. Apagar as colunas de rótulo antigas]

1. Expandir: adicione uma coluna translate_key (aceitando NULL) na tabela de menu. Aditiva e idempotente — ninguém lê ainda, nada quebra.

2. Preencha as chaves. Uma migração percorre cada menu existente e deriva uma chave estável a partir do rótulo atual — slugificada em algo como billing.invoices — e grava na coluna nova. É re-executável: se uma rodada parar na metade, ela continua de onde estava.

3. Semeie, depois espalhe. O rótulo que já estava na linha vira o valor do idioma de origem para aquela chave no serviço de tradução. Uma tarefa em segundo plano assume a partir daí, traduzindo cada chave para os outros locales e gravando cada resultado de volta. O texto fixo antigo não foi jogado fora — foi o seed da própria substituição.

4. Escreva nas duas. O editor de menu agora salva o rótulo fixo e a chave, lado a lado — exatamente como nas duas colunas do exemplo anterior.

5. Leia pela chave. A renderização resolve cada rótulo pela chave mais o locale do usuário, com o rótulo fixo original como fallback sempre que ainda falta uma tradução. Atrás de uma flag, então a troca inteira reverte em segundos.

6. Contraia. Quando todo menu tiver chave e nada mais ler as colunas fixas, apague-as apenas por último.

Repare no que mudou e no que não mudou: a “forma nova” aqui não é uma coluna — é uma chave apoiada num subsistema de tradução inteiro, com jobs, locales e tudo mais… Para o padrão, tanto faz: adicione a coisa nova, mantenha as duas vivas enquanto migra e remova a antiga por último. E o mais interessante: o que estava sendo substituído semeou a própria substituição. Aquele rótulo fixo foi a semente de onde toda tradução nasceu — até o momento em que deu pra apagá-lo com segurança.

A coluna foi só o exemplo mais claro

O padrão não se restringe apenas a banco de dados. O ponto é: adicione a coisa nova, mantenha as duas vivas enquanto migra, remova a antiga por último. Ele aparece em qualquer mudança com dependências que você não controla por inteiro.

  • Renomear um campo numa API. EXPANDIR: retorne name ao lado do antigo full_name. As duas são válidas, então todo cliente continua funcionando e migra no próprio ritmo — sem dia D. O “backfill” aqui é só paciência: você observa qual campo os clientes de fato pedem. CONTRAIR: apague full_name quando esse número zerar.

  • Subir lógica arriscada. EXPANDIR: suba o caminho novo atrás de uma flag, desligada — implantado mas dormente. RODAR AS DUAS: vá de 1% a 100% (ou rode novo e antigo em paralelo e compare), olhando os gráficos. CONTRAIR: apague a flag e o ramo antigo quando o novo tiver merecido. A flag é seu corte reversível; apagá-la é o passo sem volta, guardado pro fim.

  • Mover uma URL ou um domínio. EXPANDIR: sirva o novo lugar com um 302 — um redirect que funciona agora, mas continua reversível, porque nada o cacheia pra sempre. CONTRAIR: só quando o tráfego tiver escoado e você tiver certeza, promova pra 301, a versão permanente que você não desfaz.

O mesmo vale pra evoluir o payload de um evento, um formato de config, qualquer coisa com consumidores que você não controla. O que é constante é a disciplina: nunca tornar o mundo antigo e o novo mutuamente exclusivos, e guardar o único ato irreversível — o DROP, a flag apagada, o 301 — pro final, quando a dúvida é menor.

Quando seguir todos os passos é exagero

Seis passos cuidadosos pra uma tabela de config. de 12 linhas que ninguém lê em produção? Não precisa. A cautela tem que ser proporcional ao custo de errar: uma coluna muito acessada, numa tabela enorme, com leitores que você nem consegue listar, merece todos os passos; já uma tabela minúscula, que só você mexe, se resolve no bom senso.

O padrão é uma ferramenta, não um ritual — use só o tanto que o tamanho do estrago justificar, e nada além. Expandir, rodar as duas, contrair — e deixar o passo irreversível por último. A coluna foi só onde isso me pegou. Seus fins de semana agradecem.

Para se aprofundar

  • Parallel Change — Danilo Sato, no martinfowler.com. O texto canônico do padrão (expand → migrate → contract), de onde vem o nome.
  • Backward compatible database changes — PlanetScale. Um passo a passo prático do expand/contract para a mudança de uma coluna, no schema e no código da aplicação.
  • Online migrations at scale — Jacqueline Xu, Stripe. A mesma ideia numa tabela gigante: a migração em quatro passos com dual-write, backfill, troca de leitura e limpeza.
  • Evolutionary Database Design — Pramod Sadalage & Martin Fowler. O contexto mais amplo: migrações como artefatos versionados e de primeira classe, que deixam o schema evoluir continuamente.
  • Refactoring Databases: Evolutionary Database Design — Scott Ambler & Pramod Sadalage. O tratamento em formato de livro, com um catálogo de refatorações de schema pequenas e seguras.

Compartilhe este post: