Cloud e plataforma

Estratégias de Migração de Banco de Dados para Deployments Zero-Downtime

Migrações de banco de dados não precisam significar janelas de manutenção. Aprenda padrões expand-contract, backfill e dual-write que mantêm seus sistemas funcionando enquanto mudanças de schema são implementadas.

11/03/20267 min de leituraCloud
Estratégias de Migração de Banco de Dados para Deployments Zero-Downtime

Resumo executivo

Migrações de banco de dados não precisam significar janelas de manutenção. Aprenda padrões expand-contract, backfill e dual-write que mantêm seus sistemas funcionando enquanto mudanças de schema são implementadas.

Ultima atualizacao: 11/03/2026

O problema da migração

Em modelos tradicionais de deployment, migrações de banco de dados acionam janelas de manutenção. A aplicação para, mudanças de schema são aplicadas, dados migram e a aplicação reinicia. Esta abordagem não escala mais em ambientes que requerem disponibilidade 24/7.

O desafio emerge da tensão fundamental entre mudanças de schema e código em execução. Novo código espera novo schema; código antigo roda em schema antigo. Durante a migração, ambas as versões coexistem, criando uma janela de incompatibilidade.

Migrações zero-downtime resolvem isso através de orquestração cuidadosa: deployments em fases, mudanças de schema compatíveis com versões anteriores, e estratégias de migração de dados que mantêm sistemas operacionais durante todo o processo.

O padrão expand-contract

O padrão expand-contract estrutura mudanças de schema em fases aditivas e subtrativas, garantindo compatibilidade entre versões antigas e novas de código.

Fase 1: Expand (mudanças aditivas)

Adicione novas colunas, tabelas ou índices sem remover estruturas existentes. Novo código pode usar novos recursos enquanto código antigo continua usando estruturas existentes.

sql-- Passo 1: Adicionar nova coluna com valor padrão
ALTER TABLE usuarios ADD COLUMN email_verificado BOOLEAN DEFAULT FALSE;

-- Passo 2: Criar nova tabela para novo relacionamento
CREATE TABLE preferencias_usuario (
    id SERIAL PRIMARY KEY,
    usuario_id INTEGER NOT NULL REFERENCES usuarios(id),
    preferencias JSONB NOT NULL,
    criado_em TIMESTAMP DEFAULT NOW()
);

-- Passo 3: Criar índice para novo padrão de consulta
CREATE INDEX idx_usuarios_email_verificado ON usuarios(email_verificado) WHERE email_verificado = TRUE;

Fase 2: Deploy do código da aplicação

Implante nova versão da aplicação que funciona com schemas antigo e novo. Código condicionalmente usa novos recursos baseado na disponibilidade de dados.

typescript// Código da aplicação lidando com estado de migração
class UsuarioService {
  async getPreferenciasUsuario(usuarioId: number): Promise<Preferencias> {
    // Tentar nova tabela primeiro
    const novasPrefs = await this.db.preferenciasUsuario.findUnique({
      where: { usuarioId }
    });

    if (novasPrefs) {
      return novasPrefs.preferencias;
    }

    // Fallback para estrutura antiga
    const usuario = await this.db.usuarios.findUnique({
      where: { id: usuarioId }
    });

    return usuario.preferenciasLegadas || {};
  }

  async setEmailVerificado(usuarioId: number, verificado: boolean): Promise<void> {
    // Escrever em ambas as estruturas
    await this.db.usuarios.update({
      where: { id: usuarioId },
      data: { emailVerificado: verificado }
    });

    await this.db.preferenciasUsuario.upsert({
      where: { usuarioId },
      update: { preferencias: { verificado } },
      create: { usuarioId, preferencias: { verificado } }
    });
  }
}

Fase 3: Backfill de dados

Migre dados existentes para nova estrutura enquanto o sistema permanece operacional. Use processamento em lote com tratamento de erros e monitoramento.

typescriptclass ServicoBackfillDados {
  async backfillPreferenciasUsuario(lote: number = 1000): Promise<void> {
    let offset = 0;
    let temMais = true;

    while (temMais) {
      const usuarios = await this.db.usuarios.findMany({
        take: lote,
        skip: offset,
        where: { emailVerificado: null }
      });

      if (usuarios.length === 0) {
        temMais = false;
        continue;
      }

      for (const usuario of usuarios) {
        try {
          await this.migrarPreferenciasUsuario(usuario);
          this.metrics.record('backfill_sucesso', { usuarioId: usuario.id });
        } catch (error) {
          this.metrics.record('backfill_erro', {
            usuarioId: usuario.id,
            erro: error.message
          });
          this.logger.error(`Falha ao fazer backfill do usuário ${usuario.id}`, { error });
        }
      }

      offset += lote;
      // Prevenir sobrecarregar banco de dados
      await sleep(100);
    }
  }

  private async migrarPreferenciasUsuario(usuario: any): Promise<void> {
    const prefsLegadas = await this.db.preferenciasLegadas.findUnique({
      where: { usuarioId: usuario.id }
    });

    await this.db.preferenciasUsuario.create({
      data: {
        usuarioId: usuario.id,
        preferencias: this.transformarPreferencias(prefsLegadas)
      }
    });
  }
}

