Blog

Migração de banco sem janela: expandir, migrar e contrair sem derrubar escrita

A janela de manutenção pedida era de quarenta minutos num domingo de madrugada, e a resposta do time de negócio foi que o sistema atende clientes em três fusos horários e não existe madrugada para todo mundo ao mesmo tempo. É a situação normal, não a exceção: a maioria dos bancos em produção hoje não tem hora morta. A saída não é escolher um horário menos ruim, é mudar a forma da migração para que ela nunca precise de exclusividade. Este artigo mostra por que o padrão expandir, migrar e contrair funciona, qual é a regra de compatibilidade que decide a ordem de cada deploy, por que o backfill precisa ser um trabalho retomável e não uma transação gigante, o que muda quando o banco reescreve a tabela inteira sob um lock que ninguém pediu, como a escrita dupla se torna verificável em vez de esperançosa, e qual é o único momento em que a contração deixa de ser reversível.

2026-08-25 / Arquitetura / 16 min

01

A janela não some, ela é trocada por compatibilidade

A migração com janela é simples por um motivo específico: durante a parada existe apenas uma versão do código e uma versão do schema, e por isso o schema pode mudar de forma incompatível sem consequência. Tirar a janela não elimina esse problema, apenas obriga a resolvê-lo de outro jeito. Sem parada, existe pelo menos um intervalo em que a versão antiga e a versão nova do código conversam com o mesmo banco ao mesmo tempo, seja porque o deploy é gradual, seja porque um pod demora a terminar de drenar, seja porque um worker de fila só reinicia quando termina o lote atual.

Daí sai a regra que governa tudo o que vem depois e que é o verdadeiro conteúdo do padrão: cada deploy precisa ser compatível com o deploy imediatamente anterior, tanto no schema quanto no código. Não é compatibilidade com a versão de seis meses atrás, o que seria caro demais, e não é compatibilidade só com a versão final, o que seria justamente o erro. É a vizinhança de dois passos. Uma vez aceita essa restrição, o número de passos deixa de ser negociável: adicionar uma coluna nova, preencher a coluna, passar a ler dela e só então remover a antiga são quatro deploys porque nenhum par consecutivo entre eles quebra, e não porque alguém gosta de burocracia.

MIGRACAO COM JANELA (uma versao viva de cada vez)

  [app v1 + schema v1]  --- PARADA ---  [app v2 + schema v2]
                          40 min sem
                          atender ninguem


EXPANDIR / MIGRAR / CONTRAIR (duas versoes vivas por deploy)

  D1 expandir   schema aceita v1 e v2      app v1 roda intacto
                (coluna nova, anulavel)

  D2 escrita    app escreve nos dois       leitura ainda no antigo
     dupla      campos                     backfill roda em lotes

  D3 leitura    app le do campo novo       escrita continua dupla
                                           rollback = trocar a flag

  D4 contrair   app para de escrever no    coluna antiga sem leitor
                antigo, coluna removida    ponto sem volta

  ^ em nenhum instante existe um par (app, schema) incompativel

Vale nomear o custo dessa escolha em vez de escondê-lo, porque ele é real e aparece no planejamento de sprint. Uma mudança que caberia em um pull request vira quatro, espalhados por dias ou semanas, e nesse meio tempo o código carrega uma complexidade temporária que precisa ser removida depois. Times que ignoram a última etapa acumulam colunas mortas, escritas duplas esquecidas e flags permanentes, e o preço disso é pago em toda leitura futura daquele arquivo. A contração não é opcional, é a metade da migração que ninguém agenda.

02

Expandir sem travar a tabela

A etapa de expansão parece a mais inofensiva e é onde acontecem os incidentes mais rápidos, porque a mesma instrução DDL tem custos radicalmente diferentes dependendo de um detalhe que não aparece na sintaxe. Em Postgres moderno, adicionar uma coluna anulável ou com valor padrão constante é uma mudança apenas no catálogo e termina em milissegundos. Adicionar a mesma coluna com NOT NULL sem valor padrão, ou com um valor padrão volátil, força a reescrita da tabela inteira sob um lock exclusivo, e numa tabela de duzentos milhões de linhas isso significa que toda leitura e toda escrita param até terminar.

Há um segundo efeito que costuma pegar os times de surpresa e que é pior que a lentidão em si. O lock exclusivo não é adquirido só ao final: o comando entra na fila de locks e passa a bloquear todas as consultas que chegam depois dele, mesmo que ele próprio ainda esteja esperando uma transação antiga terminar. Uma migração que parecia rápida fica presa atrás de um SELECT de relatório que roda há três minutos, e enquanto isso o tráfego normal se enfileira atrás da migração. O sintoma é uma tabela indisponível por causa de um comando que, sozinho, levaria dez milissegundos.

