Blog

Multi-região com escrita única: o que muda quando a latência vira decisão de produto

A segunda região subiu em uma tarde e o painel de latência melhorou na mesma hora: leituras que levavam duzentos e trinta milissegundos passaram a levar dezoito. Três semanas depois começaram os tickets de clientes que salvavam um formulário e viam o valor antigo voltar na tela seguinte. O banco não tinha bug, a replicação estava saudável, e o atraso médio entre as réplicas era de quarenta milissegundos. O problema é que a arquitetura de escrita única transformou uma propriedade física, a distância entre continentes, em um comportamento de produto que ninguém tinha decidido. Este artigo mostra por que a escrita única é quase sempre a escolha certa e por que ela custa caro exatamente onde ninguém olha, qual conta separa a operação que pode ler da réplica local da que precisa atravessar o oceano, como implementar leitura da própria escrita sem grudar o usuário na região primária para sempre, por que o failover de escrita é uma decisão manual disfarçada de automação, e qual métrica prova que a topologia está entregando o que prometeu antes de o cliente reclamar.

2026-08-31 / Arquitetura / 17 min

01

A escrita única não é uma limitação técnica, é um contrato de consistência

A discussão sobre multi-região costuma começar errada porque trata a escrita única como uma etapa provisória, algo que se resolve depois com um banco multi-master. A topologia de escrita única existe porque ela é a única que permite manter uma ordem total dos eventos sem coordenação distribuída no caminho crítico. Existe um único lugar onde a sequência de commits é decidida, e por consequência existe uma única resposta para perguntas como qual foi a última alteração desse pedido. Quando você distribui a escrita, essa pergunta deixa de ter resposta única e passa a exigir resolução de conflito, que é uma decisão de negócio, não uma configuração de banco.

O que muda com a segunda região não é a capacidade de escrita, é a geografia da leitura. As réplicas locais transformam consultas de duzentos milissegundos em consultas de vinte, e isso é uma melhoria real e mensurável. O custo aparece porque a réplica local está sempre atrasada em relação ao primário, e esse atraso não é ruído: é o tempo de propagação da rede mais o tempo de aplicação do log, tipicamente entre trinta e cento e cinquenta milissegundos entre continentes em condição saudável, e segundos inteiros durante uma rajada de escrita ou uma manutenção do primário.

A consequência prática é que um sistema que antes tinha uma única classe de leitura passa a ter duas, e a distinção entre elas não é técnica. Ela depende de quem está lendo e do que essa pessoa acabou de fazer. Um relatório de vendas do mês passado tolera meio segundo de atraso sem nenhum problema, porque nenhum humano consegue notar. A tela que aparece imediatamente após o usuário salvar um formulário não tolera nem quarenta milissegundos, porque o usuário sabe exatamente o que acabou de escrever e vai comparar. Classificar as operações nessas duas categorias é o trabalho central de uma migração multi-região, e ele é feito no código de aplicação, não na configuração do banco.

OperaçãoOnde lerAtraso toleradoO que acontece se errar a escolha
Catálogo de produtos, listagem públicaRéplica localSegundosNada perceptível, e ler do primário desperdiça latência
Tela imediatamente após salvar um cadastroPrimário ou réplica com marca de versãoZeroUsuário vê o valor antigo e salva de novo, gerando duplicidade
Saldo antes de autorizar uma transferênciaPrimário, sempreZeroAutoriza operação sobre saldo que já foi consumido
Relatório analítico do período fechadoRéplica local ou réplica dedicadaMinutosNenhum impacto, e ler do primário concorre com o tráfego transacional
Verificação de permissão após troca de perfilPrimário até a replicação alcançarZeroUsuário mantém acesso que acabou de ser revogado

02

Leitura da própria escrita sem prender o usuário no primário

A primeira solução que aparece para o problema da tela desatualizada é grudar a sessão do usuário no primário depois de qualquer escrita. Funciona, e joga fora a razão de existir da segunda região: qualquer usuário que interaja com o sistema volta a pagar a latência transatlântica em todas as leituras seguintes. A variação um pouco menos ruim, que fixa o usuário no primário por trinta segundos, ainda é um número escolhido no chute, e ele erra nas duas direções: é curto demais quando a replicação atrasa, e longo demais na esmagadora maioria das vezes em que ela está saudável.