Fase 4: Contract (mudanças subtrativas)

Remova colunas antigas, tabelas e índices após verificar que novo código funciona corretamente com nova estrutura.

sql-- Passo 1: Remover coluna antiga
ALTER TABLE usuarios DROP COLUMN preferencias_legadas;

-- Passo 2: Drop tabela antiga (após verificação)
DROP TABLE preferencias_usuario_legadas;

-- Passo 3: Remover índices antigos
DROP INDEX IF EXISTS idx_usuarios_lookup_legado;

Estratégia dual-write

Para migrações complexas que requerem mudanças estruturais, o padrão dual-write escreve em ambas as estruturas antiga e nova simultaneamente.

Padrão de implementação

typescriptclass ServicoDualWrite {
  private readonly janelaMigracao: Duration = { dias: 7 };
  private readonly janelaVerificacao: Duration = { dias: 3 };

  async criarUsuario(dados: DadosUsuario): Promise<Usuario> {
    // Iniciar transação para consistência
    const resultado = await this.db.$transaction(async (tx) => {
      // Escrever em estrutura antiga
      const usuarioAntigo = await tx.usuarios.create({
        data: this.mapearParaSchemaAntigo(dados)
      });

      // Escrever em estrutura nova
      const usuarioNovo = await tx.novosUsuarios.create({
        data: this.mapearParaSchemaNovo(dados)
      });

      // Registrar mapeamento para verificação
      await tx.mapeamentoMigracaoUsuario.create({
        data: {
          idAntigo: usuarioAntigo.id,
          idNovo: usuarioNovo.id,
          migradoEm: new Date()
        }
      });

      return { usuarioAntigo, usuarioNovo };
    });

    return resultado.usuarioAntigo; // Retornar estrutura antiga durante migração
  }

  async atualizarUsuario(usuarioId: number, dados: Partial<DadosUsuario>): Promise<Usuario> {
    // Encontrar ambos os IDs do mapeamento
    const mapeamento = await this.db.mapeamentoMigracaoUsuario.findUnique({
      where: { idAntigo: usuarioId }
    });

    if (!mapeamento) {
      throw new Error('Mapeamento de migração de usuário não encontrado');
    }

    // Atualizar ambas as estruturas
    await this.db.$transaction(async (tx) => {
      await tx.usuarios.update({
        where: { id: mapeamento.idAntigo },
        data: this.mapearParaSchemaAntigo(dados)
      });

      await tx.novosUsuarios.update({
        where: { id: mapeamento.idNovo },
        data: this.mapearParaSchemaNovo(dados)
      });
    });

    return this.db.usuarios.findUnique({ where: { id: usuarioId } });
  }
}

Verificação e cutover

typescriptclass ServicoVerificacaoMigracao {
  async verificarIntegridadeMigracao(): Promise<RelatorioVerificacao> {
    const totalRegistros = await this.db.mapeamentoMigracaoUsuario.count();
    const erros: ErroMigracao[] = [];

    // Verificação de amostra: verificar 10% dos registros
    const tamanhoAmostra = Math.ceil(totalRegistros * 0.1);
    const amostras = await this.db.mapeamentoMigracaoUsuario.findMany({
      take: tamanhoAmostra,
      orderBy: { criadoEm: 'desc' }
    });

    for (const amostra of amostras) {
      const registroAntigo = await this.db.usuarios.findUnique({
        where: { id: amostra.idAntigo }
      });

      const registroNovo = await this.db.novosUsuarios.findUnique({
        where: { id: amostra.idNovo }
      });

      const diff = this.compararRegistros(registroAntigo, registroNovo);
      if (!diff.match) {
        erros.push({
          usuarioId: amostra.idAntigo,
          diferencas: diff.diferencas,
          severidade: this.avaliarSeveridade(diff)
        });
      }
    }

    return {
      totalRegistros,
      tamanhoAmostra,
      qtdErros: erros.length,
      taxaErro: erros.length / tamanhoAmostra,
      erros: erros.filter(e => e.severidade === 'critico'),
      avisos: erros.filter(e => e.severidade !== 'critico')
    };
  }

  private compararRegistros(antigo: any, novo: any): { match: boolean; diferencas: string[] } {
    const diferencas: string[] = [];

    // Comparar todos os campos mapeados
    const mapaCampos = {
      'email': 'email',
      'nome': 'nomeCompleto',
      'criadoEm': 'timestampCriacao'
    };

    for (const [campoAntigo, campoNovo] of Object.entries(mapaCampos)) {
      if (antigo[campoAntigo] !== novo[campoNovo]) {
        diferencas.push(`${campoAntigo} (${antigo[campoAntigo]}) !== ${campoNovo} (${novo[campoNovo]})`);
      }
    }

    return {
      match: diferencas.length === 0,
      diferencas
    };
  }
}

