Blog

Réplica de leitura atrasada: quando o usuário salva e não vê o que acabou de salvar

O cliente abre o checkout, corrige o endereço de entrega, clica em salvar e a página recarrega mostrando o endereço antigo. Ele salva de novo, e agora aparece o novo. Na semana em que o time passou a mandar as leituras para duas réplicas do PostgreSQL, o suporte recebeu trezentos e quarenta chamados de "o endereço não salva", o painel registrou pedidos criados que abriam em 404 no redirecionamento e o financeiro encontrou compras duplicadas de clientes que clicaram duas vezes porque o pedido "não apareceu". Nenhum dado se perdeu. Toda escrita estava no primário, e as réplicas a aplicaram corretamente, só que alguns milissegundos depois, e em certos momentos oito ou trinta segundos depois. O banco fez exatamente o que a replicação assíncrona promete, e o sistema foi construído como se ela prometesse outra coisa. Este artigo explica de onde vem o atraso da réplica e por que ele tem picos, quais sintomas ele produz que nem parecem problema de replicação, por que as correções mais comuns falham, como garantir que o usuário leia o que acabou de escrever usando a posição do WAL, como decidir onde cada tipo de leitura deve ir, e como medir o atraso real sem ser enganado pelas métricas padrão.

2026-09-28 / Arquitetura / 17 min

01

De onde vem o atraso da réplica e por que ele tem picos

Na replicação por streaming do PostgreSQL, o primário confirma o COMMIT assim que grava o WAL no próprio disco e só depois envia esse WAL às réplicas. Cada réplica percorre quatro etapas: recebe os bytes, grava, sincroniza no disco e aplica as mudanças nas páginas de dados. Uma consulta na réplica só enxerga uma transação depois da última etapa, o replay. Em operação normal, tudo isso leva de dez a cinquenta milissegundos, e é por isso que o problema passa despercebido nos testes: o atraso médio é menor que o tempo de um redirecionamento. O que causa os chamados são os picos.

Causa do picoO que aconteceComo aparece
Rajada de escrita no primárioUma importação ou um UPDATE em massa gera gigabytes de WAL em minutos, e o replay, que aplica o WAL em um único processo, não acompanhaTodas as réplicas atrasam juntas, por segundos ou minutos
Consulta longa na réplicaO replay precisa remover versões que a consulta ainda lê; ele pausa até max_standby_streaming_delay, trinta segundos por padrão, e depois cancela a consultaUma réplica atrasa sozinha, em degraus de até trinta segundos, enquanto a outra está em dia
Disco ou CPU da réplica saturadosRéplica menor que o primário, ou dividindo recurso com relatórios, aplica mais devagar do que o primário produzAtraso que cresce continuamente no horário de pico e só zera de madrugada
Rede entre zonas ou regiõesLatência e banda limitam o envio do WAL, sobretudo em rajadasAtraso proporcional ao volume escrito, pior na réplica mais distante

No incidente do checkout, as duas primeiras causas se somavam. Às duas da tarde, a importação de catálogo gerava seis gigabytes de WAL e as duas réplicas chegavam a oito segundos de atraso. Ao longo do dia, um relatório de vendas de vinte e cinco segundos rodava na segunda réplica e pausava o replay dela a cada execução. As consultas abaixo mostram as duas coisas: o atraso por réplica visto do primário, a diferença entre o que a réplica recebeu e o que ela já aplicou, e quantas consultas foram canceladas por conflito com o replay.

-- No primario: quanto cada replica esta atrasada, em bytes e em tempo
SELECT application_name,
       state,
       pg_wal_lsn_diff(pg_current_wal_lsn(), replay_lsn) AS bytes_atras,
       write_lag,
       flush_lag,
       replay_lag
FROM pg_stat_replication
ORDER BY bytes_atras DESC;