A solução correta usa a posição do log de replicação como token. Toda escrita retorna a posição em que ela foi confirmada no primário, essa posição viaja com o usuário em um cookie ou cabeçalho, e cada leitura compara o token com a posição que a réplica local já aplicou. Se a réplica alcançou aquela posição, a leitura local é servida e é comprovadamente consistente com o que o usuário escreveu. Se ainda não alcançou, existe uma escolha explícita entre esperar alguns milissegundos ou desviar aquela leitura específica para o primário. O ponto que torna isso muito melhor que o tempo fixo é que ele se autoajusta: quando a replicação está com quarenta milissegundos de atraso, praticamente nenhuma leitura desvia; quando ela degrada, o desvio acontece exatamente enquanto for necessário.

// dados/roteador-leitura.js
// Roteia cada leitura para a replica local ou para o primario com base na
// posicao de replicacao que o usuario ja observou (read-your-writes).
// O token nao e um timestamp: comparar relogios entre regioes e justamente
// o que nao funciona. E a posicao monotonica do log do primario.

const ESPERA_MAXIMA_MS = 60;   // acima disso, atravessar o oceano e mais rapido
const INTERVALO_SONDA_MS = 5;

export const criarRoteadorLeitura = ({ primario, replicaLocal, metricas }) => {
  /**
   * @param {object} ctx
   * @param {bigint|null} ctx.posicaoObservada  ultima posicao escrita pelo usuario
   * @param {boolean} ctx.exigeAtual            operacao que nunca aceita atraso
   */
  const escolherConexao = async ({ posicaoObservada, exigeAtual }) => {
    if (exigeAtual) {
      metricas.incrementar('leitura.primario.exigida');
      return primario;
    }

    // Usuario que nunca escreveu nesta sessao nao tem nada a esperar.
    if (posicaoObservada == null) {
      metricas.incrementar('leitura.local.sem_token');
      return replicaLocal;
    }

    const inicio = process.hrtime.bigint();

    // Espera curta e limitada: na maior parte das vezes a replica ja
    // alcancou e o laco encerra na primeira iteracao, sem custo.
    while (true) {
      const aplicada = await replicaLocal.posicaoAplicada();
      if (aplicada >= posicaoObservada) {
        const esperaMs = Number(process.hrtime.bigint() - inicio) / 1e6;
        metricas.observar('leitura.local.espera_ms', esperaMs);
        return replicaLocal;
      }

      const decorridoMs = Number(process.hrtime.bigint() - inicio) / 1e6;
      if (decorridoMs >= ESPERA_MAXIMA_MS) {
        // Desviar e a decisao certa aqui: esperar mais custaria ao usuario
        // mais do que a ida ate o primario. O contador abaixo e o sinal de
        // que a replicacao degradou, e ele se move antes de qualquer ticket.
        metricas.incrementar('leitura.primario.desvio_por_atraso');
        return primario;
      }

      await new Promise((r) => setTimeout(r, INTERVALO_SONDA_MS));
    }
  };

  /**
   * Executa a escrita e devolve a posicao para o chamador propagar ao cliente.
   */
  const escrever = async (executar) => {
    const resultado = await primario.transacao(executar);
    const posicao = await primario.posicaoAtual();
    return { resultado, posicao };
  };

  return { escolherConexao, escrever };
};

O contador chamado de desvio por atraso é a parte mais valiosa desse código e passa despercebida na revisão. Em operação saudável ele fica próximo de zero, e ele sobe minutos antes de qualquer sintoma visível para o usuário, porque a replicação degrada gradualmente antes de degradar de forma perceptível. Alertar sobre a razão entre desvios e leituras totais dá um sinal antecipado que nenhum painel do banco fornece, porque o banco enxerga o atraso em segundos e não sabe quantas leituras da aplicação aquele atraso está de fato prejudicando.

Um detalhe de implementação que costuma ser esquecido é o que fazer com o token quando o usuário abre uma segunda aba ou troca de dispositivo. O token vive na sessão, não no cliente individual, e por isso o lugar correto de guardá-lo é o armazenamento de sessão do lado do servidor, com o cookie carregando apenas o identificador. Guardar a posição diretamente no cookie funciona para uma aba só e falha silenciosamente quando o usuário abre a segunda, porque a aba antiga carrega uma posição defasada e nada quebra de forma visível: ela apenas serve leituras locais que a outra aba já sabia estarem desatualizadas.

03

A escrita continua atravessando o oceano, e isso precisa ser projetado