Backfill com limitação de taxa

Backfills de dados grandes podem sobrecarregar bancos de dados. Limitação de taxa e processamento em lote garantem que migração não impacte performance de produção.

typescriptclass ServicoBackfillLimitado {
  private readonly rateLimiter: TokenBucket;

  constructor(private config: ConfiguracaoBackfill) {
    // Token bucket: permite bursts até burstCapacity, depois taxa sustentada
    this.rateLimiter = new TokenBucket({
      capacidade: config.capacidadeBurst || 100,
      taxaReposicao: config.requisicoesPorSegundo || 10
    });
  }

  async backfillDatasetGrande(consulta: any, transformador: (row: any) => any): Promise<void> {
    let cursor: string | null = null;
    let totalProcessado = 0;

    while (cursor !== 'DONE') {
      // Esperar por rate limiter
      await this.rateLimiter.waitForToken();

      // Buscar lote
      const lote = await this.db.query(consulta, {
        cursor,
        limit: this.config.tamanhoLote || 100
      });

      if (lote.length === 0) {
        cursor = 'DONE';
        continue;
      }

      // Processar lote
      const resultados = await Promise.allSettled(
        lote.map(row => this.processarLinha(row, transformador))
      );

      // Rastrear resultados
      const sucesso = resultados.filter(r => r.status === 'fulfilled').length;
      const falha = resultados.filter(r => r.status === 'rejected').length;

      totalProcessado += sucesso;

      this.metrics.record('backfill_lote', {
        processado: sucesso,
        falha,
        total: lote.length
      });

      // Verificar saúde antes de continuar
      if (await this.devePausarParaSaude()) {
        await this.esperarAteSaudavel();
      }

      cursor = lote[lote.length - 1].cursor;
    }
  }

  private async devePausarParaSaude(): Promise<boolean> {
    const metricas = await this.getMetricasBancoDados();

    return (
      metricas.cpu > this.config.maxCpu ||
      metricas.memoria > this.config.maxMemoria ||
      metricas.conexoes > this.config.maxConexoes
    );
  }
}

Integridade transacional

Migrações devem manter consistência de dados mesmo quando falhas ocorrem.

typescriptclass ServicoMigracaoTransacional {
  async migrarComRollback(
    idMigracao: string,
    operacoes: OperacaoMigracao[]
  ): Promise<ResultadoMigracao> {
    const registroMigracao = await this.db.registroMigracao.create({
      data: {
        id: idMigracao,
        status: 'EM_PROGRESSO',
        iniciadoEm: new Date()
      }
    });

    try {
      for (const operacao of operacoes) {
        await this.executarOperacao(operacao);
        await this.registrarProgressoOperacao(idMigracao, operacao.id, 'CONCLUIDO');
      }

      await this.db.registroMigracao.update({
        where: { id: idMigracao },
        data: {
          status: 'CONCLUIDO',
          concluidoEm: new Date()
        }
      });

      return { sucesso: true, idMigracao };
    } catch (error) {
      // Rollback de operações concluídas
      await this.rollbackMigracao(idMigracao, operacoes);

      await this.db.registroMigracao.update({
        where: { id: idMigracao },
        data: {
          status: 'FALHOU',
          falhouEm: new Date(),
          erro: error.message
        }
      });

      throw new ErroMigracaoFalhou(`Migração ${idMigracao} falhou`, { causa: error });
    }
  }

  private async rollbackMigracao(
    idMigracao: string,
    operacoes: OperacaoMigracao[]
  ): Promise<void> {
    // Rollback em ordem inversa
    const operacoesConcluidas = operacoes.filter(o => o.status === 'CONCLUIDO').reverse();

    for (const operacao of operacoesConcluidas) {
      try {
        await this.executarRollback(operacao);
        await this.registrarProgressoRollback(idMigracao, operacao.id);
      } catch (erroRollback) {
        this.logger.error(`Falha ao rollback ${operacao.id}`, { error: erroRollback });
      }
    }
  }
}