-- Na replica: posicao recebida, posicao aplicada e replay pausado por conflito
SELECT pg_last_wal_receive_lsn() AS recebido,
       pg_last_wal_replay_lsn()  AS aplicado,
       pg_wal_lsn_diff(pg_last_wal_receive_lsn(), pg_last_wal_replay_lsn()) AS bytes_por_aplicar;

-- Na replica: consultas canceladas por conflito com o replay, por banco
SELECT datname, confl_snapshot, confl_lock, confl_bufferpin
FROM pg_stat_database_conflicts;

Quando bytes_por_aplicar cresce na réplica enquanto o recebimento está em dia, o gargalo é o replay, e não a rede. Quando confl_snapshot sobe junto com os degraus de atraso, a causa são consultas longas disputando com o replay. Ligar hot_standby_feedback evita esses cancelamentos, mas transfere o custo para o primário, que passa a segurar versões mortas para proteger a consulta da réplica. É a troca entre atraso na réplica e tabela inchada no primário, e ela precisa ser feita de forma consciente.

02

Os sintomas que não parecem problema de replicação

Uma réplica atrasada nunca devolve erro. Ela devolve um dado que foi verdadeiro alguns segundos atrás, e a aplicação trata esse dado como verdade atual. Por isso os sintomas chegam ao time como bugs de interface, de cache ou de regra de negócio, e raramente alguém olha para a replicação primeiro.

  • Salvar e ver o valor antigo: o POST grava no primário, o redirecionamento faz um GET que cai na réplica e mostra o estado anterior. É a quebra da garantia de ler o que se escreveu.
  • Criar e receber 404: o pedido é criado, o navegador é levado para a página dele, e a réplica ainda não tem a linha. O usuário entende que a compra falhou e tenta de novo.
  • Valor que aparece e some: com duas réplicas em atrasos diferentes e balanceamento alternado, a primeira leitura cai na réplica em dia e mostra o endereço novo, e a segunda cai na atrasada e mostra o antigo. É a quebra das leituras monotônicas, e é o sintoma mais difícil de reproduzir.
  • Job que não encontra o registro: a escrita publica uma mensagem na fila, o worker consome em vinte milissegundos, lê da réplica e não encontra a linha. O job falha, vai para retentativa ou, pior, conclui que não há nada a fazer.
  • Validação que aprova o que não devia: checar saldo, estoque ou unicidade na réplica antes de gravar no primário decide com base em um estado antigo. A escrita passa, e a regra foi violada.
  • Cache que eterniza o dado velho: a escrita invalida a chave, a próxima leitura busca o valor na réplica atrasada e grava o valor antigo de volta no cache, onde ele fica até o TTL expirar.

O terceiro e o quinto itens mostram que o problema não se resume à tela de confirmação. O primeiro é um incômodo; o quinto é um defeito de integridade, e ele existe mesmo com atraso de cinquenta milissegundos, porque não depende do tamanho do atraso, só de ele não ser zero.

03

Por que as correções mais comuns falham

A primeira reação costuma ser um sleep depois da escrita ou uma segunda tentativa quando a leitura volta vazia. As duas trocam um defeito por outro: o sleep deixa toda escrita mais lenta para cobrir um atraso que na maior parte do tempo não existe, e não cobre o pico de oito segundos. A segunda tentativa transforma um 404 legítimo em espera e não resolve o caso em que a leitura devolve um valor antigo, que não é vazio. As alternativas sérias são as da tabela abaixo.

EstratégiaGarantiaCustoQuando falha
Todas as leituras no primárioTotalO primário volta a carregar toda a leitura, que era o motivo de ter réplicasNão falha, mas desfaz o ganho de capacidade
Primário por N segundos após escreverEnquanto o atraso for menor que NBaixo, com uma marca de tempo por sessãoNo pico maior que N, e manda para o primário leituras pesadas que não precisavam ir
synchronous_commit = remote_applyA réplica síncrona já aplicou quando o COMMIT voltaTodo COMMIT espera o replay; se a réplica síncrona cair, as escritas travamSó cobre réplicas listadas como síncronas; as outras continuam atrasadas
Token com a posição do WALExata: lê de onde já aplicou a escrita do próprio usuárioUma verificação a mais nas leituras de quem escreveu há poucoPrecisa carregar o token até onde a leitura acontece