Nenhuma técnica de roteamento de leitura muda o fato de que uma escrita originada em São Paulo com primário em Frankfurt paga cerca de cento e oitenta milissegundos só de ida e volta na rede. Se o fluxo de cadastro faz seis escritas sequenciais, o usuário espera mais de um segundo apenas em propagação, sem nenhum tempo de processamento envolvido. É aqui que a latência deixa de ser uma métrica de infraestrutura e vira uma decisão de produto, porque a correção não está no banco: está em quantas idas e voltas o fluxo exige.

  1. Contar as idas e voltas ao primário por fluxo de negócio, não por endpoint. O número que importa é quantas vezes uma jornada completa atravessa a distância, e ele costuma ser bem maior do que o time imagina.
  2. Agrupar escritas relacionadas em uma única transação enviada de uma vez. Seis escritas sequenciais viram uma chamada, e o custo de rede cai de seis viagens para uma.
  3. Mover validações que só leem dados para a réplica local antes de abrir a transação, para que o caminho longo carregue apenas o que de fato precisa ser confirmado.
  4. Confirmar ao usuário assim que o primário confirmou, sem esperar a replicação alcançar a região de origem, e servir a próxima tela usando o token de posição.
  5. Para escritas que não precisam de resposta imediata, gravar localmente em um outbox e propagar de forma assíncrona, aceitando que aquele dado só existe de verdade depois da propagação.

O quinto item é o que exige mais cuidado, porque ele muda a semântica do sistema. Um evento de rastreamento ou um registro de auditoria de leitura pode ser gravado localmente e propagado depois sem que ninguém perceba a diferença. Um pedido de compra não pode, porque a confirmação ao cliente cria uma obrigação que precisa sobreviver à perda daquela região antes da propagação. A pergunta que separa os dois casos é direta: se essa região desaparecer agora, antes de propagar, o dado perdido gera prejuízo, obrigação legal ou apenas uma métrica incompleta?

  Usuário (São Paulo)
        |
        | 1. leitura de catálogo  ~18 ms
        v
  +------------------------+        replicação assíncrona
  |  Réplica local (BR)    | <---------------------------------+
  |  posição aplicada: 941 |                                   |
  +------------------------+                                   |
        ^                                                      |
        | 4. próxima tela: token 942 <= 941? não, então desvia  |
        |                                                      |
        |                                               +--------------+
        +----- 2. escrita (~180 ms ida e volta) ------>  |  Primário    |
        |                                               |  (Frankfurt) |
        +----- 3. leitura desviada (~180 ms) --------->  |  posição 942 |
                                                        +--------------+

  Regra: o desvio do passo 3 dura só até a réplica aplicar a posição 942.
  Sem o token, a alternativa é fixar o usuário no primário por tempo fixo,
  pagando 180 ms em toda leitura mesmo quando a réplica já está em dia.

04

Failover de escrita: automatizar a promoção é como perder dados

A promoção automática de uma réplica a primário parece o complemento natural da topologia, e é a decisão que mais destrói dados em incidentes de multi-região. O motivo é que a condição que dispara a promoção, a região primária parou de responder, é indistinguível da condição em que a região primária está viva e saudável mas a rede entre as regiões particionou. No segundo caso, promover a réplica cria duas regiões que aceitam escrita ao mesmo tempo, cada uma convencida de ser a única, e a reconciliação posterior é manual, cara e frequentemente impossível sem perder alguma coisa.

A propriedade que sustenta uma promoção segura não é a detecção de falha, é o quórum. Enquanto a decisão de promover depende de dois observadores, sempre existe uma configuração de rede em que os dois discordam. Com um terceiro ponto de observação em uma região independente, a promoção só acontece com a maioria dos votos, e a região isolada perde a votação e se recusa a aceitar escrita mesmo estando viva. Isso não é opcional em uma topologia que aceita promoção automática: sem o terceiro observador, a promoção é uma aposta contra a partição de rede.

O segundo componente é o cercamento, que garante que o primário antigo pare de aceitar escrita antes que o novo comece. A forma robusta usa um número de época que só cresce, gravado junto com cada escrita. Quando a nova região é promovida, ela incrementa a época, e o primário antigo, ao voltar da partição, descobre que sua época é inferior à corrente e rejeita as escritas que ainda tinha em voo em vez de aplicá-las sobre um estado que já avançou sem ele.

