Teorema CAP e consistência
A escolha obrigatória entre consistência e disponibilidade quando a rede se divide, e os níveis de consistência entre os dois extremos.
Esta página mostra como garantir que o usuário veja o que acabou de salvar, num PostgreSQL com um banco principal e uma réplica de leitura assíncrona. Tudo foi testado num par principal e réplica montado com Docker, com a aplicação das alterações pausada na réplica para simular atraso.
A ideia do token de posição
Todo banco PostgreSQL mantém um registro sequencial de alterações, o write-ahead log. Cada ponto desse registro tem uma posição, o LSN (log sequence number), que só cresce. A réplica aplica as alterações nessa mesma ordem e sabe até onde já chegou.
O fluxo fica assim:
- A escrita vai para o principal. Depois do
COMMIT, a aplicação lê a posição atual do registro do principal. - Essa posição, o token, é guardada na sessão do usuário ou devolvida ao cliente.
- Na próxima leitura, a aplicação pergunta à réplica se ela já aplicou até aquela posição.
- Se sim, lê da réplica. Se não, lê do principal.
As duas funções do PostgreSQL envolvidas:
-- No principal, depois do COMMIT: a posição atual do registro
SELECT pg_current_wal_lsn();
-- Na réplica: ela já aplicou tudo até a posição do token?
SELECT pg_last_wal_replay_lsn() >= '0/3000108'::pg_lsn;No teste, com a réplica pausada, ela respondeu false e ainda devolvia o status antigo. Depois de retomada, respondeu true e devolvia o status novo.
Escrever e ler com o token
import { Pool } from "pg";
const principal = new Pool({ connectionString: process.env.DATABASE_URL });
const replica = new Pool({ connectionString: process.env.DATABASE_REPLICA_URL });
// Grava no principal e devolve o token, lido DEPOIS do commit
export async function alterarStatus(id: number, status: string) {
await principal.query("UPDATE pedidos SET status = $1 WHERE id = $2", [status, id]);
const { rows } = await principal.query("SELECT pg_current_wal_lsn() AS lsn");
return rows[0].lsn as string;
}
export async function lerPedido(id: number, token?: string) {
let banco = replica;
if (token) {
const { rows } = await replica.query(
"SELECT coalesce(pg_last_wal_replay_lsn() >= $1::pg_lsn, false) AS em_dia",
[token],
);
if (!rows[0].em_dia) banco = principal;
}
const { rows } = await banco.query("SELECT id, status FROM pedidos WHERE id = $1", [id]);
return rows[0];
}import psycopg
principal = psycopg.connect(DATABASE_URL, autocommit=True)
replica = psycopg.connect(DATABASE_REPLICA_URL, autocommit=True)
def alterar_status(pedido_id: int, status: str) -> str:
"""Grava no principal e devolve o token, lido DEPOIS do commit."""
principal.execute(
"UPDATE pedidos SET status = %s WHERE id = %s", (status, pedido_id)
)
return principal.execute("SELECT pg_current_wal_lsn()::text").fetchone()[0]
def ler_pedido(pedido_id: int, token: str | None = None):
banco = replica
if token:
em_dia = replica.execute(
"SELECT coalesce(pg_last_wal_replay_lsn() >= %s::pg_lsn, false)",
(token,),
).fetchone()[0]
if not em_dia:
banco = principal
return banco.execute(
"SELECT id, status FROM pedidos WHERE id = %s", (pedido_id,)
).fetchone()package pedidos
import (
"context"
"database/sql"
)
type Repositorio struct {
Principal *sql.DB
Replica *sql.DB
}
// AlterarStatus grava no principal e devolve o token, lido DEPOIS do commit.
func (r *Repositorio) AlterarStatus(ctx context.Context, id int64, status string) (string, error) {
_, err := r.Principal.ExecContext(ctx,
"UPDATE pedidos SET status = $1 WHERE id = $2", status, id)
if err != nil {
return "", err
}
var token string
err = r.Principal.QueryRowContext(ctx,
"SELECT pg_current_wal_lsn()::text").Scan(&token)
return token, err
}
func (r *Repositorio) LerPedido(ctx context.Context, id int64, token string) (string, error) {
banco := r.Replica
if token != "" {
var emDia bool
err := r.Replica.QueryRowContext(ctx,
"SELECT coalesce(pg_last_wal_replay_lsn() >= $1::pg_lsn, false)",
token).Scan(&emDia)
if err != nil || !emDia {
banco = r.Principal
}
}
var status string
err := banco.QueryRowContext(ctx,
"SELECT status FROM pedidos WHERE id = $1", id).Scan(&status)
return status, err
}import java.sql.SQLException;
import javax.sql.DataSource;
public class Pedidos {
private final DataSource principal;
private final DataSource replica;
public Pedidos(DataSource principal, DataSource replica) {
this.principal = principal;
this.replica = replica;
}
// Grava no principal e devolve o token, lido DEPOIS do commit
public String alterarStatus(long id, String status) throws SQLException {
try (var conexao = principal.getConnection()) {
try (var update = conexao.prepareStatement(
"UPDATE pedidos SET status = ? WHERE id = ?")) {
update.setString(1, status);
update.setLong(2, id);
update.executeUpdate(); // autocommit: confirmado aqui
}
try (var lsn = conexao.createStatement();
var resultado = lsn.executeQuery("SELECT pg_current_wal_lsn()::text")) {
resultado.next();
return resultado.getString(1);
}
}
}
public String lerStatus(long id, String token) throws SQLException {
DataSource banco = replica;
if (token != null) {
try (var conexao = replica.getConnection();
var consulta = conexao.prepareStatement(
"SELECT coalesce(pg_last_wal_replay_lsn() >= ?::pg_lsn, false)")) {
consulta.setString(1, token);
try (var resultado = consulta.executeQuery()) {
resultado.next();
if (!resultado.getBoolean(1)) banco = principal;
}
}
}
try (var conexao = banco.getConnection();
var consulta = conexao.prepareStatement(
"SELECT status FROM pedidos WHERE id = ?")) {
consulta.setLong(1, id);
try (var resultado = consulta.executeQuery()) {
return resultado.next() ? resultado.getString(1) : null;
}
}
}
}using Npgsql;
public class Pedidos(NpgsqlDataSource principal, NpgsqlDataSource replica)
{
// Grava no principal e devolve o token, lido DEPOIS do commit
public async Task<string> AlterarStatus(long id, string status)
{
await using (var update = principal.CreateCommand(
"UPDATE pedidos SET status = $1 WHERE id = $2"))
{
update.Parameters.Add(new() { Value = status });
update.Parameters.Add(new() { Value = id });
await update.ExecuteNonQueryAsync();
}
await using var lsn = principal.CreateCommand("SELECT pg_current_wal_lsn()::text");
return (string)(await lsn.ExecuteScalarAsync())!;
}
public async Task<string?> LerStatus(long id, string? token)
{
var banco = replica;
if (token is not null)
{
await using var emDia = replica.CreateCommand(
"SELECT coalesce(pg_last_wal_replay_lsn() >= $1::pg_lsn, false)");
emDia.Parameters.Add(new() { Value = token });
if (!(bool)(await emDia.ExecuteScalarAsync())!) banco = principal;
}
await using var consulta = banco.CreateCommand("SELECT status FROM pedidos WHERE id = $1");
consulta.Parameters.Add(new() { Value = id });
return (string?)await consulta.ExecuteScalarAsync();
}
}O que muda de uma linguagem para outra
As cinco versões fazem a mesma coisa. Os pontos que mudam são onde a confirmação acontece e como o token viaja:
| Onde a escrita é confirmada | Tipo do token | |
|---|---|---|
Node (pg) | Cada pool.query fora de transação já confirma | Texto, como 0/3000108 |
Python (psycopg) | Com autocommit=True; sem ele, só no commit() | Texto, com ::text na consulta |
Go (database/sql) | Cada ExecContext fora de transação já confirma | Texto, com ::text na consulta |
| Java (JDBC) | Com autocommit ligado, o padrão; senão, no commit() | Texto, com ::text na consulta |
| C# (Npgsql) | Cada comando fora de transação já confirma | Texto, com ::text na consulta |
O token precisa chegar à próxima leitura do mesmo usuário: na sessão do servidor, num cookie ou num cabeçalho que o cliente devolve. O coalesce(..., false) cobre o caso em que a consulta cai num banco que não é réplica: lá, pg_last_wal_replay_lsn() devolve nulo, e a leitura vai para o principal.
O erro que torna o token inútil
O token só funciona se for lido depois do COMMIT. Lido dentro da mesma transação da escrita, ele aponta para uma posição anterior à confirmação.
No teste, com a réplica pausada e o token lido dentro da transação, a réplica respondeu que estava em dia, embora ainda não tivesse aplicado a alteração:
BEGIN;
UPDATE pedidos SET status = 'enviado' WHERE id = 981;
SELECT pg_current_wal_lsn(); -- ERRADO: a confirmação ainda não está no registro
COMMIT;réplica em dia? true ← falso positivoO mesmo acontece, sem nenhum BEGIN à vista, quando várias instruções vão juntas numa única chamada ao banco: o PostgreSQL executa as instruções de uma chamada numa transação implícita. Foi assim que o erro apareceu na nossa primeira tentativa do teste.
Monitorar o atraso das réplicas
Saber quanto a réplica está atrasada ajuda a decidir para onde mandar as leituras e a ser avisado antes que os usuários percebam.
-- No principal: o atraso de cada réplica, em bytes do registro
SELECT application_name,
state,
sync_state,
pg_wal_lsn_diff(pg_current_wal_lsn(), replay_lsn) AS bytes_de_atraso
FROM pg_stat_replication;-- Na réplica: há quanto tempo foi aplicada a última transação
SELECT now() - pg_last_xact_replay_timestamp() AS atraso;A segunda consulta engana quando o principal fica muito tempo sem escritas: o "atraso" cresce mesmo com a réplica em dia, porque a última transação aplicada é antiga. A primeira, em bytes, é mais confiável.
Lista de revisão
Leituras
- Decisões de negócio, como saldo, estoque e aprovação, leem do principal?
- Depois de salvar, o usuário lê do principal ou de uma réplica que comprovadamente já aplicou a escrita?
- O token de posição é lido depois do
COMMIT, numa instrução separada? - O token chega à próxima leitura do mesmo usuário?
Escolhas
- Para cada tipo de dado importante, está definido quanto atraso é aceitável?
- Se o banco aceita escritas em vários lugares, a estratégia de conflito foi escolhida de propósito?
- A replicação síncrona, se usada, tem mais de uma réplica para não travar as escritas?
Operação
- O atraso das réplicas é monitorado, com alerta?
- Há um plano para quando a réplica atrasar muito: tirar das leituras, ou mandar tudo ao principal?
Prompts para aprofundar
Ao abrir no ChatGPT ou no Claude, o prompt é enviado ao serviço escolhido pela URL e também fica copiado na sua área de transferência.
Montar um principal e uma réplica e ver o atraso
Quero montar um banco principal e uma réplica de leitura localmente, com Docker, para ver o atraso de replicação acontecer. Pergunte qual banco eu quero usar. Vamos em etapas. Em cada uma, me passe os comandos e explique o que eles fazem: 1. Subir o principal e liberar a conexão de replicação. 2. Criar a réplica a partir de uma cópia do principal e iniciar a replicação. 3. Conferir que a réplica está recebendo as alterações. 4. Pausar a aplicação das alterações na réplica, escrever no principal e ler nos dois. 5. Medir o atraso pelo principal e pela réplica. 6. Retomar a aplicação e conferir que a réplica alcançou o principal. No final, explique o que mudaria com replicação síncrona e como configurá-la.
Implementar o token de posição
Quero implementar a garantia de ler as próprias escritas com um token de posição do registro de alterações. Pergunte qual banco, linguagem, driver e framework eu uso. Explique, com código para o meu caso: 1. Como obter a posição do registro no principal e por que ela precisa ser lida depois do COMMIT, numa instrução separada. 2. Como guardar o token entre requisições: sessão, cookie ou cabeçalho. 3. Como conferir na réplica se ela já chegou àquela posição, tratando o caso em que a consulta cai no principal. 4. Como encapsular isso numa camada de acesso a dados, para que o resto do código não precise lembrar. 5. Como testar, pausando a aplicação das alterações numa réplica local. Aponte os erros mais comuns, como ler o token dentro da transação ou dentro de um bloco transacional aberto pelo ORM.
Revisar leituras em réplicas e caches
Vou colar abaixo código que lê e escreve dados num sistema com réplicas de leitura ou cache. Revise com foco em consistência. Verifique, nesta ordem, e para cada item diga se está correto, incorreto ou ausente, citando a linha: 1. Alguma decisão de negócio (saldo, estoque, aprovação, permissão) é tomada com um dado lido de réplica ou cache? 2. Depois de uma escrita, a leitura do mesmo usuário pode vir de uma cópia atrasada? 3. Se há token de posição, ele é lido depois da confirmação, numa instrução separada? 4. A escolha entre principal e réplica está centralizada, ou espalhada pelo código? 5. Há tratamento para réplica muito atrasada ou fora do ar? 6. Escritas concorrentes em vários lugares têm uma estratégia de conflito explícita? Para cada problema, descreva o que o usuário veria de errado e mostre a correção. Não reescreva o que já está correto. Código: [cole aqui]