OperaçãoCusto real em PostgresRisco sem cuidadoForma segura
Adicionar coluna anulávelCatálogo, milissegundosBaixo, ainda pega lock breveDireto, com lock_timeout curto
Adicionar coluna NOT NULLReescrita da tabela inteiraAlto: tabela travada por minutosAnulável, backfill, constraint NOT VALID e depois VALIDATE
Criar índiceBloqueia escrita durante a construçãoAlto em tabela quenteCREATE INDEX CONCURRENTLY, fora de transação
Adicionar chave estrangeiraVarredura completa das duas tabelasAlto: lock em ambasNOT VALID no primeiro passo, VALIDATE CONSTRAINT depois
Renomear colunaCatálogo, instantâneoCrítico: quebra a versão antiga do appNunca renomear, criar nova e contrair depois

A última linha é a que mais causa incidente porque parece a mais barata. Renomear uma coluna custa nada para o banco e quebra imediatamente qualquer instância do código antigo que ainda esteja rodando, o que inclui a instância que está drenando enquanto o deploy acontece. Em um rollout gradual, isso significa erro em produção durante toda a janela de convivência. A regra prática é que renomear é sempre a soma de expandir e contrair, e tratar assim desde o começo evita a pior categoria de erro, que é aquela que só aparece durante o deploy e some quando ele termina.

-- migrations/001_expand.sql
-- Expandir: o schema passa a aceitar as duas versoes do app.
-- Nada aqui pode exigir que o codigo novo ja esteja rodando.

-- Sem lock_timeout, uma migracao que esperaria 10ms fica presa atras de
-- uma transacao longa e enfileira todo o trafego atras dela. Falhar rapido
-- e tentar de novo e melhor do que travar a tabela por minutos.
SET lock_timeout = '3s';
SET statement_timeout = '30s';

-- Coluna anulavel: mudanca de catalogo, milissegundos, nao reescreve a tabela.
-- Com NOT NULL e sem default, o Postgres reescreveria as 200M de linhas
-- sob lock exclusivo.
ALTER TABLE conversations
  ADD COLUMN customer_uuid uuid;

-- Indice concorrente nao bloqueia escrita, mas nao roda dentro de
-- transacao. Se o framework de migracao envolve tudo em BEGIN/COMMIT,
-- este comando precisa de um arquivo proprio marcado como nao transacional.
-- Em caso de falha, o indice fica INVALID e precisa ser derrubado a mao
-- antes de tentar de novo.
CREATE INDEX CONCURRENTLY IF NOT EXISTS conversations_customer_uuid_idx
  ON conversations (customer_uuid);

-- A constraint entra como NOT VALID: passa a valer para linha nova e
-- atualizada sem varrer as antigas, o que evita o lock longo. A validacao
-- das linhas existentes vem depois do backfill, em outro deploy.
ALTER TABLE conversations
  ADD CONSTRAINT conversations_customer_uuid_not_null
  CHECK (customer_uuid IS NOT NULL) NOT VALID;

03

Escrita dupla que é verificável e não apenas esperançosa

A escrita dupla é o coração da migração e é onde a maioria dos times para no meio do caminho. A parte fácil é fazer o código gravar nos dois lugares. A parte que decide se a migração vai dar certo é conseguir provar, antes de trocar a leitura, que os dois lugares concordam. Sem essa prova, a virada de leitura é um salto de fé: se houver qualquer caminho de escrita que escapou, um job antigo, uma rotina de importação, um gatilho no banco, a divergência só aparece depois que o campo novo já virou a fonte da verdade, e aí o dado errado já está sendo servido para o cliente.

A forma de tornar isso verificável tem duas metades. A primeira é escrever nos dois campos dentro da mesma transação, e não em duas operações separadas, para que nunca exista um estado em que um foi gravado e o outro não por causa de uma falha no meio. A segunda é ler dos dois durante uma fase de sombra, comparar e contar a divergência como métrica em vez de exceção. Comparar e não usar o resultado parece inútil, mas é exatamente esse contador que autoriza ou bloqueia o deploy seguinte, porque ele responde à única pergunta que importa: existe algum caminho de escrita que ainda não foi coberto?

