Migrations de banco em produção

TL;DR

Um deploy rolling coloca duas versões da aplicação rodando ao mesmo tempo contra o mesmo banco — e um ALTER TABLE RENAME COLUMN que parecia trivial no ambiente local vira um incidente em produção, porque a versão antiga quebra quando a coluna que ela espera desaparece. O padrão que resolve isso é expand/contract (também chamado de parallel change, cunhado por Martin Fowler): (1) expand — adiciona o novo sem remover o velho, retrocompatível; (2) migrate/backfill — copia e preenche os dados, com dual-write se necessário; (3) contract — só depois que 100% do tráfego usa o novo, remove o velho, num deploy separado. Junto disso: cuidado com locks (um ALTER mal escrito trava a tabela inteira e derruba o site), backfill em batches para não sobrecarregar produção, e a lição mais traiçoeira de todas — “down migration” não é um botão de desfazer seguro, porque dados já migrados não voltam sozinhos. A defesa preferida da indústria não é reverter, é seguir em frente com uma correção (fix forward).

Duas da tarde, deploy rolling em andamento. A v2 do serviço sobe aos poucos — 10%, 30%, 60% das instâncias — enquanto a v1 ainda atende o resto do tráfego. Faz parte do plano: é assim que um rolling deployment evita downtime, trocando pods um a um em vez de derrubar tudo de uma vez (você já viu essa mecânica na nota sobre deployment strategies).

O problema é que essa migration, feita às pressas na sexta à tarde, renomeou uma coluna: nome virou nome_completo. A migration rodou primeiro, porque “afinal, o schema precisa estar pronto antes do código novo”. Ela terminou em 200 milissegundos — parecia inofensiva.

Só que agora as duas versões da aplicação leem o mesmo banco ao mesmo tempo. A v2, que já sabe da coluna nova, funciona bem. A v1, que ainda está rodando em 40% das instâncias e vai continuar rodando até o rollout terminar, faz SELECT nome FROM usuarios — e recebe um erro de coluna inexistente. Metade do tráfego começa a retornar 500.

Pior ainda: em bancos mais antigos ou em tabelas gigantes, o próprio ALTER TABLE pode não ser instantâneo — ele pode travar a tabela inteira enquanto reescreve dados em disco, e todo o tráfego (das duas versões) fica esperando esse lock. Nesse cenário não é só a v1 que quebra: o site inteiro trava, porque ninguém — nem v1, nem v2 — consegue mais fazer uma query naquela tabela.

Esse é o assunto desta nota: como o schema do banco sobrevive a um deploy em que duas versões da aplicação coexistem, sem downtime e sem lock generalizado. Não é sobre sintaxe de ALTER TABLE ou sobre como configurar réplicas — é sobre a disciplina de sequenciar mudanças de schema para que elas nunca quebrem a versão da aplicação que ainda está rodando.

Por que a coexistência de versões é o cerne do problema

Voltando ao princípio: qualquer estratégia de deploy que não seja “desligar tudo e ligar de novo” — rolling, blue-green, canary — implica, por definição, um período em que mais de uma versão do código está lendo e escrevendo no mesmo banco. Isso já foi estabelecido na nota anterior sobre deployment strategies; aqui a pergunta é: o que isso exige do schema?

A resposta é uma restrição simples de enunciar e fácil de esquecer sob pressão: o schema em produção precisa, durante toda a janela de rollout, ser compatível com a versão antiga E com a versão nova ao mesmo tempo. Não “eventualmente compatível” — compatível agora, enquanto os dois códigos coexistem.

Isso descarta de cara qualquer migration que seja destrutiva e imediata: remover uma coluna que o código antigo ainda lê, renomear algo sem deixar o nome antigo disponível, mudar o tipo de uma coluna de forma que quebre a serialização que o código antigo espera. Cada uma dessas operações, se feita “de uma vez”, assume implicitamente que só existe uma versão de código rodando — uma premissa que rolling/blue-green/canary já invalidaram.


graph TD
    subgraph JANELA["Janela do rollout"]
        V1["v1 (código antigo)<br/>ainda rodando em N instâncias"]
        V2["v2 (código novo)<br/>já rodando em M instâncias"]
    end
    DB[("Um único banco<br/>compartilhado")]
    V1 -->|"lê/escreve"| DB
    V2 -->|"lê/escreve"| DB
    DB -.->|"schema precisa servir<br/>as duas versões<br/>AO MESMO TEMPO"| JANELA