A janela de N segundos é um bom primeiro passo, porque se implementa em uma tarde e resolve a maior parte dos chamados. O problema é que ela aposta em um número: com N igual a dois, o pico de oito segundos da importação continua quebrando o checkout; com N igual a trinta, qualquer usuário que salvou algo passa meio minuto lendo tudo do primário, inclusive listagens e relatórios. O remote_apply é útil em escritas pontuais, com SET LOCAL dentro da transação, mas estende a latência de cada COMMIT até o replay e amarra a disponibilidade das escritas à disponibilidade da réplica síncrona. O token com a posição do WAL troca a aposta por uma medição.

04

Ler o que escreveu: a posição do WAL como token

Toda transação confirmada no primário ocupa uma posição no WAL, o LSN, e toda réplica informa até que posição já aplicou com pg_last_wal_replay_lsn(). Se a aplicação guarda a posição logo depois da escrita e, na leitura seguinte do mesmo usuário, só aceita uma réplica que já passou dessa posição, a garantia de ler o que se escreveu é exata, sem aposta de tempo. Se nenhuma réplica chegou lá dentro de uma espera curta, a leitura vai para o primário. Quem não escreveu nada recentemente não carrega token e continua lendo de qualquer réplica, sem custo adicional.

import pg from 'pg';

const primary = new pg.Pool({ connectionString: process.env.PRIMARY_URL, max: 20 });
const replicas = (process.env.REPLICA_URLS || '')
  .split(',')
  .filter(Boolean)
  .map((url) => new pg.Pool({ connectionString: url, max: 20 }));

const LSN_RE = /^[0-9A-F]{1,8}[/][0-9A-F]{1,8}$/i;
export const isValidLsn = (value) => typeof value === 'string' && LSN_RE.test(value);

const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

// Executa a escrita no primario e devolve a posicao do WAL que a proxima
// leitura desse usuario precisa enxergar. Lida depois do COMMIT, ela e maior
// ou igual a posicao do commit, o que torna a garantia conservadora.
export async function writeTx(fn) {
  const client = await primary.connect();
  try {
    await client.query('BEGIN');
    const result = await fn(client);
    await client.query('COMMIT');
    const { rows } = await client.query('SELECT pg_current_wal_lsn()::text AS lsn');
    return { result, lsn: rows[0].lsn };
  } catch (err) {
    await client.query('ROLLBACK').catch(() => {});
    throw err;
  } finally {
    client.release();
  }
}

// Le de uma replica que ja aplicou minLsn. Tenta as replicas por ate maxWaitMs
// e, se nenhuma alcancar a posicao, cai para o primario.
export async function readAfter(minLsn, sql, params = [], { maxWaitMs = 150 } = {}) {
  if (!isValidLsn(minLsn) || replicas.length === 0) {
    const pool = replicas.length ? replicas[Math.floor(Math.random() * replicas.length)] : primary;
    return pool.query(sql, params);
  }

  const deadline = Date.now() + maxWaitMs;
  do {
    for (const pool of replicas) {
      const client = await pool.connect();
      try {
        const { rows } = await client.query(
          'SELECT pg_last_wal_replay_lsn() >= $1::pg_lsn AS ok',
          [minLsn],
        );
        // O replay so avanca: se a replica ja passou de minLsn nesta conexao,
        // a consulta seguinte na mesma conexao tambem enxerga a escrita.
        if (rows[0].ok) return await client.query(sql, params);
      } finally {
        client.release();
      }
    }
    await sleep(25);
  } while (Date.now() < deadline);

  return primary.query(sql, params);
}