// src/repositories/conversation-repository.js
// Escrita dupla e leitura em sombra. A leitura em sombra existe para
// autorizar o deploy seguinte com dado, nao com confianca.

export const createConversationRepository = ({ db, flags, metrics }) => {
  const save = async (conversation) => {
    // Os dois campos na MESMA transacao. Em duas operacoes separadas,
    // uma falha no meio deixa os campos divergentes de forma permanente
    // e sem nenhum registro de que isso aconteceu.
    await db.transaction(async (trx) => {
      await trx('conversations')
        .insert({
          id: conversation.id,
          customer_id: conversation.customerId, // legado: int
          customer_uuid: conversation.customerUuid, // novo: uuid
        })
        .onConflict('id')
        .merge(['customer_id', 'customer_uuid']);
    });
  };

  const findByCustomer = async (customer) => {
    const readFromNew = flags.enabled('conversations.read_uuid');

    // Fase de sombra: le dos dois e compara sem usar o resultado novo.
    // O contador de divergencia e o que autoriza a virada da leitura.
    if (!readFromNew && flags.enabled('conversations.shadow_read')) {
      const [legacy, next] = await Promise.all([
        db('conversations').where({ customer_id: customer.id }).orderBy('id'),
        db('conversations').where({ customer_uuid: customer.uuid }).orderBy('id'),
      ]);

      // Comparar por conjunto de ids, nao por contagem: contagens iguais
      // com ids diferentes e o caso que passa despercebido.
      const legacyIds = new Set(legacy.map((row) => row.id));
      const matches =
        legacy.length === next.length && next.every((row) => legacyIds.has(row.id));

      metrics.increment('conversations.shadow_read', {
        result: matches ? 'match' : 'divergent',
      });

      return legacy;
    }

    return readFromNew
      ? db('conversations').where({ customer_uuid: customer.uuid }).orderBy('id')
      : db('conversations').where({ customer_id: customer.id }).orderBy('id');
  };

  return { save, findByCustomer };
};
  • A divergência precisa ser rotulada por caminho de escrita, não apenas contada em agregado. Saber que existem 0,3% de divergências não ajuda; saber que todas vêm do importador de CSV resolve o problema em uma tarde.
  • A leitura em sombra dobra a carga de leitura naquela consulta específica. Em tabela quente vale amostrar uma fração do tráfego em vez de comparar tudo, desde que a amostra cubra todos os caminhos de escrita.
  • Gatilhos e views no banco também são caminhos de escrita e são invisíveis para quem procura apenas no código da aplicação. Vale listar as dependências da tabela antes de assumir que o mapeamento está completo.
  • A ordem entre backfill e escrita dupla não é indiferente: a escrita dupla entra primeiro, senão o backfill preenche linhas antigas enquanto as novas continuam nascendo sem o campo, e o trabalho nunca converge.
  • A flag de leitura precisa ser avaliada por requisição e não lida uma vez na inicialização, senão o rollback exige um novo deploy e deixa de ser instantâneo justamente no momento em que a velocidade importa.

04

Backfill como trabalho retomável, não como transação gigante

O backfill em uma única instrução UPDATE sobre a tabela toda é a forma mais direta de transformar uma migração sem janela em uma parada não planejada. Uma transação que percorre duzentos milhões de linhas mantém locks de linha durante todo o tempo, faz o volume de WAL explodir, impede o vacuum de limpar versões mortas e, se for cancelada no minuto quarenta, desfaz tudo e volta ao ponto zero. Pior: enquanto ela roda, a replicação atrasa, e um atraso de réplica grande costuma ser o primeiro sintoma percebido pelo usuário, porque as leituras que vão para a réplica passam a devolver dado velho.

A forma correta trata o backfill como um trabalho em lotes com estado próprio, capaz de parar e retomar de onde estava. Três propriedades tornam isso seguro na prática. O lote precisa avançar por chave e não por deslocamento, porque OFFSET fica progressivamente mais lento e pula linhas quando há remoções concorrentes. Cada lote precisa ser uma transação própria e curta, para que o cancelamento custe no máximo um lote. E o ritmo precisa ser adaptativo, reagindo ao atraso de replicação, porque é ele que traduz a pressão do backfill em impacto visível para o cliente.

// scripts/backfill-customer-uuid.js
// Backfill retomavel: lotes por chave, transacao curta por lote e ritmo
// que reage ao atraso de replicacao.

const BATCH_SIZE = 2_000;
const MAX_REPLICA_LAG_MS = 1_000;

