Migração de Banco de Dados em 2026: Zero Downtime

Profile picture of Equipe Arvucore

Equipe Arvucore

September 22, 2025 · Atualizado em August 26, 2026

15 min read

Uma migração de banco de dados com zero downtime é uma mudança no schema ou nos dados dividida em passos pequenos o suficiente para que a aplicação em execução nunca quebre: adicione a estrutura nova, faça o código funcionar com as duas formas, mova os dados e então remova a estrutura antiga. O padrão se chama expand-and-contract e é o núcleo de toda estratégia de migração em produção. Todo o resto deste guia (DDL online, processamento em lotes, ferramentas, rollback) existe para tornar esses passos seguros em tabelas grandes sob tráfego real.

Três tipos de migração de banco de dados

A palavra "migração" cobre três problemas diferentes. Eles precisam de planos diferentes.

Tipo O que muda Risco típico Técnica principal
Migração de schema Tabelas, colunas, índices, constraints Locks, DDL longo, quebra do código antigo Expand-and-contract, DDL online
Migração de dados As próprias linhas: backfills, reformatação, mover valores entre colunas Transações longas, lag de replicação, estado parcial Lotes, jobs idempotentes, reconciliação
Migração de plataforma O motor ou o host: MySQL para PostgreSQL, on-prem para nuvem gerenciada, uma versão major para outra Diferenças semânticas, cutover, perda de dados Replicação/CDC, dual write, cutover ensaiado

A maioria das releases só precisa das duas primeiras. Uma migração de plataforma é um projeto, não um passo do deploy, e geralmente faz parte de um esforço maior, como migrar sistemas legados para arquiteturas modernas. Este artigo foca em migrações de schema e de dados, que são as que as equipes entregam toda semana.

Uma regra vale para as três: o banco nunca pode depender de uma versão específica da aplicação estar no ar. O código antigo e o novo vão coexistir durante qualquer rolling deploy, e uma migração que assume o contrário causa uma indisponibilidade exatamente no momento em que você não consegue fazer rollback limpo.

Deploys retrocompatíveis: a restrição da qual tudo deriva

Durante um deploy rolling ou blue-green existe uma janela em que a versão N e a versão N+1 da aplicação falam com o mesmo banco. Se você usa deploys canary ou rolling, essa janela pode durar horas. A migração precisa ser compatível com as duas.

Na prática, isso significa:

  • Mudanças aditivas vão primeiro, na própria migração, antes do código que as usa. Colunas novas nullable, tabelas novas, índices novos.
  • Mudanças destrutivas vão por último, depois que o código antigo sumiu. Drops, renomeações, NOT NULL em coluna existente, mudanças de tipo.
  • Nenhuma migração renomeia ou apaga algo que a versão atual lê. Renomear é o erro clássico: ALTER TABLE ... RENAME COLUMN é instantâneo, e quebra toda query da versão antiga instantaneamente.
  • A aplicação tolera as duas formas durante toda a transição: escreve nas duas colunas, ou lê a nova com fallback para a antiga.

Se o seu ORM gera um rename ou um drop automaticamente, trate isso como sinal para parar e dividir a mudança. Uma feature flag permite trocar as leituras da antiga para a nova sem deploy, o que torna os passos intermediários reversíveis.

Expand-and-contract, passo a passo

O padrão tem quatro fases. Cada uma é um deploy separado ou uma migração separada, nunca agrupadas.

  1. Expand: adicione a estrutura nova sem tocar na antiga.
  2. Migrar o código: a aplicação escreve nas duas, lê da nova (com fallback).
  3. Backfill: copie os dados existentes para a estrutura nova, em lotes.
  4. Contract: mude as leituras por completo, adicione constraints, apague a estrutura antiga.

Exemplo 1: renomear uma coluna

Objetivo: renomear users.fullname para users.display_name em uma tabela com milhões de linhas.

Passo 1, expand (migração, antes do deploy):

ALTER TABLE users ADD COLUMN display_name text;

Adicionar uma coluna nullable sem default é uma mudança só de metadados no PostgreSQL e no MySQL moderno (InnoDB instant ADD COLUMN). Não reescreve a tabela.

Passo 2, dual write (deploy da aplicação):