"A migration é rápida, então não tem risco de coexistência"

O que acontece: o time roda a migration de renomear a coluna, ela termina em 200ms, e assume que o risco passou — afinal foi “rápido”. Por quê: o risco não é o tempo que a migration leva para rodar — é o tempo que o rollout do deploy leva para completar. Um ALTER TABLE RENAME COLUMN pode ser instantâneo e ainda assim deixar a v1 quebrada por 5, 10, 20 minutos, enquanto o Kubernetes ainda está trocando pods um a um. Como evitar: trate a pergunta certa como “quanto tempo as duas versões vão coexistir?”, não “quanto tempo a migration leva”. Se a resposta for “minutos a mais que zero” — e quase sempre é — a migration precisa ser retrocompatível com a versão antiga durante essa janela inteira.

O padrão: expand / migrate / contract (parallel change)

O nome mais citado para esse padrão vem de Martin Fowler, que o descreve como Parallel Change — a técnica de quebrar uma mudança incompatível numa interface em três fases distintas, de forma que o sistema nunca fique quebrado em nenhum momento intermediário (martinfowler.com/bliki/ParallelChange). Fowler é explícito sobre a aplicação a bancos de dados: “a maioria dos refactorings de banco de dados segue o padrão de parallel change, onde a fase de migrate é o período de transição entre o schema original e o novo, até que todo o código de acesso ao banco tenha sido atualizado para trabalhar com o novo schema”. O mesmo padrão é discutido em profundidade, com Pramod Sadalage, no livro/artigo Evolutionary Database Design (martinfowler.com/articles/evodb.html) — a referência canônica para tratar o schema como algo que evolui em pequenos passos versionados, não como um artefato fixo desenhado de uma vez.

Aplicado a schema de banco, o padrão vira três fases, cada uma um deploy separado (não um único script):

1. Expand. Adiciona o novo — coluna, tabela, índice — sem tocar no velho. O schema resultante é a união do antigo e do novo: ambos existem, ambos funcionam. A v1 continua lendo/escrevendo a coluna velha exatamente como sempre fez; nada nela precisa mudar. Essa é a fase que garante retrocompatibilidade.

2. Migrate (backfill). Copia e preenche os dados: um job de backfill popula a coluna nova a partir da velha para as linhas que já existiam antes do expand. Se a aplicação continuar recebendo escritas nesse meio-tempo, ela precisa gravar nos dois lugares (dual-write) para que nenhuma linha nova fique só no lado velho. Só depois que o backfill termina e é validado — e só depois que 100% das instâncias já rodam código que lê do lado novo — a próxima fase pode começar.

3. Contract. Remove o velho. Só é seguro quando nenhuma instância em produção ainda depende dele — nem para ler, nem para escrever. Essa é, tipicamente, a operação mais rápida de todas (remover uma coluna é metadado, não reescrita de dados), mas é a que mais gente executa cedo demais, antes de confirmar que o velho está realmente órfão.


graph LR
    subgraph E["1. EXPAND (deploy A)"]
        E1["Adiciona coluna nova<br/>Schema = velho + novo"]
    end
    subgraph M["2. MIGRATE (deploy B)"]
        M1["App faz dual-write<br/>Job faz backfill<br/>App passa a LER do novo"]
    end
    subgraph C["3. CONTRACT (deploy C)"]
        C1["Confirma: nenhuma instância<br/>depende do velho"]
        C2["Remove coluna velha"]
    end
    E1 --> M1 --> C1 --> C2
    style E1 fill:#4A90D9,color:#fff
    style M1 fill:#F5A623,color:#000
    style C1 fill:#D0021B,color:#fff
    style C2 fill:#D0021B,color:#fff

Repare no detalhe estrutural: são três deploys, não um. Cada fase só avança depois que a anterior foi confirmada estável em produção — geralmente com dias de intervalo entre elas, não minutos. É o oposto do impulso natural de “resolver tudo numa migration só, de uma vez, e seguir em frente”.

Exemplo trabalhado: renomear uma coluna sem downtime