Dois detalhes tornam o código correto. O primeiro é que a verificação e a consulta usam a mesma conexão: como o replay só avança, se a réplica já aplicou a posição na verificação, a consulta seguinte naquela conexão também enxerga a escrita. Fazer a verificação em uma conexão e a consulta em outra do mesmo pool funciona na prática, mas depende de a réplica ser a mesma, o que o balanceador não garante. O segundo é que a comparação acontece no próprio PostgreSQL, com o tipo pg_lsn, e não em JavaScript, onde comparar as strings de LSN como texto dá resultado errado assim que a parte alta muda de número de dígitos.

O token precisa chegar até onde a leitura acontece. No fluxo de salvar e redirecionar, um cookie de vida curta basta. Quando o usuário alterna entre celular e computador, o token vai para a sessão no servidor ou para uma chave por usuário no Redis com o mesmo prazo. Quando a escrita dispara um job assíncrono, o LSN vai dentro da mensagem, e o worker chama readAfter com ele. Esse último caso é o que mais se esquece, e é o que transforma o job que não encontra o registro em um problema resolvido de vez.

import express from 'express';
import cookieParser from 'cookie-parser';
import { writeTx, readAfter, isValidLsn } from './db.js';

const app = express();
app.use(express.urlencoded({ extended: false }));
app.use(cookieParser());

const TOKEN_TTL_MS = 30_000;

app.post('/enderecos/:id', async (req, res) => {
  const { lsn } = await writeTx((client) =>
    client.query(
      'UPDATE enderecos SET logradouro = $1, cep = $2, atualizado_em = now() WHERE id = $3 AND usuario_id = $4',
      [req.body.logradouro, req.body.cep, req.params.id, req.user.id],
    ),
  );
  res.cookie('min_lsn', lsn, { httpOnly: true, secure: true, sameSite: 'lax', maxAge: TOKEN_TTL_MS });
  res.redirect(303, '/enderecos/' + req.params.id);
});

app.get('/enderecos/:id', async (req, res) => {
  const minLsn = isValidLsn(req.cookies.min_lsn) ? req.cookies.min_lsn : null;
  const { rows } = await readAfter(
    minLsn,
    'SELECT id, logradouro, cep FROM enderecos WHERE id = $1 AND usuario_id = $2',
    [req.params.id, req.user.id],
  );
  if (rows.length === 0) return res.sendStatus(404);
  res.json(rows[0]);
});

O cookie é controlado pelo cliente, então a validação de formato não é opcional. Um valor fora do padrão pode quebrar o cast para pg_lsn e virar erro 500; um valor válido e absurdamente alto só manda as leituras daquele usuário para o primário por trinta segundos, um custo limitado que o rate limit da aplicação já contém. Se isso ainda for relevante no seu contexto, basta assinar o valor com HMAC antes de gravá-lo. O prazo de trinta segundos cobre os picos normais; um atraso maior que isso é incidente, e quem trata incidente é o monitoramento da última seção, não o token.

05

Onde cada leitura deve ir e como manter a ordem entre réplicas

O token resolve a leitura de quem escreveu, mas não decide sozinho para onde cada consulta deve ir. A regra que evita o defeito de integridade é simples: qualquer leitura que decide uma escrita acontece no primário, dentro da mesma transação da escrita e com o bloqueio adequado. Réplica é para mostrar, nunca para decidir.

Tipo de leituraOnde lerMotivo
Checagem antes de gravar: saldo, estoque, unicidade, estado de um pedidoPrimário, na mesma transação, com SELECT ... FOR UPDATE quando aplicávelDecidir com base em estado antigo viola a regra mesmo com atraso de milissegundos
Tela logo após o usuário salvarRéplica com token de LSN, primário como fallbackPrecisa ver a própria escrita; ninguém mais precisa
Worker que processa algo recém-criadoRéplica com o LSN que veio na mensagemO consumo da fila costuma ser mais rápido que o replay
Listagens, buscas, páginas públicasQualquer réplica saudávelAlguns segundos de atraso são aceitáveis e invisíveis
Relatórios e exportações pesadasRéplica dedicada, fora do balanceamento das telasConsultas longas pausam o replay e atrasam quem divide a réplica com elas