// Cursor por chave, nao OFFSET: com OFFSET o banco percorre e descarta as
// linhas anteriores a cada lote, entao o lote 5000 le 10M de linhas para
// devolver 2k. Alem disso, remocoes concorrentes deslocam a janela e o
// OFFSET pula linhas silenciosamente.
export const runBackfill = async ({ db, checkpoint, logger, sleep, replicaLagMs }) => {
  let cursor = await checkpoint.read('conversations.customer_uuid');
  let processed = 0;

  for (;;) {
    const lag = await replicaLagMs();
    if (lag > MAX_REPLICA_LAG_MS) {
      // Nao aborta: desacelera. O backfill e trabalho de fundo e sempre
      // perde a prioridade para o trafego do cliente.
      logger.warn({ lag }, 'replica lag alto, pausando backfill');
      await sleep(5_000);
      continue;
    }

    // Uma transacao por lote. Um UPDATE unico sobre a tabela inteira
    // mantem locks por horas, infla o WAL, bloqueia o vacuum e, se for
    // cancelado no minuto 40, desfaz tudo.
    const updated = await db.transaction(async (trx) => {
      const rows = await trx('conversations')
        .select('id', 'customer_id')
        .where('id', '>', cursor)
        .whereNull('customer_uuid')
        .orderBy('id')
        .limit(BATCH_SIZE)
        // FOR UPDATE SKIP LOCKED: se o trafego normal esta editando uma
        // linha, o backfill pula em vez de esperar. A linha volta no
        // proximo passe porque a condicao whereNull continua valendo.
        .forUpdate()
        .skipLocked();

      if (rows.length === 0) return [];

      await trx.raw(
        `UPDATE conversations c
            SET customer_uuid = m.uuid
           FROM customer_uuid_map m
          WHERE c.customer_id = m.customer_id
            AND c.id = ANY(?)`,
        [rows.map((row) => row.id)],
      );

      return rows;
    });

    if (updated.length === 0) break;

    // O checkpoint avanca DEPOIS do commit. Avancar antes significa pular
    // um lote inteiro se o processo morrer entre as duas operacoes.
    cursor = updated[updated.length - 1].id;
    await checkpoint.write('conversations.customer_uuid', cursor);

    processed += updated.length;
    logger.info({ processed, cursor }, 'lote concluido');

    // Pausa curta e deliberada: da espaco para o vacuum e evita que o
    // backfill monopolize a conexao e a banda de WAL.
    await sleep(50);
  }

  logger.info({ processed }, 'backfill concluido');
  return processed;
};

Um detalhe do código acima merece destaque porque é a diferença entre um backfill que termina e um que precisa ser reiniciado do zero na segunda-feira: o checkpoint é gravado depois do commit do lote, nunca antes. Se o processo morre entre o commit e a escrita do checkpoint, o pior caso é reprocessar um lote já feito, o que é inofensivo porque a condição de filtro exclui as linhas já preenchidas. Se a ordem fosse invertida, o pior caso seria pular um lote inteiro sem que nada registrasse o buraco, e a divergência só apareceria muito depois, na forma de linhas com o campo novo vazio que ninguém sabe explicar.

05

Contrair é o único passo sem volta

Até a virada da leitura, tudo é reversível trocando uma flag: a coluna antiga continua sendo escrita e continua correta, então voltar a ler dela é instantâneo. A contração rompe essa propriedade. No momento em que o código para de escrever no campo antigo, ele começa a envelhecer, e cada minuto que passa aumenta o custo de um eventual rollback, porque não basta mais trocar a flag, é preciso reconciliar tudo o que mudou desde então. Remover a coluna transforma o custo em impossibilidade.

Por isso a contração se divide em dois deploys separados por um período de observação, e não em um só. O primeiro para a escrita dupla e mantém a coluna antiga no lugar, intocada. Esse é o intervalo em que qualquer consumidor esquecido, um relatório mensal, um job de fechamento, uma integração externa, tem chance de falhar e ser notado enquanto a volta ainda é barata. O segundo deploy remove a coluna, e é o único passo da sequência inteira que não tem plano de rollback além de restaurar backup.

DeployMuda no schemaMuda no códigoRollback
D1 expandirColuna nova anulável, índice concorrente, constraint NOT VALIDNadaDerrubar a coluna, sem impacto no app
D2 escrita duplaNadaGrava nos dois campos na mesma transação, sombra de leituraDesligar a flag de escrita dupla
D3 backfill e validaçãoVALIDATE CONSTRAINT ao finalNada, roda fora do deployParar o job, nada foi trocado
D4 virar a leituraNadaPassa a ler do campo novo, escrita continua duplaDesligar a flag de leitura, instantâneo
D5 parar escrita antigaNadaRemove a escrita no campo legadoReversível, mas exige reconciliar o período
D6 remover colunaDROP COLUMNRemove código morto e flagsNenhum: só restaurar backup