Voltando à cena de abertura — renomear nome para nome_completo numa tabela usuarios com milhões de linhas, num serviço com deploy rolling e SLA de disponibilidade contínua. Eis a sequência completa, cada passo um deploy independente:

Passo 1 — Expand: adicionar a coluna nova.

ALTER TABLE usuarios ADD COLUMN nome_completo TEXT;

Sem DEFAULT e sem NOT NULL — isso é proposital, não descuido. Em PostgreSQL moderno, adicionar uma coluna sem valor default é uma operação de metadado, praticamente instantânea, porque o Postgres não precisa reescrever as linhas existentes. Se você adicionar um DEFAULT não nulo numa tabela grande, versões antigas de MySQL (e mesmo Postgres, se o default depender de expressão volátil) reescrevem a tabela inteira — minutos ou horas de operação bloqueante numa tabela de centenas de milhões de linhas. A tática segura: adicionar a coluna sem default, preencher com UPDATE depois (passo 3), e só então — se realmente precisar de um NOT NULL — aplicar a constraint.

A aplicação, na v1, nem sabe que essa coluna existe. Ela continua lendo e escrevendo nome normalmente. Zero impacto.

Passo 2 — Deploy da app: dual-write.

A v2 do código passa a escrever nas duas colunas em todo INSERT/UPDATE:

-- pseudocódigo do que o ORM/repositório faz agora
UPDATE usuarios SET nome = $1, nome_completo = $1 WHERE id = $2;

Ela ainda de nome (a versão antiga do dado é a fonte de verdade até o backfill terminar). Durante o rollout desse deploy, a v1 (que só escreve em nome) e a v2 (que escreve nas duas) coexistem sem conflito — a coluna nova só fica temporariamente desatualizada para linhas que a v1 tocar, o que o backfill do próximo passo resolve.

Passo 3 — Backfill: preencher o histórico.

Um job separado — não parte do deploy da aplicação, rodado sob controle, geralmente fora do horário de pico — copia nome para nome_completo nas linhas que existiam antes do dual-write começar, em lotes pequenos:

UPDATE usuarios
SET nome_completo = nome
WHERE nome_completo IS NULL
  AND id BETWEEN :lote_inicio AND :lote_fim;

O motivo de fazer em batches (em vez de um UPDATE sem WHERE que toca a tabela inteira de uma vez) é duplo: um único UPDATE gigante mantém uma transação longa aberta, o que segura locks e infla o WAL/binlog por todo esse tempo; e ele compete diretamente com o tráfego de produção pelos mesmos recursos de I/O e CPU. A prática comum é: lotes de alguns milhares de linhas, um pequeno intervalo de espera entre lotes, monitorar a réplica (se o lag de replicação subir, pausar), e checkpoints que permitem retomar de onde parou se o job cair.

Passo 4 — Deploy da app: virar a leitura para o novo.

Com o backfill validado (uma query de sanidade: SELECT count(*) FROM usuarios WHERE nome_completo IS NULL deve ser zero, ou perto disso), a v3 do código passa a ler de nome_completo em vez de nome — mas continua escrevendo nas duas, por segurança, caso alguma instância antiga do deploy anterior ainda esteja no ar.

Passo 5 — Deploy da app: parar de escrever no velho.

Depois que 100% do tráfego roda a v3 (ou posterior) por tempo suficiente para dar confiança, a v4 para de escrever em nome. Agora nome está órfão: nenhum código em produção lê nem escreve nele.

Passo 6 — Contract: remover a coluna velha.

ALTER TABLE usuarios DROP COLUMN nome;

Só chega aqui depois de confirmar, com uma margem de segurança generosa (dias, não minutos — o suficiente para saber que nenhum job em background, relatório, ou serviço satélite esquecido ainda depende de nome), que o velho está mesmo morto. Esse DROP COLUMN é rápido — remover metadado, não dado — mas é irreversível sem restaurar de um backup, então a paciência das etapas anteriores é o que faz dele seguro.

Seis deploys para renomear uma coluna. É deliberadamente mais devagar do que um ALTER TABLE ... RENAME COLUMN de uma linha só — e é exatamente essa lentidão que evita o incidente descrito na abertura desta nota.