Armadilhas comuns de migração

Armadilha 1: Assumir cutover imediato

Problema: Assumir que todos os backfills de dados são instantâneos e cutover pode acontecer imediatamente.

Consequências: Cutover falha porque backfill não completou ou dados são inconsistentes.

Solução: Monitorar progresso do backfill, verificar integridade de dados e permitir janela de verificação antes do cutover.

Armadilha 2: Esquecer sobre índices

Problema: Adicionar colunas sem índices resulta em performance ruim de consulta.

Consequências: Performance degradada após migração, timeouts, falhas em cascata.

Solução: Adicionar índices como parte da fase expand, monitorar performance de consultas.

Armadilha 3: Mudar tipos de dados no lugar

Problema: ALTER TABLE que muda tipo de dados trava a tabela inteira.

Consequências: Lock de tabela bloqueia todas as escritas, causando falhas de aplicação.

Solução: Usar expand-contract: adicionar nova coluna, backfill dados, migrar leituras/escritas, então dropar coluna antiga.

Armadilha 4: Ignorar restrições de chave estrangeira

Problema: Adicionar chaves estrangeiras requer lock na tabela referenciada.

Consequências: Lock bloqueia escritas, potencial downtime.

Solução: Adicionar chaves estrangeiras sem restrição NOT NULL primeiro, backfill dados, então adicionar restrição.

Checklist de migração

Antes de executar migração zero-downtime:

Fase de planejamento

  • [ ] Documentar schema atual e schema alvo
  • [ ] Identificar todas as versões da aplicação acessando a tabela
  • [ ] Mapear passos expand-contract
  • [ ] Projetar estratégia de backfill com limitação de taxa
  • [ ] Planejar procedimento de rollback
  • [ ] Configurar monitoramento e alertas

Fase expand

  • [ ] Deploy de mudanças de schema (apenas aditivas)
  • [ ] Verificar que nova estrutura existe
  • [ ] Deploy de código de aplicação compatível com ambos os schemas
  • [ ] Monitorar erros e impacto de performance

Fase backfill

  • [ ] Executar migração de dados com limitação de taxa
  • [ ] Monitorar métricas de saúde do banco de dados
  • [ ] Verificar integridade de dados com amostras
  • [ ] Rastrear progresso e ETA

Fase de verificação

  • [ ] Comparar conjuntos de dados antigo e novo
  • [ ] Verificar funcionalidade da aplicação com novo schema
  • [ ] Teste de carga com nova estrutura
  • [ ] Revisar logs de erros para problemas de migração

Fase contract

  • [ ] Deploy de aplicação usando apenas novo schema
  • [ ] Monitorar problemas com dependências antigas
  • [ ] Remover schema antigo (drop colunas, tabelas, índices)
  • [ ] Limpar dados de migração e metadados

Recomendações de ferramentas

Ferramentas de migração de banco de dados

FerramentaMelhor paraTrade-offs
LiquibaseCompatibilidade cross-databaseConfiguração XML verbosa
FlywaySimples, migrations baseadas em versãoSuporte limitado de rollback
AlembicEcossistemas PythonDependência de SQLAlchemy
dbmateSimples, agnóstico de bancoMenos recursos avançados
Prisma MigrateMigrations type-safeAcoplamento com ORM

Ferramentas de verificação

bash# Usar sqldiff para comparação de schema
sqldiff schema_antigo.sql schema_novo.sql

# Usar pg_restore --list para verificar backups
pg_restore --list backup.dump

# Usar pgbench para teste de carga
pgbench -h localhost -p 5432 -U postgres -d producao -c 10 -j 2 -T 300

Conclusão

Migrações zero-downtime de banco de dados requerem planejamento cuidadoso, execução em fases e verificação contínua. O padrão expand-contract fornece uma abordagem estruturada: adicionar mudanças, deploy de código compatível, migrar dados, verificar e então remover estruturas antigas.

O insight principal é tratar migrações como um processo, não um evento. Mudanças de banco de dados abrangem fases de deployment, não uma única janela de manutenção. Projetando para compatibilidade durante migração, você mantém disponibilidade enquanto evolui seu modelo de dados.

Comece com migrações menores em tabelas não-críticas. Construa memória muscular com padrões expand-contract, backfill e verificação. Conforme sua equipe ganha confiança, aplique estas técnicas a mudanças de schema maiores e mais complexas.


Sua equipe precisa de ajuda planejando e executando migrações zero-downtime de banco de dados? Fale com especialistas em engenharia da Imperialis sobre estratégia de migração, suporte de implementação e ferramentas de migração de nível de produção para seus sistemas críticos.

Fontes

Leituras relacionadas