Antes do último deploy vale um passo de verificação que custa pouco e evita o incidente mais constrangedor da lista. Marcar a coluna como ignorada no ORM e observar por alguns dias captura o consumidor que ninguém lembrava, porque o código deixa de acessá-la sem que ela desapareça. Complementarmente, ativar o log de instruções que referenciam a coluna, ou consultar as estatísticas de uso do índice associado, mostra se ainda existe tráfego real chegando nela. Descobrir que a coluna ainda é lida por um serviço de terceiros é constrangedor na segunda-feira e é um incidente de dados no sábado seguinte.

06

O teste que prova a compatibilidade entre versões vizinhas

A propriedade que a migração precisa garantir não é que a versão nova funciona, isso os testes normais já cobrem. É que a versão antiga continua funcionando contra o schema novo, e que a versão nova funciona contra o schema antigo caso o rollback aconteça antes da migração ser revertida. Nenhuma suíte convencional testa isso, porque cada suíte roda uma versão do código contra a versão correspondente do schema. A verificação precisa ser explícita e é barata de montar: aplicar a migração de expansão em um banco de teste e rodar a suíte da versão anterior contra ele.

// test/migration-compatibility.test.js
// A suite normal testa app novo + schema novo. O que quebra em producao
// e o par que ninguem testa: app ANTIGO + schema NOVO, que existe durante
// todo rollout gradual e toda drenagem de pod.

import { describe, it, beforeAll, expect } from 'vitest';
import { applyMigrationsUpTo, resetDatabase } from './helpers/db.js';
import { createConversationRepository } from '../src/repositories/conversation-repository.js';

// Repositorio da versao anterior, congelado. Nao importar o atual: o teste
// perde todo o valor no dia em que os dois convergirem.
import { createLegacyRepository } from './fixtures/repository-v1.js';

describe('compatibilidade entre deploys vizinhos', () => {
  beforeAll(async () => {
    await resetDatabase();
    await applyMigrationsUpTo('001_expand');
  });

  it('a versao anterior do app opera sobre o schema expandido', async () => {
    const legacy = createLegacyRepository({ db });

    // O app antigo nao conhece customer_uuid. Se a coluna fosse NOT NULL
    // sem default, este insert falharia e o rollout quebraria em producao
    // no primeiro pod que ainda nao tivesse reiniciado.
    await legacy.save({ id: 'conv-1', customerId: 42 });

    const found = await legacy.findByCustomer({ id: 42 });
    expect(found).toHaveLength(1);
  });

  it('a escrita dupla mantem os dois campos consistentes', async () => {
    const repo = createConversationRepository({
      db,
      flags: { enabled: (name) => name === 'conversations.shadow_read' },
      metrics: { increment: () => {} },
    });

    await repo.save({ id: 'conv-2', customerId: 43, customerUuid: UUID_43 });

    const [row] = await db('conversations').where({ id: 'conv-2' });
    expect(row.customer_id).toBe(43);
    expect(row.customer_uuid).toBe(UUID_43);
  });

  it('a leitura em sombra acusa divergencia quando um caminho escapa', async () => {
    const divergences = [];
    const repo = createConversationRepository({
      db,
      flags: { enabled: (name) => name === 'conversations.shadow_read' },
      metrics: { increment: (_, tags) => divergences.push(tags.result) },
    });

    // Escrita que ignorou o campo novo, como faria um job legado ou um
    // gatilho no banco. O contador precisa registrar, nao lancar excecao.
    await db('conversations').insert({ id: 'conv-3', customer_id: 44 });

    await repo.findByCustomer({ id: 44, uuid: UUID_44 });
    expect(divergences).toContain('divergent');
  });
});

A terceira asserção é a que costuma faltar e a que mais paga por si mesma. Ela verifica que uma escrita que escapou da aplicação, exatamente o cenário do job legado ou do gatilho esquecido, produz um sinal de divergência em vez de uma exceção. A distinção importa porque durante a migração a divergência é esperada e informativa: ela é o mapa dos caminhos de escrita que ainda faltam cobrir. Transformá-la em erro faria o time desligar a verificação por ruído justamente na fase em que ela é a única fonte de informação confiável sobre o estado real da migração.