Pular o dual-write "porque o backfill é rápido"

O que acontece: o time faz o expand, roda o backfill, e já assume que terminou — sem nunca ter implementado o dual-write do passo 2. Por quê: entre o momento em que o backfill começou e o momento em que ele terminou, a aplicação continuou recebendo escritas novas em nome. Sem dual-write, todo INSERT/UPDATE que aconteceu durante essa janela ficou de fora do backfill — silenciosamente, sem erro nenhum, porque a coluna nova simplesmente ficou NULL para essas linhas. Como evitar: trate dual-write como obrigatório sempre que existir uma janela de escrita concorrente com o backfill — o que é o caso em praticamente qualquer tabela que recebe tráfego de escrita ativo. Rode uma query de reconciliação ao final do backfill (contar NULLs remanescentes) antes de declarar a fase de migrate concluída.

Locks: o outro jeito de uma migration derrubar o site

Coexistência de versão é um jeito de a migration quebrar produção. O outro é mais direto: a própria operação de DDL trava a tabela e todo o tráfego — de qualquer versão — fica esperando.

O caso clássico é criar um índice. CREATE INDEX normal adquire um lock que bloqueia escritas na tabela até terminar — em uma tabela de milhões de linhas, isso pode ser minutos de indisponibilidade de escrita. O PostgreSQL resolve isso com CREATE INDEX CONCURRENTLY, que constrói o índice sem bloquear INSERT/UPDATE/DELETE concorrentes — ao custo de levar mais tempo e não poder rodar dentro de uma transação explícita. A documentação oficial do Postgres é clara sobre essa troca (postgresql.org/docs/current/sql-createindex.html).

Outro caso citado com frequência: adicionar uma constraint NOT NULL numa tabela grande. Fazer isso de uma vez com ALTER TABLE ... ALTER COLUMN ... SET NOT NULL exige que o Postgres varra a tabela inteira sob um lock exclusivo para validar que nenhuma linha viola a constraint. A técnica segura, documentada no guia da Braintree/PayPal PostgreSQL at Scale: Database Schema Changes Without Downtime (James Coleman, medium.com/paypal-tech), é adicionar a constraint como CHECK (coluna IS NOT NULL) NOT VALID primeiro — instantâneo, porque não valida linhas existentes — e depois rodar VALIDATE CONSTRAINT separadamente, que usa um lock mais brando (ShareUpdateExclusiveLock) e permite leituras e escritas normais durante a validação.

Em MySQL, o quadro histórico foi pior: por muito tempo, boa parte dos ALTER TABLE reconstruía a tabela inteira, bloqueando escritas durante todo o processo. Isso motivou o ecossistema a construir ferramentas de migration online por fora do próprio ALTER TABLE: o gh-ost, criado pelo GitHub, usa o binlog do MySQL para replicar mudanças para uma tabela-sombra sem usar triggers (ao contrário de ferramentas anteriores como o pt-online-schema-change da Percona), permitindo pausar, throttle e controlar o ritmo da migration dinamicamente sem nunca travar a tabela original (github.blog, anúncio do gh-ost). O Vitess, a camada que a PlanetScale expõe como “non-blocking schema changes”, segue a mesma ideia arquitetural: cria uma cópia da tabela, aplica a mudança nela, mantém as duas sincronizadas, e faz um cutover rápido no final (planetscale.com/blog/non-blocking-schema-changes).


graph TD
    OP["Operação de DDL"] --> Q{"Trava a tabela<br/>inteira?"}
    Q -->|"Sim — CREATE INDEX comum,<br/>ALTER com reescrita,<br/>SET NOT NULL direto"| BAD["🔴 Todo tráfego<br/>(v1 e v2) espera<br/>o lock liberar"]
    Q -->|"Não — CONCURRENTLY,<br/>NOT VALID + VALIDATE,<br/>gh-ost/Vitess tabela-sombra"| GOOD["🟢 Leituras e escritas<br/>seguem normais<br/>durante a migration"]

Rollback de migration: por que é traiçoeiro

A intuição de “se algo der errado, a gente reverte” — que funciona bem para o código da aplicação (basta apontar o load balancer de volta para a versão anterior) — não funciona da mesma forma para dados.