O sintoma do valor que aparece e some pede uma segunda garantia, a de leituras monotônicas: depois de ver um estado, o usuário nunca volta a ver um estado mais antigo. Há duas formas de obtê-la. A mais barata é fixar cada sessão em uma réplica, por exemplo com um hash do id do usuário, de modo que as leituras dele acompanhem sempre o mesmo relógio de replay. A mais robusta é tratar o LSN como uma marca d'água: cada resposta devolve a posição da réplica que a serviu, e a próxima leitura exige pelo menos aquela posição. A segunda sobrevive à saída de uma réplica do pool; a primeira, não.

Leitura chega
  |
  +-- decide uma escrita? ------------------ sim --> primario, mesma transacao
  |
  +-- tem token de LSN (usuario ou job)?
  |     |
  |     +-- alguma replica com replay >= token? -- sim --> essa replica
  |     |
  |     +-- esperou 150 ms e nenhuma chegou? ----------> primario
  |
  +-- relatorio pesado? -------------------- sim --> replica dedicada
  |
  +-- demais leituras -----------------------------> replica saudavel
                                                     (atraso < limite)

A última linha do diagrama esconde uma decisão de capacidade. Tirar do balanceamento uma réplica cujo atraso passou do limite é correto, mas, se as duas atrasarem juntas, como na importação das duas da tarde, toda a leitura volta para o primário ao mesmo tempo em que ele está processando a rajada de escrita. Antes de ligar essa regra, é preciso saber se o primário aguenta a carga inteira de leitura por alguns minutos. Se não aguenta, o comportamento certo é aceitar réplicas mais atrasadas para leituras sem token durante o pico, ou limitar a própria importação, e não empurrar tudo para o primário.

06

Medir o atraso real e saber quando o token está trabalhando demais

As métricas padrão enganam nos dois sentidos. A expressão now() - pg_last_xact_replay_timestamp(), usada em muitos painéis, mede o tempo desde a última transação aplicada, e não o atraso: com o primário ocioso de madrugada, ela sobe sem parar e dispara alertas falsos. As colunas de lag de pg_stat_replication são mais confiáveis, mas deixam de ser atualizadas quando não há tráfego. A forma robusta é um heartbeat: uma linha que o primário atualiza a cada segundo e que cada réplica compara com o próprio relógio.

-- No primario: uma linha que um job atualiza a cada segundo
CREATE TABLE replicacao_heartbeat (
  id         int PRIMARY KEY,
  gravado_em timestamptz NOT NULL
);
INSERT INTO replicacao_heartbeat VALUES (1, now());

-- Job no primario, a cada segundo
UPDATE replicacao_heartbeat SET gravado_em = now() WHERE id = 1;

-- Em cada replica: atraso real, mesmo quando o primario esta ocioso
SELECT extract(epoch FROM now() - gravado_em) AS atraso_s
FROM replicacao_heartbeat
WHERE id = 1;

O heartbeat depende de os relógios do primário e das réplicas estarem sincronizados por NTP, o que em qualquer ambiente de nuvem moderno significa um erro de poucos milissegundos, desprezível perto dos atrasos que importam. Com o atraso real medido, os indicadores que valem a pena acompanhar são os que mostram tanto a saúde da replicação quanto o esforço da aplicação para esconder o atraso.

  1. Atraso p99 por réplica, medido pelo heartbeat, com alerta quando passa de um segundo por cinco minutos seguidos, e não em um pico isolado.
  2. Percentual de leituras com token que caíram no primário. Se passa de um ou dois por cento, as réplicas estão atrasando mais do que a espera curta cobre, e o primário está absorvendo leitura sem que ninguém perceba.
  3. Tempo gasto esperando a réplica alcançar o token, em p95. Ele é latência que o usuário sente e que não aparece como tempo de consulta.
  4. Consultas canceladas por conflito de replay em cada réplica, que apontam relatórios no lugar errado antes de eles virarem degraus de atraso.
  5. Volume de WAL gerado por minuto no primário, que antecipa o atraso das réplicas e identifica qual job produz as rajadas.