FAQ

Perguntas frequentes

Quanto tempo manter a escrita dupla antes de contrair?

O critério não é tempo de calendário, é cobertura de ciclos. A escrita dupla precisa sobreviver a pelo menos uma execução completa de cada processo que toca aquela tabela, e o processo mais lento é quem define o prazo. Se existe um fechamento mensal que lê a coluna, o mínimo é um mês mais uma margem, porque só nessa execução é que um consumidor esquecido se revela. Se o mais lento é um job semanal, duas semanas cobrem com folga. Três sinais autorizam a contração e nenhum deles é a passagem do tempo isolada: a taxa de divergência da leitura em sombra em zero durante todo o período, incluindo os dias de pico e os dias de fechamento; a estatística de uso do índice antigo estável, mostrando que ninguém consulta mais por aquele caminho; e a lista de dependências da tabela revisada à mão, incluindo views, gatilhos, funções e integrações externas, que não aparecem em busca no código da aplicação. A pressa aqui tem um custo assimétrico e é isso que deve orientar a decisão: esperar mais uma semana custa uma semana de complexidade temporária no código, enquanto contrair cedo demais custa um incidente de dados com reconciliação manual e sem rollback barato.

Como fazer isso quando a mudança é o tipo da coluna e não uma coluna nova?

A mudança de tipo é o caso em que a tentação de usar ALTER COLUMN TYPE é maior e o resultado é pior, porque esse comando reescreve a tabela inteira sob lock exclusivo e ainda invalida os planos em cache, de forma que o impacto se estende para depois do término. O padrão continua sendo o mesmo, apenas com uma etapa extra de conversão. Cria-se uma coluna nova com o tipo alvo, a aplicação passa a escrever nas duas convertendo em código, o backfill preenche o histórico em lotes e a leitura vira quando a divergência estiver em zero. A etapa extra aparece nos casos em que a conversão não é total, que é a maioria: passar de integer para bigint é seguro, mas de texto livre para enum, de timestamp sem fuso para timestamptz, ou de decimal para inteiro em centavos, cada um tem linhas que não convertem. Essas linhas precisam ser levantadas antes de começar, porque a decisão sobre elas é de negócio e não de engenharia, e descobri-las no meio do backfill significa parar o trabalho para tomar uma decisão que ninguém tem autoridade para tomar às duas da manhã. Vale registrar que em Postgres alguns casos específicos evitam a reescrita, como aumentar o limite de um varchar, mas depender disso exige verificar o comportamento da versão exata em uso, e o plano seguro não deve depender de uma otimização condicional do motor.

O padrão vale também para bancos sem schema fixo, como MongoDB?

Vale, e por um motivo que costuma ser mal compreendido: a ausência de schema no banco não elimina o schema, apenas o transfere para o código, que passa a ser o único lugar onde ele existe. Como o banco aceita documentos com formatos diferentes sem reclamar, a etapa de expansão fica de graça, mas a etapa de contração fica mais difícil, porque não há um comando que garanta que nenhum documento antigo restou. Na prática o padrão fica assim: o código passa a ler os dois formatos e escrever no novo, um backfill em lotes reescreve os documentos antigos usando o mesmo cuidado de cursor por chave e ritmo adaptativo, e a leitura do formato antigo só é removida depois que uma contagem confirma que nenhum documento com o formato legado restou na coleção. A diferença prática mais relevante é que o momento de remover o código de leitura antiga precisa ser decidido por consulta de verificação e não por confiança no backfill, porque não existe constraint que impeça um caminho esquecido de gravar no formato antigo no dia seguinte. Vale também usar o validador de schema da coleção depois da contração, para que o formato consolidado passe a ser aplicado pelo banco em vez de permanecer apenas como convenção.

A migração segura é a que nunca precisa de exclusividade

Um banco que atende clientes em três fusos horários não tem madrugada, e insistir em encontrar a janela menos ruim é resolver o problema errado. Expandir, migrar e contrair troca a exclusividade por compatibilidade entre deploys vizinhos, e o preço disso é um número maior de passos, cada um deles reversível até o último. Posso desenhar a sequência de migração do seu banco, definir quais operações exigem índice concorrente e constraint em duas fases, montar o backfill retomável com controle de atraso de replicação, instrumentar a leitura em sombra que autoriza a virada com dado em vez de confiança e definir os critérios objetivos para contrair sem deixar coluna morta nem flag permanente.