O motivo é estrutural: reverter um deploy de aplicação desfaz comportamento — o binário antigo volta a rodar, ponto final. Reverter uma migration de banco precisa desfazer dados que já mudaram no mundo real. Se o backfill do passo 3 do exemplo acima já rodou e um usuário já fez login usando o fluxo novo, “reverter a migration” com um down() automático não desfaz esse login, não desfaz um e-mail que já foi disparado como efeito colateral de uma escrita, não desfaz um evento que um worker já consumiu de uma fila. A ferramenta pode até reverter o schema — trazer a coluna de volta —, mas não tem como reverter o mundo que já reagiu aos dados novos.

Essa é a razão pela qual a prática amadurecida da indústria migrou de “sempre escreva uma down migration” para fix forward: se algo deu errado, a resposta não é tentar desfazer, é escrever e aplicar uma nova migration que corrige o estado atual para o estado correto — avançando, nunca recuando. Um post de referência sobre o tema resume bem o critério prático: se a mudança foi só aditiva (expand — adicionar algo), reverter é seguro e barato; se a mudança foi destrutiva (remover, renomear sem manter o velho), reverter exige plano manual, não um botão automático (schemasmith.com/guides/database-rollback-strategies).

E é exatamente aqui que o padrão expand/contract paga seu custo de disciplina: se você sempre mantém o velho vivo até ter certeza absoluta de que ninguém mais depende dele, o “rollback” da fase de expand é trivial (a coluna nova simplesmente fica sem uso — não há nada de crítico para desfazer). O rollback perigoso só aconteceria na fase de contract — e por definição, a fase de contract só roda depois que a fase mais arriscada (migrate/backfill) já foi validada em produção por dias. Expand/contract não elimina o risco de rollback; ele empurra o momento irreversível para o mais tarde possível, depois que a maior parte da incerteza já foi resolvida.

Confiar cegamente no down() do framework de migration

O que acontece: Flyway, Liquibase, Rails ActiveRecord e ferramentas similares oferecem um comando de rollback (down), e o time trata isso como uma rede de segurança automática — “se der ruim, a gente roda o rollback e volta ao normal”. Por quê: o down() desfaz o schema corretamente na maioria dos casos, mas raramente desfaz os dados de forma segura — uma coluna dropada e recriada perde os valores que tinha; um down que remove uma tabela nova apaga qualquer dado gravado nela desde o up, mesmo que esse dado já tenha sido lido, processado ou replicado para outro sistema. Como evitar: escreva down() apenas para migrations que são genuinamente reversíveis sem perda (tipicamente, só o contract de uma expand/contract bem-feita — porque o velho nunca foi removido até você ter certeza). Para o resto, planeje fix-forward desde o início: o plano B de uma migration arriscada não é “reverter”, é “ter o próximo passo pronto para corrigir o que deu errado”.

Separar a migration do deploy da aplicação

Um erro comum de sequenciamento é tratar “rodar a migration” e “fazer o deploy” como o mesmo evento atômico — um script de CI que faz migrate && deploy em sequência cega. O padrão expand/contract já deixa implícito por que isso é problemático: cada fase (expand, migrate, contract) é o schema antecipando uma versão de código que ainda vai chegar, ou limpando depois que uma versão antiga já foi embora. Isso exige uma ordem específica, não simultaneidade:

  • Expand roda antes do deploy que o usa. A coluna nova precisa existir antes que qualquer instância de v2 tente escrever nela — senão a primeira instância de v2 que subir já quebra.
  • Contract roda depois que o deploy anterior está 100% estabilizado. Remover a coluna velha antes que a última instância de v1 (ou de uma v2 intermediária que ainda a usa) tenha saído do ar quebra exatamente quem você estava tentando proteger.

Quem executa cada fase também importa operacionalmente: em times pequenos, geralmente é o próprio pipeline de CI/CD que roda migrations como um passo distinto — não acoplado ao rollout do deploy —, com uma ferramenta de migration versionada controlando o quê já rodou. Em times maiores ou em bancos especialmente sensíveis, pode ser um humano (DBA ou engenheiro de plantão) que aprova cada fase manualmente, exatamente porque o contract é irreversível sem backup e vale o julgamento extra antes de apertar o gatilho.

Migrations versionadas e idempotência