O código escreve nas duas colunas em todo insert e update, e lê display_name com fallback para fullname. Se você não consegue alterar todos os caminhos de escrita (jobs legados, outros serviços), um trigger mantém as duas em sincronia até conseguir:

CREATE OR REPLACE FUNCTION sync_display_name() RETURNS trigger AS $$
BEGIN
  IF NEW.display_name IS NULL THEN NEW.display_name := NEW.fullname; END IF;
  IF NEW.fullname IS NULL THEN NEW.fullname := NEW.display_name; END IF;
  RETURN NEW;
END $$ LANGUAGE plpgsql;

CREATE TRIGGER users_sync_display_name
BEFORE INSERT OR UPDATE ON users
FOR EACH ROW EXECUTE FUNCTION sync_display_name();

Passo 3, backfill (job em lotes, não um único UPDATE):

UPDATE users
SET display_name = fullname
WHERE id IN (
  SELECT id FROM users
  WHERE display_name IS NULL AND fullname IS NOT NULL
  ORDER BY id
  LIMIT 5000
);

Rode em loop até afetar zero linhas, com um sleep curto entre os lotes. Cada lote é uma transação própria, então os locks são curtos e a replicação acompanha.

Passo 4, contract (depois que o código antigo foi totalmente desativado):

ALTER TABLE users ALTER COLUMN display_name SET NOT NULL;
DROP TRIGGER users_sync_display_name ON users;
DROP FUNCTION sync_display_name();
ALTER TABLE users DROP COLUMN fullname;

No PostgreSQL, SET NOT NULL varre a tabela. Em uma tabela grande, adicione primeiro CHECK (display_name IS NOT NULL) NOT VALID, depois VALIDATE CONSTRAINT (que só pega um lock fraco), e então o SET NOT NULL usa a check para pular a varredura.

Quatro passos em pelo menos duas releases para um rename. Esse é o custo de não sair do ar.

Exemplo 2: dividir uma tabela

Objetivo: mover os campos de endereço de customers para uma nova tabela customer_addresses, para que um cliente possa ter vários endereços.

Expand:

CREATE TABLE customer_addresses (
  id          bigserial PRIMARY KEY,
  customer_id bigint NOT NULL REFERENCES customers(id),
  kind        text NOT NULL DEFAULT 'primary',
  street      text,
  city        text,
  postal_code text,
  country     text,
  created_at  timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX CONCURRENTLY customer_addresses_customer_id_idx
  ON customer_addresses (customer_id);

Dual write: a aplicação escreve o endereço tanto nas colunas antigas quanto na tabela nova. As leituras vêm de customer_addresses quando existe uma linha, senão das colunas antigas.

Backfill, em lotes por faixa de id de cliente:

INSERT INTO customer_addresses (customer_id, kind, street, city, postal_code, country)
SELECT id, 'primary', street, city, postal_code, country
FROM customers
WHERE id > :last_id AND id <= :last_id + 5000
  AND street IS NOT NULL
  AND NOT EXISTS (
    SELECT 1 FROM customer_addresses a
    WHERE a.customer_id = customers.id AND a.kind = 'primary'
  );

O NOT EXISTS torna o job idempotente: se ele morrer no meio, rodar de novo não duplica linhas.

Verifique, antes de contrair: conte os clientes com endereço nas colunas antigas mas sem linha primary na tabela nova. Precisa ser zero.

Contract: mude as leituras só para a tabela nova, remova o dual write e então apague as colunas antigas em uma release posterior.

Migrações longas sem travar a produção

A fase de expand é barata quando o DDL é só de metadados. Deixa de ser barata quando o motor precisa reescrever ou varrer a tabela, ou quando um lock fica na fila atrás de uma transação longa. Estas são as operações para ficar de olho.

PostgreSQL

  • CREATE INDEX CONCURRENTLY e DROP INDEX CONCURRENTLY evitam o lock de escrita. Não podem rodar dentro de uma transação, então a maioria das ferramentas precisa de uma flag por migração para desativar o wrapper transacional (-- atlas:txmode none, autocommit_block() no Alembic, disable_ddl_transaction! no Rails). Se interrompido, o índice fica INVALID; confira pg_index.indisvalid e recrie.
  • Adicionar uma coluna com default volátil (ex.: DEFAULT now()) reescreve a tabela; um default constante não.
  • Foreign keys e check constraints: adicione com NOT VALID, depois VALIDATE CONSTRAINT separadamente.
  • Sempre defina lock_timeout (alguns segundos) na sessão da migração. Um ALTER TABLE esperando um lock ACCESS EXCLUSIVE bloqueia toda query atrás dele; um timeout faz ele falhar rápido em vez de congelar a aplicação. Tente de novo em loop.
  • Mudar o tipo de uma coluna geralmente reescreve a tabela. Prefira adicionar uma coluna nova e passar pelo expand-and-contract.

MySQL / MariaDB

  • O InnoDB suporta ALGORITHM=INSTANT para adicionar colunas e ALGORITHM=INPLACE, LOCK=NONE para muitas operações de índice. Especifique-os explicitamente para que o comando falhe caso o motor fosse cair em uma cópia.
  • Para qualquer coisa que ainda exija cópia da tabela, use uma ferramenta externa de online schema change: gh-ost (baseado em binlog, com throttling, sem triggers) ou pt-online-schema-change (baseado em triggers). Os dois criam uma tabela sombra, copiam as linhas em blocos e trocam no final. Custam disco extra e adicionam carga de replicação, então rode fora do pico e acompanhe o lag.

Qualquer motor

  • Processe migrações de dados em blocos de alguns milhares de linhas, por faixa de chave primária, uma transação por bloco.
  • Torne os jobs de backfill retomáveis e idempotentes. Guarde o último id processado.
  • Faça throttling pelo lag de replicação, não por um sleep fixo. Pause quando o lag ultrapassar o seu limite.
  • Nunca rode um backfill dentro da transação da ferramenta de migração de schema. Entregue como um job da aplicação ou um script separado, com monitoramento próprio.

Comparativo de ferramentas de migração

As ferramentas fazem duas coisas: mantêm um histórico ordenado e versionado das mudanças e aplicam as que um determinado banco ainda não viu. Fora isso, diferem em como as migrações são escritas, se detectam drift entre o schema declarado e o banco real, e como se encaixam no CI.

Ferramenta Linguagem / ecossistema Modelo de versionamento Suporte a rollback Detecção de drift Integração com CI
Flyway CLI Java, SQL-first, qualquer stack Arquivos SQL versionados, checksums Scripts de undo (plano pago) validate confere checksums aplicados, não o schema real CLI, Maven/Gradle, imagem Docker
Liquibase CLI Java, changesets XML/YAML/JSON/SQL Changelog com changesets, checksums Rollback embutido por changeset, tags diff contra um banco de referência CLI, Maven/Gradle, imagem Docker
Alembic Python / SQLAlchemy Grafo de revisões (DAG), branches downgrade por revisão autogenerate compara modelos vs banco Python, roda em qualquer pipeline
Prisma Migrate TypeScript / Node schema.prisma declarativo, histórico SQL gerado Nenhum embutido (escreva uma nova migração forward) Detecta drift em migrate dev e migrate diff migrate deploy no CI
Rails Active Record Ruby DSL Ruby com timestamp, snapshot schema.rb down por migração, DSL reversível Compara schema.rb; a gem strong_migrations aponta DDL inseguro Tasks do Rake
Django migrations Python Grafo de dependências por app, autogerado Operações reversas, migrate app 000N makemigrations --check falha o CI se faltar migração manage.py no pipeline
golang-migrate CLI e biblioteca Go, SQL-first Pares SQL up/down numerados Arquivos down Nenhuma Binário estático pequeno, imagem Docker
Atlas CLI Go, qualquer stack, HCL/SQL/ORM como fonte Declarativo ou versionado, diretório com checksum Planejado via migrate down (ciente do estado) Recurso central: schema diff, schema inspect, linting de mudanças inseguras GitHub Action, gate de lint, imagem Docker

Como ler a tabela:

  • SQL-first vs declarativo. Flyway, golang-migrate e Liquibase (em modo SQL) guardam o que você escreveu. Prisma e Atlas guardam o estado final desejado e geram o diff. O declarativo é mais rápido de escrever, mas gera um DROP COLUMN sem hesitar, então precisa de um gate de lint.
  • Suporte a rollback é menos útil do que parece. Uma down migration para ADD COLUMN funciona. Uma down migration para DROP COLUMN não consegue restaurar dados. Veja a próxima seção.
  • Detecção de drift é o recurso que mais falta nas equipes e que mais aparece em incidentes: um hotfix aplicado na mão em produção que nenhum arquivo de migração conhece. Atlas, diff do Liquibase e autogenerate do Alembic pegam isso; Flyway e golang-migrate, não.
  • Linting de DDL inseguro (Atlas lint, strong_migrations para Rails, squawk para arquivos SQL do PostgreSQL) transforma as regras da seção anterior em falhas de CI. Adicione um, seja qual for a ferramenta que você usa.

Estratégia de rollback e de testes

A posição honesta sobre rollback: mudanças de schema aditivas podem ser desfeitas, mudanças de dados geralmente não. Planeje em torno dessa assimetria.

Prefira roll-forward. Como o expand-and-contract nunca quebra a versão antiga, "rollback" na fase de expand significa refazer o deploy da versão anterior da aplicação e deixar a coluna nova no lugar. É inofensivo. Na fase de contract, rollback é uma restauração de backup, e é por isso que o contract roda por último e só depois da verificação.

Ensaie em uma cópia parecida com produção. Restaure o backup da noite anterior (anonimizado, se necessário) em uma instância descartável e rode o conjunto completo de migrações. Meça o tempo de relógio por migração e anote as que passam de alguns segundos. Fixtures sintéticas com cem linhas não vão mostrar um problema de lock; uma cópia com a contagem real de linhas e a cardinalidade real dos índices vai. Isso também serve como o teste de restauração de backup que a maioria das equipes nunca faz.

Automatize as verificações no CI, junto com o seu pipeline de CI/CD normal:

  • Banco limpo: aplique todas as migrações do zero e então rode a suíte de testes.
  • Banco existente: aplique só as migrações novas sobre um snapshot do schema atual de produção.
  • Down e up de novo para cada migração nova, para provar que o script de down pelo menos roda.
  • Lint de operações inseguras (renames, drops, NOT NULL sem default, CONCURRENTLY faltando).
  • Falhe o build se o modelo do ORM e o histórico de migrações discordarem (makemigrations --check, prisma migrate diff, atlas migrate lint).

Verifique os dados, não só o DDL. Depois de um backfill, rode queries de reconciliação: contagem de linhas de cada lado, nulls na coluna nova, foreign keys sem pai. Mantenha as queries no repositório ao lado da migração.

Acompanhe os sinais certos durante a aplicação: esperas por lock, lag de replicação, latência p99 dos endpoints mais quentes, taxa de erro. Ligue o executor de migrações ao mesmo logging e alertas da aplicação, para que abortar seja uma decisão, não um chute.

Checklist de migração em produção

Antes do merge:

  • A mudança está dividida em expand, código, backfill e contract, cada um na própria migração ou release.
  • Nenhum rename, drop, mudança de tipo ou NOT NULL em coluna que a versão atual lê.
  • Toda migração é idempotente ou protegida (IF NOT EXISTS, NOT EXISTS nos backfills).
  • Índices em tabelas grandes usam CONCURRENTLY (PostgreSQL) ou INPLACE, LOCK=NONE / gh-ost (MySQL).
  • Constraints são adicionadas como NOT VALID e validadas separadamente.
  • lock_timeout e statement_timeout definidos para a sessão da migração.
  • Backfills em lotes, retomáveis e rodando fora da transação da migração.
  • Revisado por alguém responsável pelo banco, não só pela funcionalidade.

Antes de aplicar em produção:

  • Ensaiado em uma cópia do tamanho de produção, com duração medida.
  • Backup verificado e point-in-time recovery confirmado para a janela.
  • Caminho de rollback por escrito: qual versão da aplicação refazer o deploy, qual migração reverter ou qual restauração rodar.
  • Aplicado na ordem certa em relação ao deploy: expand antes, contract depois.
  • O runbook tem os comandos exatos, os critérios de aborto e quem está de plantão.

Depois de aplicar:

  • As queries de reconciliação passam.
  • Nenhum índice inválido, nenhuma constraint sem validar deixada para trás.
  • Migração de contract agendada para uma release posterior, com ticket, para que a coluna antiga não viva para sempre.

Quando escolher cada abordagem

  • Tabela pequena, pouco tráfego, janela curta de manutenção aceitável: uma única migração com lock costuma ser mais barata que quatro passos. Meça em uma cópia primeiro; "pequena" significa que o DDL termina bem abaixo do seu lock_timeout.
  • Tabela grande ou SLA rígido: expand-and-contract com backfill em lotes, sempre. A release extra é o preço de continuar no ar.
  • MySQL com uma cópia de tabela que você não consegue evitar: gh-ost ou pt-online-schema-change, fora do pico, acompanhando o lag.
  • Índice ou constraint do PostgreSQL em uma tabela quente: CONCURRENTLY e NOT VALID / VALIDATE, com o wrapper transacional desativado para essa migração.
  • Muitos serviços em um só schema: triggers para o dual write durante a transição, porque você não consegue coordenar o deploy de todos os escritores.
  • Trocar o motor ou o host: replicação lógica ou CDC para o destino novo, dual write para verificação, cutover ensaiado com um go/no-go firme. Isso é uma migração de plataforma e merece um plano próprio.

Recomendação

Adote o expand-and-contract como padrão para toda mudança de schema, não como um procedimento especial para as grandes. Use a ferramenta de migração nativa do seu framework, se houver, e adicione ao CI um linter de DDL inseguro e uma verificação de drift, independentemente da ferramenta. Ensaie as migrações de cada release em uma cópia do tamanho de produção; é a única prática que pega problemas de lock e de duração antes dos usuários. Trate rollback como roll-forward mais backups verificados, e mantenha o passo de contract como um follow-up rastreado, para que os schemas não acumulem colunas mortas. Na Arvucore, costumamos recomendar começar pelo linter e pelo ambiente de ensaio: custam um dia para configurar e removem a maior parte do risco de toda migração daí em diante.

Pronto para Transformar seu Negócio?

Vamos conversar sobre como nossas soluções podem ajudá-lo a alcançar seus objetivos. Entre em contato com nossos especialistas hoje mesmo.

Falar com um Especialista

Tags:

migração de banco de dadosmigrações de banco de dadosversionamento de schemazero downtimeexpand and contract
Equipe Arvucore

Equipe Arvucore

A equipe editorial da Arvucore é formada por profissionais experientes em desenvolvimento de software. Somos dedicados a produzir e manter conteúdo de alta qualidade que reflete as melhores práticas da indústria e insights confiáveis.

Perguntas frequentes

O que é uma migração de banco de dados com zero downtime?
É uma mudança de schema ou de dados aplicada enquanto a aplicação continua atendendo tráfego. Funciona dividindo a mudança em passos pequenos e retrocompatíveis, de modo que a versão antiga e a nova da aplicação consigam rodar contra o mesmo banco em todos os momentos.
O que é o padrão expand-and-contract?
Expand adiciona a estrutura nova ao lado da antiga, a aplicação passa a escrever nas duas e ler da nova, os dados são preenchidos (backfill) e contract remove a estrutura antiga. Nenhum passo isolado quebra a versão em execução.
As migrações devem rodar antes ou depois do deploy da aplicação?
Migrações aditivas (expand) rodam antes do deploy, para que o código novo encontre as colunas de que precisa. Migrações destrutivas (contract) rodam depois que o código antigo foi totalmente desativado, geralmente em uma release posterior.
Dá para reverter uma migração de banco de dados?
Mudanças de schema apenas aditivas podem ser desfeitas com uma down migration. Qualquer coisa que apagou dados ou reescreveu linhas não pode ser revertida de verdade; nesses casos, conte com backups, point-in-time recovery e uma correção roll-forward. Projete as migrações para que o rollback raramente seja necessário.
Qual ferramenta de migração de banco de dados devo usar?
Use a nativa da sua stack quando existir (Rails, Django, Prisma, Alembic). Para equipes poliglotas ou SQL-first, Flyway e golang-migrate são simples e previsíveis; o Liquibase adiciona recursos de governança; o Atlas adiciona schemas declarativos, detecção de drift e linting.
Como adicionar um índice em uma tabela grande do PostgreSQL sem travá-la?
Use CREATE INDEX CONCURRENTLY fora de uma transação. Demora mais e pode deixar um índice inválido se for interrompido, então confira pg_index.indisvalid depois e recrie se necessário.