Depois da mudança, o checkout passou a ler o endereço de uma réplica com token em noventa e oito por cento das vezes e do primário nas demais, concentradas nos minutos da importação. Os chamados de endereço que não salva zeraram, os pedidos duplicados por duplo clique também, e a carga de leitura no primário ficou abaixo de quatro por cento do total. A importação passou a ser feita em lotes com pausa entre eles, o que tirou o pico de oito segundos, e o relatório de vendas foi para uma réplica própria.

FAQ

Perguntas frequentes

Isso vale para MySQL, Aurora ou outros bancos com réplica?

O problema vale para qualquer replicação assíncrona, e a solução tem a mesma forma, mudando apenas o identificador de posição. No MySQL com GTID, a sessão pode obter o conjunto de GTIDs da própria escrita com session_track_gtids e, na réplica, esperar por ele com WAIT_FOR_EXECUTED_GTID_SET, que aceita um tempo máximo. No Aurora, as réplicas compartilham o armazenamento e o atraso típico fica abaixo de cem milissegundos, o que reduz a frequência do problema, mas não o elimina: o defeito de validar em réplica antes de gravar continua existindo com qualquer atraso diferente de zero. Bancos gerenciados que oferecem leitura consistente por sessão estão implementando, por dentro, a mesma ideia do token.

Tenho cache na frente do banco. A réplica atrasada também afeta o cache?

Afeta, e de um jeito pior. O fluxo comum é invalidar a chave depois da escrita e deixar a próxima leitura repopular o cache. Se essa leitura vai para uma réplica atrasada, ela grava o valor antigo de volta, e agora o dado velho não dura o atraso da réplica, dura o TTL do cache, que pode ser de minutos ou horas. As correções são repopular o cache com o valor que acabou de ser escrito, na própria escrita, ou fazer a leitura que repopula com o token de LSN, ou ainda adiar a invalidação com uma segunda remoção alguns segundos depois. A primeira é a mais simples quando a escrita já tem o objeto completo em mãos.

Não é mais simples ler do primário por alguns segundos depois de qualquer escrita?

É mais simples e é um bom primeiro passo, porque resolve a maior parte dos casos com uma marca de tempo na sessão. Os limites aparecem com o tempo. A janela aposta em um número que o pico de atraso eventualmente ultrapassa, e nesse momento o defeito volta exatamente quando o sistema está sob mais carga. Ela também manda para o primário todas as leituras do usuário, inclusive listagens pesadas que não têm nada a ver com o que ele escreveu. E ela não cobre jobs assíncronos, que não têm sessão. Se você começar pela janela, meça quantas leituras ela envia ao primário e quantas vezes o atraso passa do limite; quando qualquer um dos dois crescer, é hora de trocar pelo token.

Réplica atrasada não é defeito do banco, é uma garantia que a aplicação precisa pedir

A replicação assíncrona entrega os dados a todas as réplicas, só não promete quando. Em operação normal o atraso é de milissegundos, mas rajadas de escrita, consultas longas na réplica e recursos saturados produzem picos de segundos, e é nos picos que o usuário salva e não vê, o pedido abre em 404 e a validação aprova o que não devia. Leituras que decidem escritas vão para o primário, na mesma transação. Leituras de quem acabou de escrever usam a posição do WAL como token e só aceitam réplicas que já chegaram lá. Jobs carregam o token na mensagem, e o atraso real é medido por heartbeat. Posso revisar como a sua aplicação distribui leituras entre primário e réplicas, implementar a garantia de ler o que se escreveu e montar o monitoramento que mostra quando ela está sendo exigida demais.