As Três Fases de Movimentação de Dados Online, Lidas Como Três Invariantes
Todo diagrama de migração online que vi se parece com o mesmo — três setas entre duas caixas — e pula a parte que importa: para que cada fase realmente serve. Estas são minhas anotações depois de reler os escritos da Stripe sobre DocDB junto com a documentação de cutover do gh-ost e do Vitess, destilados em três invariantes que posso plotar em gráficos e usar como portões de decisão.
Todo diagrama de migração online que já vi tem a mesma cara. Três setas entre uma caixa pequena e uma caixa maior, às vezes uma anotação dizendo "feature flag". As palestras tratam as três setas como uma sequência, mas raramente como uma sequência de coisas que eu realmente posso afirmar em produção. Passei algumas noites revisitando os escritos da Stripe sobre DocDB — o post de engenharia sobre migrações online, mais a talk do QCon San Francisco sobre movimentações de tenants em escala de petabytes — junto com o artigo da PlanetScale sobre níveis de rollback e a documentação de cutover do gh-ost e do Vitess. O padrão que essas fontes continuam reforçando é o mesmo. A substância que eu sempre acabo deixando passar nos meus próprios designs não são as fases. É para que cada fase serve.
Esse reenquadramento é a ideia central que quero registrar: uma movimentação de dados online é composta por três fases empilhadas sobre três invariantes, não por um único grande chaveamento. Cada invariante tem uma métrica que posso plotar em gráficos. Cada fase termina quando essa métrica cruza um portão que escolhi de antemão. Cada transição tem um rollback que custa menos do que a etapa de migração que ela encerra. O trabalho está em manter cada invariante observável, não no cutover.
Aqui está o que cada fase está me pedindo para provar.
Fase um: dual-write com um invariante de freshness
A primeira fase escreve o mesmo registro no store antigo e no store novo a cada mutação. O enquadramento ingênuo do dual-write — "basta escrever em ambos" — esconde a parte que quebra primeiro. Sem um version stamp por registro, eu não tenho como saber qual cópia é a atual quando as duas discordam, e elas vão discordar. As duas escritas não são atômicas. Não podem ser, a menos que eu esteja disposto a pagar por uma transação distribuída entre stores, e se eu estivesse, não estaria nessa migração em primeiro lugar.
O invariante que o version stamp me permite afirmar é o de freshness. Eu escolho um budget — por exemplo, o store novo está, no máximo, N segundos atrás do antigo para qualquer registro — e emito uma métrica por escrita: o gap entre o commit timestamp do store antigo e o do store novo para a mesma primary key. Uma linha p99 sobre esse gap é o portão. Se ela ficar abaixo do budget ao longo de um ciclo diurno completo e alguns picos de tráfego, o dual-write está fazendo o que eu quero.
O que "fazer o que eu quero" realmente significa é mais estreito do que parece. O dual-write não prova que o store novo está correto. Ele só prova que registros novos estão chegando em ambos os stores dentro de uma janela limitada. Registros históricos — tudo que foi escrito antes do dual-write ser ativado — ainda estão apenas no store antigo. O portão de freshness é necessário, mas a única coisa que ele libera é a próxima fase.
Pular o version stamp é a falha que eu mais vejo em relatos. Equipes ligam o dual-write, e uma semana depois não conseguem explicar por que um registro presente em ambos os stores tem valores diferentes, porque não há ordenação entre as duas escritas e nenhuma forma de reconstruí-la. O version stamp não precisa ser um relógio monotônico; um hybrid logical clock ou mesmo o commit LSN do store antigo funciona, desde que um leitor do lado novo consiga comparar dois stamps para a mesma key e escolher um vencedor.
O rollback nessa fase é o mais barato dos três. Pare de escrever no store novo. As leituras ainda vão para o store antigo. Nada do que a aplicação viu mudou. O store novo tem permissão para estar errado; ninguém está lendo dele ainda.
Fase dois: backfill atrás de um budget de divergência de shadow-read
A fase dois faz duas coisas em paralelo. Um job de backfill copia as linhas históricas que o dual-write não pegou. Um shadow read dispara cada leitura de produção para ambos os stores e compara os resultados antes de retornar a resposta do store antigo. O usuário vê a resposta antiga; a migração vê o diff.
O invariante aqui é a taxa de divergência do shadow. Eu escolho um budget — por exemplo, menos de 0,01% das leituras comparadas divergem em uma janela móvel de 24 horas — e o portão é a taxa cumulativa permanecer abaixo desse budget por um certo número de janelas consecutivas. Duas janelas limpas são confortáveis para serviços pequenos. Os escritos da Stripe para tenants em escala de petabytes apontam para janelas limpas medidas em dias e não em horas, o que soa longo até eu pensar no custo de uma divergência não detectada nessa escala.
O que "divergir" significa é a parte que eu sempre erro no primeiro design. A diferença semântica entre sequências de bytes iguais e registros iguais é onde os bugs moram. Engenheiros rodando shadow reads descobriram isso da pior forma mais de uma vez. Os relatos publicados são explícitos: timezones se comportavam de forma diferente entre os stores, colunas NULL eram preenchidas com defaults no caminho de entrada, e a ordem de sort mudava por um critério de desempate que o store novo não preservava. Nenhum desses casos teria disparado uma verificação por contagem de linhas ou checksum. Todos eles apareceram como um diff em um comparador no estilo Scientist que conhecia o schema. A biblioteca Scientist do GitHub é a referência original para esse padrão — ela executa ambos os caminhos de código dentro da requisição, registra divergências e retorna a resposta de controle ao usuário, que é exatamente a propriedade que eu preciso para manter a latência inalterada durante a validação.
O shadow read tem que rodar dentro do read path que o usuário realmente faz. Um job de diff offline separado não vai pegar bugs de ordenação e desempate, porque ele não vê o contexto da leitura — o LIMIT, o cursor, o plano do predicado. Rodar o diff no caminho da requisição custa um pouco de p99 (uma leitura adicional em paralelo), mas pega as diferenças que importam para os usuários. Pelo que leio, esse custo de latência é a principal razão pela qual as equipes querem pular essa fase. O custo que pagam por pular é um cutover onde descobrem bugs em produção sem nenhum rollback mais rápido do que rerodar o backfill do zero.
O job de backfill que roda em paralelo ao shadow read tem suas próprias propriedades a serem garantidas. Ele tem que ser idempotente — um lote reexecutado deve deixar o store novo no mesmo estado que uma execução única bem-sucedida. Ele tem que ser throttled contra um sinal de controle do store novo, não uma taxa fixa; o padrão que eu pego emprestado do tablet throttler do Vitess é desacelerar ou pausar quando o lag de replicação ou a latência de leitura no alvo passa de um threshold. Um backfill que sobrecarrega o alvo é seu próprio incidente, separado da migração.
O rollback nessa fase ainda é barato. Desligue os shadow reads. Desligue o backfill. O store antigo continua autoritativo para leituras. O store novo pode ficar populado ou ser apagado; nada visível ao usuário quebra de qualquer forma.
Antes de detalhar o cutover, ajuda visualizar os três invariantes empilhados ao longo do tempo.
Fase três: cutover com uma janela de resfriamento de reverse dual-write
A fase três é onde a maioria dos diagramas termina e a maioria dos incidentes começa. O enquadramento ingênuo é "vire as leituras para o store novo". A versão que eu anoto nas minhas notas é mais próxima de: desloque as leituras gradualmente e mantenha as escritas fluindo para o store antigo pela duração de um budget de rollback que eu escolho antes do deslocamento começar.
O deslocamento é gradual porque nada mais me dá um raio de explosão pequeno o suficiente. O rollout publicado da Stripe para switches de leitura é a forma canônica: 1%, depois 10%, depois 50%, depois 100%, observando taxas de erro entre cada etapa. As percentagens dos coortes e a seleção dos coortes — aleatória, por tenant ID, ou por hash do registro — dependem do workload, mas a propriedade é a mesma: qualquer erro introduzido pelo chaveamento de leituras é limitado pelo tamanho do coorte no momento em que ele aparece.
O invariante durante o cutover é o próprio budget de rollback. Enquanto as leituras estão sendo deslocadas para o store novo, as escritas continuam indo para os dois stores via reverse dual-write: store novo primeiro, store antigo de forma assíncrona. Enquanto o store antigo permanece atualizado dentro do budget de freshness que defini na fase um, o rollback é um único chaveamento — aponte as leituras de volta para o store antigo, ignore o novo. A métrica que governa essa fase é o gap de freshness no sentido reverso: quão atrás o store antigo está ficando agora que não é mais o primário? Se esse gap explodir o budget, meu caminho de rollback está encolhendo, e eu tenho que ou corrigi-lo ou aceitar que o rollback não é mais uma opção.
A janela de resfriamento — o tempo em que mantenho escrevendo nos dois stores depois que 100% das leituras estão no novo store — é a parte que eu sempre quero encurtar e sempre me arrependo de ter encurtado. Os padrões que leio convergem para pelo menos um ciclo de negócio completo de operação limpa antes de desligar o reverse dual-write. Para um serviço de pagamentos, isso é um fim de mês e um fim de trimestre. Para uma carga analítica com relatórios mensais, são dois meses. O custo de manter o reverse dual-write rodando é pequeno. O custo de desligá-lo e encontrar um bug uma semana depois é ilimitado; o único caminho de volta ao store antigo é então uma migração reversa do zero.
O MoveTables.SwitchTraffic do Vitess é uma referência útil para o que o cutover realmente faz por baixo dos panos quando um vendor o implementa. Ele pausa brevemente as escritas no primary de origem, espera o alvo se igualar, e então roteia as escritas para o alvo e adiciona a origem a uma denylist. A breve pausa é o custo inevitável de conseguir um chaveamento atômico nas escritas; ferramentas "online" não eliminam isso, só minimizam. O cutover do gh-ost no MySQL tem a mesma forma — um rename atômico em dois passos atrás de um breve metadata lock, com a tabela original ainda intacta se o swap der timeout. Ambos combinam cutover de leitura e escrita em um único passo atômico. O padrão de três fases os separa, o que é o que dá ao slow-roll de leituras espaço para funcionar.
Quando uma fase merece ser pulada
Nem toda movimentação precisa de três fases. Saber quando deixar uma de lado é mais útil do que rodar todas as três por padrão.
Pule a fase um (dual-write) quando o store novo pode ser construído a partir de um change stream que o store antigo já emite. Com CDC ou um transactional outbox rodando para o store novo com lag limitado, o dual-write é redundante — o change stream é o dual-write, com o bônus de ser ordenado. O invariante de freshness agora é "lag de CDC abaixo de N segundos", que eu provavelmente já estou plotando por outras razões. O trade-off é que eu abri mão da capacidade de escrever diretamente no store novo durante a migração, o que torna o reverse dual-write da fase três desajeitado, a menos que eu adicione um caminho temporário de escrita direta.
Pule a fase dois (shadow reads) quando o store novo é uma réplica idêntica rodando o mesmo engine no mesmo schema. Um shard split entre dois clusters Postgres homogêneos não precisa de um diff semântico por requisição — replicação, contagem de linhas e checksums são suficientes. No momento em que os engines ou schemas diferem — indo de um row store para um document store, mudando codificações JSON, mudando collations — os shadow reads param de ser opcionais. Ordem de sort, tratamento de NULL e case folding são os cantos onde as suposições "idênticas" quebram.
Pule a fase três (cutover gradual) só quando a granularidade do coorte está errada. Algumas movimentações não podem ser divididas: um contador global único, uma config singleton em escala de serviço, um coordenador eleito por leader election. Para essas, o cutover é um único chaveamento com uma janela de manutenção, e o padrão de três fases é a ferramenta errada. Aplicá-lo mesmo assim transforma a migração em teatro — uma feature flag que vira para todo mundo de uma vez não é um rollout gradual.
Quando a resposta certa é uma janela de manutenção
O caso contraintuitivo ao qual eu sempre volto é o menor deles. Aceite o downtime. Lendo o doc de limitações do gh-ost e o post de níveis de rollback do pgroll lado a lado, a opção da janela de manutenção continua sendo a mais barata para certos formatos de trabalho. Uma janela de 30 minutos no meio de um domingo para um sistema interno de baixo throughput é mais barata do que três semanas de infraestrutura de dual-write, mais o custo do diff de shadow-read, mais a atenção operacional.
A pergunta honesta que faço antes de me comprometer com o padrão de três fases é a pergunta de SLO: quanto error budget eu tenho para gastar nessa migração, e ao longo de quanto tempo? Se a resposta é "nem um minuto de serviço degradado é aceitável", as três fases pagam seu custo. Se a resposta é "uma hora na janela de manutenção que eu já tenho está bom", as três fases são over-engineering, e fazer over-engineering em uma migração é uma classe de incidente por si só. Cada peça adicional em movimento — o caminho de dual-write, o comparador de shadow-read, o throttler do backfill, a rampa do cutover, o reverse dual-write — é código que tem que continuar funcionando sob carga, e qualquer um deles pode ser o que quebra em vez da migração que ele deveria proteger.
O que estou levando de volta para minhas notas
O padrão em uma frase: nomeie cada fase pelo invariante dela, plote a métrica em gráfico e escolha o portão antes da fase começar.
Uma lista curta de ações que estou guardando:
- Trate o dual-write como um contrato de freshness, não como um job de cópia. Adicione um version stamp por registro antes de ativá-lo.
- Rode shadow reads dentro do caminho da requisição com um comparador ciente do schema. Checksums em nível de linha sozinhos vão deixar passar os bugs que mordem.
- Throttle do backfill contra um sinal do lado do alvo, não uma taxa fixa. Um backfill que sobrecarrega é um incidente separado da migração.
- Deixe o reverse dual-write rodar por um ciclo de negócio completo após o switch de leitura. Encurtar a janela de resfriamento é o rollback que se perde primeiro.
- Escolha o budget de rollback para cada fase antes da fase começar. Se a métrica explodir o budget no meio da fase, pare a migração. Não eleve o budget.
- Questione a migração antes de projetá-la. Uma janela de manutenção é uma resposta legítima e frequentemente a mais barata.
Quando recorrer ao padrão de três fases: serviços de alto throughput com SLO apertado, stores de origem e destino heterogêneos, ou movimentações em escala de tenants onde o rollout por coorte é a única forma de limitar o raio de explosão. Quando evitá-lo: sistemas de baixo throughput com janelas de manutenção disponíveis, movimentações de réplicas idênticas que precisam apenas de replicação padrão, e qualquer movimentação onde a granularidade do coorte está errada para um deslocamento gradual de tráfego.
As três setas no diagrama não são o trabalho. As três métricas embaixo delas são.
Curtindo? Talvez goste disso aqui.
Nada parecido — quer tentar outro ângulo?
Posts Relacionados
Read-Your-Writes é um Contrato de Sessão, Não uma Configuração de Banco
Adicionei uma réplica de leitura e, em um dia, usuários reportaram edições que "não salvaram" — elas tinham salvado, mas as leituras correram contra o stream de replicação e perderam. Estas são minhas notas sobre por que read-your-writes é um contrato de sessão em vez de uma configuração de réplica, reproduzido com 50 ms de lag fixo em Go, e as três formas de carregá-lo — roteamento sticky, um token de versão estilo GTID e uma espera de bounded-staleness — comparadas em custo.
Quando o SQL Basta para Streaming: O Que a Manutenção Incremental de Visões Garante — e O Que Ela Recusa
A promessa de 2026 é que um job de stream escrito à mão se resume a um único CREATE MATERIALIZED VIEW, então construí um no RisingWave e passei meu tempo de estudo tentando quebrá-lo. Estas são minhas anotações sobre a consistência que a visão realmente entrega conforme os eventos chegam, por que cada join nela é uma conta de memória permanente, e as queries que se recusam a ser incrementais — com um consumidor Node acompanhando o changefeed da visão sobre o protocolo puro do Postgres.
O Servidor Agora É um Relay de Sincronização: Arquitetando em Torno do Estado que Pertence ao Cliente
Na QCon London 2026, Kleppmann descreveu o local-first como o melhor do Google Sheets e o melhor do Git, e neste ano os sync engines chegaram ao mercado. A partir da minha leitura do Electric, do Zero e do LiveStore lado a lado, estas são minhas anotações sobre o que muda quando a cópia do cliente se torna primária: onde os conflitos são resolvidos, o que o servidor ainda controla e a linha entre mesclar e recusar que decide quais aplicações nunca devem ser construídas dessa forma.