// operacao/cercamento-epoca.js
// Cercamento por epoca monotonica. Impede que o primario antigo, ao voltar
// de uma particao de rede, aplique escritas em voo sobre um estado que a
// nova regiao ja avancou. Detectar falha nunca e suficiente: a garantia vem
// de rejeitar escrita com epoca inferior a corrente.

export const criarGuardaEpoca = ({ registro, regiao }) => {
  let epocaLocal = null;

  /**
   * Chamado na promocao. O incremento e condicional no armazenamento
   * consistente (etcd, Consul, tabela com bloqueio) para que duas regioes
   * nunca obtenham a mesma epoca durante uma particao.
   */
  const assumirPrimario = async () => {
    const nova = await registro.incrementarEpoca({ regiao });
    epocaLocal = nova;
    return nova;
  };

  /**
   * Envolve toda escrita. A epoca corrente e lida do registro compartilhado
   * dentro da MESMA transacao da escrita, para que nao exista janela entre
   * a verificacao e a aplicacao.
   */
  const escreverCercado = async (transacao, executar) => {
    if (epocaLocal == null) {
      throw new Error('regiao nao e primaria: escrita recusada');
    }

    const corrente = await transacao.selecionarEpocaParaAtualizacao();

    if (corrente > epocaLocal) {
      // Perdemos a primazia enquanto esta escrita estava em voo. Aplicar
      // agora sobrescreveria decisoes que a nova regiao ja tomou.
      const anterior = epocaLocal;
      epocaLocal = null;
      throw new Error(
        'escrita rejeitada: epoca local ' + anterior + ' inferior a corrente ' + corrente,
      );
    }

    return executar(transacao);
  };

  return { assumirPrimario, escreverCercado };
};

A leitura da época dentro da mesma transação da escrita é o detalhe que faz a diferença entre uma proteção real e uma checagem decorativa. Se a verificação acontece antes de abrir a transação, existe uma janela entre a comparação e a aplicação, e é exatamente nessa janela que a escrita perdida se encaixa durante um failover. O bloqueio de seleção para atualização é o que fecha essa janela, ao custo de uma linha de contenção por escrita, que é irrelevante comparada ao custo de reconciliar dois primários divergentes.

05

O que medir para saber se a topologia está entregando

O painel padrão de multi-região mostra atraso de replicação em segundos e latência por região, e nenhuma das duas responde à pergunta que importa, que é se os usuários estão de fato recebendo respostas consistentes com o que acabaram de fazer. A métrica que responde é a razão entre leituras desviadas para o primário e leituras totais, segmentada por região. Ela mede diretamente quantas vezes a réplica local não conseguiu servir uma leitura que deveria ter servido.

MétricaComo calcularValor saudávelO que a variação indica
Razão de desvio por atrasodesvios dividido por leituras elegíveis a réplica, por regiãoAbaixo de 1%Subida gradual antecipa degradação da replicação em minutos
Espera até consistênciap99 do tempo gasto no laço de espera pela posiçãoAbaixo de 15 msCauda crescendo mostra que a janela fixa alternativa erraria
Idas e voltas por jornadacontagem de escritas no primário por fluxo completoUma ou duasAcima disso o custo de rede domina a latência percebida
Atraso de replicação em posiçãoposição do primário menos posição aplicada na réplicaEstável e limitadoCrescimento monotônico indica que a réplica não acompanha a escrita
Escritas rejeitadas por épocacontador do guarda de cercamentoZero fora de failoverQualquer valor fora de promoção indica dois primários ativos

Vale destacar por que a linha do atraso em posição é diferente da mesma métrica em segundos, que é a exposta por padrão pela maioria dos bancos. O atraso em segundos é calculado a partir do carimbo de tempo da última transação aplicada, e ele vai a zero quando o primário para de receber escrita, mesmo que a réplica esteja arbitrariamente atrasada. Uma réplica travada em um primário ocioso reporta atraso zero e parece perfeitamente saudável, e essa é justamente a condição que precede o failover que perde dados. A diferença de posições não tem esse ponto cego, porque ela compara duas posições reais do log em vez de comparar um relógio com o silêncio.

  • Teste de partição em ambiente controlado: bloquear a rota entre as regiões e verificar que a região isolada rejeita escrita em vez de promover a si mesma.
  • Teste de leitura da própria escrita: escrever e ler imediatamente na região mais distante, com replicação artificialmente atrasada, confirmando que a leitura desvia em vez de retornar o valor antigo.
  • Teste de época: promover a réplica, restaurar o primário antigo com escritas em voo e confirmar que elas são rejeitadas e registradas, não aplicadas silenciosamente.
  • Exercício de failover com cronômetro, medindo o tempo entre a decisão humana e a primeira escrita aceita na nova região, porque esse número é o objetivo de recuperação real.