Nada do que foi descrito acima funciona sem uma forma confiável de saber o que já rodou, em qual ordem, e o que ainda falta rodar — em cada ambiente, de cada instância do serviço. É esse o papel de ferramentas como Flyway e Liquibase: cada migration ganha um identificador de versão, é aplicada exatamente uma vez, e a ferramenta mantém uma tabela de controle (flyway_schema_history, no caso do Flyway) que registra o que já foi executado — de forma que rodar o mesmo deploy duas vezes, ou rodar a partir de duas instâncias diferentes do pipeline ao mesmo tempo, não reaplique a mesma migration por engano.

A distinção que essas ferramentas fazem entre migrations versionadas (rodam uma vez, na ordem, nunca de novo) e migrations repetíveis (rodam de novo sempre que o conteúdo muda — tipicamente para coisas como views ou stored procedures) é o motivo pelo qual idempotência importa especificamente para o segundo grupo: uma migration repetível que faz CREATE OR REPLACE VIEW pode ser reaplicada com segurança porque o resultado final é o mesmo não importa quantas vezes rode; uma migration repetível que faz INSERT sem checagem de duplicata, não. Migrations versionadas, por definição, já são protegidas de reexecução pela própria tabela de controle — mas escrevê-las de forma idempotente (CREATE TABLE IF NOT EXISTS, por exemplo) ainda é boa prática defensiva, porque protege contra o caso em que o histórico de controle fica dessincronizado do estado real do banco — algo que acontece com mais frequência do que se gostaria, num incidente de infraestrutura ou numa migration que falhou pela metade.

Em entrevista

Perguntas sobre “como você mudaria o schema de uma tabela grande em produção sem downtime” aparecem com frequência em entrevistas de staff/senior backend — geralmente como follow-up de uma pergunta de deployment strategies, exatamente porque testam se o candidato conecta os dois problemas.

O que um entrevistador sênior está de fato avaliando:

  • Se você reconhece a coexistência de versões como a causa raiz, não trata a migration como um problema isolado de SQL.
  • Se você sabe nomear e sequenciar expand/migrate/contract — três deploys, não um script — e explicar por que a ordem importa.
  • Se você sabe identificar operações de lock perigoso por engine (ADD COLUMN com default em tabela grande, CREATE INDEX sem CONCURRENTLY) e as alternativas seguras.
  • Se você entende por que rollback de dados é diferente de rollback de código, e defende fix-forward como a resposta madura em vez de prometer um “desfazer” que não existe de verdade.

A resposta fraca descreve só o SQL de uma migration isolada. A resposta forte amarra a um cenário de deploy real: “durante um rolling deploy, as duas versões leem o mesmo banco — então eu quebro a mudança em expand, migrate com dual-write e backfill em batches, e só faço o contract depois que confirmo que nenhuma instância antiga ainda está no ar.”

How to explain in English

PTEN
Migration de bancoDatabase migration / schema migration
Expandir/migrar/contrairExpand/migrate/contract (parallel change)
Escrita duplaDual-write
Preenchimento retroativoBackfill
Travar a tabelaLock the table / table lock
Migration online (sem lock)Online schema change / non-blocking migration
Corrigir seguindo em frenteFix forward
Reverter a migrationRoll back the migration
Migration versionadaVersioned migration
Migration idempotenteIdempotent migration
Tabela-sombraShadow table

“During a rolling deploy, two versions of the app read the same database at the same time — that’s what makes schema changes dangerous. We use the expand-contract pattern: first expand the schema additively, so it’s backward-compatible with the version still running; then migrate data with dual-writes and a batched backfill; only after every instance is confirmed to be on the new code do we contract and drop the old column. We also watch for locking operations — like adding a NOT NULL column with a default on a huge table — and prefer online alternatives. And we don’t rely on down-migrations to undo data; if something goes wrong, we fix forward instead of rolling back.”

O que vem a seguir

Mudar o schema sem quebrar a coexistência de versões resolve o problema do banco. O próximo passo lida com o problema irmão: como a infraestrutura em volta do serviço — o cluster, a rede, a configuração — também precisa ser versionada, revisável e reproduzível, em vez de mudada manualmente por alguém logado num servidor.

Veja também

Fontes