FAQ

Perguntas frequentes

Vale a pena colocar uma segunda região quando o tráfego de escrita é alto?

A resposta depende quase inteiramente da proporção entre leitura e escrita e da distribuição geográfica dos usuários, e não do volume absoluto. Um sistema com noventa por cento de leitura e usuários espalhados por dois continentes ganha muito com a réplica local, porque a esmagadora maioria das requisições passa a ser servida perto de quem pediu, e as escritas continuam pagando a travessia sem que isso domine a experiência. Um sistema com quarenta por cento de escrita e usuários concentrados em uma região só está trocando complexidade operacional por um ganho que quase não existe, e provavelmente resolveria melhor o problema com cache de leitura e otimização de consulta na região única. O erro que aparece com frequência é considerar apenas a latência média e ignorar quantas idas e voltas ao primário cada jornada faz: um fluxo de cadastro com seis escritas sequenciais fica pior em multi-região do que estava em região única, mesmo com todas as leituras aceleradas, e nenhuma configuração de banco corrige isso. Antes de decidir pela topologia, meça idas e voltas por jornada de negócio, porque esse número determina se a segunda região vai melhorar ou piorar a experiência percebida.

Banco multi-master resolveria o problema da latência de escrita?

Ele resolve a latência de escrita e cobra o preço em outro lugar, que é a resolução de conflito, e esse preço quase nunca é de infraestrutura. Quando duas regiões aceitam escrita sobre a mesma entidade, alguma delas precisa decidir qual versão vence, e as estratégias automáticas disponíveis são todas insatisfatórias para dados transacionais: a última escrita vence descarta silenciosamente uma alteração legítima, e a mesclagem por campo produz um estado que nenhum dos dois usuários pediu. O caso em que multi-master funciona bem é aquele em que a estrutura de dados é naturalmente convergente, como um contador incremental, um conjunto ao qual só se adiciona, ou um documento colaborativo com um tipo de dado replicado sem conflito por trás. Para saldo de conta, controle de estoque ou status de pedido, o conflito não tem resolução automática correta, porque a resposta certa depende de regra de negócio que o banco não conhece. Na prática a maioria dos sistemas que adotou multi-master acabou particionando a escrita por chave, o que é escrita única por partição com outro nome, e essa é frequentemente a arquitetura certa desde o começo.

Como lidar com residência de dados quando a escrita é única em uma região?

Regulação de residência transforma a topologia de uma decisão de latência em uma restrição de arquitetura, e ela normalmente inviabiliza a escrita única global para os dados afetados. O padrão que funciona é separar o modelo em duas classes: dados regulados, que vivem inteiramente dentro da região exigida e nunca são replicados para fora, e dados globais, como catálogo, configuração e metadados operacionais, que continuam com escrita única e réplicas em toda parte. Isso significa que a chave de particionamento passa a incluir a jurisdição, e que uma consulta que cruza jurisdições deixa de ser uma junção no banco e vira duas consultas com composição na aplicação, o que precisa ser projetado desde cedo porque é caro reformar depois. A parte que costuma ser subestimada é o que conta como dado regulado: registros de log, traços de observabilidade, backups e até chaves de cache podem carregar identificadores pessoais e acabar fora da jurisdição sem que ninguém tenha decidido isso. Vale mapear todo caminho por onde um dado sai da região, incluindo os que existem para operar o sistema e não para servi-lo, porque é por eles que o vazamento de jurisdição acontece na prática.

A distância entre regiões vira comportamento de produto quer você decida ou não

A segunda região melhora a latência de leitura de forma imediata e mensurável, e introduz uma classe de comportamento que não existia antes: o usuário passa a poder ver um estado anterior ao que ele mesmo acabou de escrever. Ignorar isso não faz o problema desaparecer, apenas transfere a decisão para o acaso da replicação. Posso mapear as operações do seu sistema entre as que toleram réplica local e as que exigem o primário, implementar leitura da própria escrita com token de posição em vez de fixação por tempo, reduzir as idas e voltas ao primário nos fluxos que mais custam latência, introduzir cercamento por época com quórum de três observadores antes de qualquer promoção automática, e deixar no painel a razão de desvio que antecipa a degradação da replicação em minutos.