← Voltar para o blog
03 de maio de 2026*14 min de leitura

Database Replication na Prática: Emulando um Ambiente de Produção com PostgreSQL, HAProxy e TypeScript

Entenda como a replicação de banco de dados funciona por baixo dos panos no PostgreSQL — WAL streaming, modos assíncrono e síncrono, e como montar um ambiente local com Docker, HAProxy e duas réplicas de leitura. Inclui exemplos práticos em TypeScript com SQL puro, Prisma e Drizzle.

Introdução:

Se você já trabalhou em um sistema com alto volume de leituras, provavelmente já ouviu falar em replicação de banco de dados. Mas entre entender o conceito e ver funcionando de verdade, existe uma distância grande. Neste artigo, vamos fechar essa distância: você vai entender o que é replicação, como o PostgreSQL implementa isso internamente pelo WAL, e como montar um ambiente completo localmente com Docker, HAProxy, e três formas diferentes de consumir tudo isso via TypeScript (SQL puro, Prisma e Drizzle).


O que é Replicação de Banco de Dados?

Replicação é o processo de manter cópias sincronizadas de um banco de dados em múltiplos servidores. O servidor que aceita escritas é chamado de primário (ou primary). Os servidores que recebem e aplicam as mudanças do primário são chamados de réplicas (ou standbys).

O objetivo principal são dois:

  1. Escalabilidade de leitura: distribuir queries de SELECT entre várias réplicas para não sobrecarregar o primário.
  2. Alta disponibilidade: se o primário cair, uma réplica pode ser promovida para assumir o lugar dele.

Na maioria das aplicações, leituras são muito mais frequentes que escritas. Uma página de e-commerce, por exemplo, faz dezenas de SELECT para exibir produtos, categorias e avaliações, mas apenas um INSERT quando o usuário finaliza um pedido. Replicação permite que essas leituras sejam absorvidas por múltiplos servidores em paralelo.


Como o PostgreSQL Replica: O WAL

Por baixo dos panos, o PostgreSQL usa o Write-Ahead Log (WAL) como mecanismo central de replicação. O WAL é uma sequência de logs transacionais armazenados em segmentos de arquivo onde toda modificação de dados é registrada antes de ser aplicada no banco. Isso serve tanto para recuperação de falhas quanto para replicação.

O fluxo funciona assim:

Cliente → INSERT/UPDATE/DELETE
           ↓
     Modificação gravada em memória (WAL buffer + shared buffers)
           ↓
     WAL flushed de forma durável para o disco (ao COMMIT)
           ↓
     ✓ resposta ao cliente (COMMIT confirmado)
           ↓
     WAL sender envia registros para as réplicas (assíncrono)
           ↓
     Réplicas recebem e aplicam (WAL receiver + startup process)

     [em background, independentemente do COMMIT]
     Checkpointer/background writer persistem
     shared buffers nas páginas de dados em disco

As réplicas ficam em modo de recovery: elas ficam continuamente recebendo e aplicando registros WAL do primário. Enquanto estão nesse modo, operam efetivamente como bancos somente leitura para dados persistentes da aplicação — qualquer tentativa de escrita é rejeitada. Você pode verificar isso com:

SELECT pg_is_in_recovery();
-- t  → está em recovery (réplica)
-- f  → é o primário

Replicação Física vs. Lógica

O PostgreSQL suporta dois tipos de replicação:

  • Física (streaming replication): replica o estado binário dos arquivos de dados. É o que usamos neste projeto. É simples, confiável e replica tudo — incluindo estrutura de tabelas.
  • Lógica: replica mudanças a nível de linha (row-level), permitindo selecionar quais tabelas replicar. É mais flexível, mas mais complexa de operar.

Neste projeto usamos streaming replication, que é o padrão para read replicas em produção.

Replication Slots

Quando uma réplica se conecta ao primário via streaming, ela precisa que os segmentos WAL não sejam reciclados antes de consumi-los. É aqui que entram os replication slots.

Um replication slot é um cursor persistente no primário que rastreia até onde cada réplica consumiu o WAL. Enquanto um slot estiver ativo, o PostgreSQL não descartará segmentos WAL ainda não consumidos por ele.

O ponto de atenção é que réplicas lentas ou desconectadas podem fazer o primário acumular WAL indefinidamente, consumindo disco. Em produção, monitore o WAL retido com:

SELECT slot_name, active,
       pg_size_pretty(pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn)) AS retained_wal
FROM pg_replication_slots;

A imagem Bitnami cria os slots automaticamente. Em setups manuais sem slots (usando wal_keep_size), uma réplica muito atrasada pode perder segmentos WAL e precisar ser reconstruída do zero.


Replicação Assíncrona vs. Síncrona

Por padrão, o PostgreSQL usa replicação assíncrona: o primário confirma o COMMIT para o cliente antes de garantir que as réplicas receberam a mudança. Isso é rápido, mas significa que uma leitura imediata de uma réplica pode retornar dados levemente desatualizados.

Escrita assíncrona:

Cliente  →  COMMIT  →  Primário confirma  →  ✓ resposta ao cliente
                             ↓
                       (depois, em background)
                       Réplica recebe e aplica

Para casos onde você precisa de read-after-write consistency nas réplicas — garantir que uma leitura após uma escrita veja a mudança — o PostgreSQL oferece synchronous_commit:

ModoComportamento
offNão espera o flush do WAL (nem local)
localEspera apenas o flush local do WAL, ignora réplicas síncronas
remote_writeEspera a réplica confirmar que recebeu e escreveu o WAL no buffer do SO
onAguarda o nível configurado pela replicação síncrona — é o padrão
remote_applyEspera a réplica aplicar o WAL (dado visível na réplica)

Em setups sem replicação síncrona configurada (como o deste projeto), on se comporta na prática como local.

Com remote_apply, o COMMIT só retorna ao cliente quando as réplicas configuradas como síncronas já aplicaram o WAL e tornaram os dados visíveis para leitura. É o modo mais forte, mas adiciona latência proporcional ao round-trip até as réplicas.


A Arquitetura do Projeto

O projeto monta o seguinte stack com Docker Compose:

┌─────────────────────────────────────────────────────┐
│                    Aplicação                        │
│                                                     │
│  DATABASE_WRITE_URL           DATABASE_READ_URL     │
│  localhost:6432               localhost:6433        │
└──────────────┬────────────────────────┬─────────────┘
               │                        │
               ▼                        ▼
        ┌─────────────────────────────────────┐
        │             HAProxy                 │
        │  :5432 → primary  :5433 → replicas  │
        └──────┬──────────────────┬───────────┘
               │                  │ (round-robin)
               ▼                  ├──────────────────┐
     ┌──────────────────┐         ▼                  ▼
     │  postgres-primary│   ┌──────────┐     ┌──────────┐
     │  (read/write)    │   │ replica-1│     │ replica-2│
     └──────────────────┘   │(readonly)│     │(readonly)│
              │             └──────────┘     └──────────┘
              │                   ▲                 ▲
              │    WAL streaming  │                 │
              └───────────────────┴─────────────────┘

HAProxy: roteador, não replicador

Um ponto importante: o HAProxy não replica dados. Ele apenas roteia conexões. A replicação acontece diretamente entre os containers PostgreSQL via streaming WAL.

O HAProxy expõe duas portas:

  • :5432 (mapeada para localhost:6432): sempre encaminha para o primário. Usada para escritas.
  • :5433 (mapeada para localhost:6433): balanceia em round-robin entre as duas réplicas. Usada para leituras.

O balanceamento ocorre por conexão TCP, não por query individual — uma conexão persistente continua roteada para a mesma réplica até ser fechada. Em aplicações com connection pool, o comportamento observado depende de quantas conexões estão abertas e por quanto tempo.

A configuração do HAProxy é direta:

frontend postgres_write
  bind *:5432
  default_backend postgres_primary

backend postgres_primary
  server primary postgres-primary:5432 check

frontend postgres_read
  bind *:5433
  default_backend postgres_replicas

backend postgres_replicas
  balance roundrobin
  server replica1 postgres-replica-1:5432 check
  server replica2 postgres-replica-2:5432 check

Ele também expõe uma página de status em localhost:8404 que mostra quais backends estão online em tempo real — útil para debugging.

Os health checks usados aqui (check) são verificações TCP simples — o HAProxy confirma apenas que a porta está aceitando conexões, sem distinguir se o servidor é um primário ou uma réplica. Em produção, normalmente usa-se health checks específicos para PostgreSQL para evitar comportamento inesperado durante failover.

Atenção: este setup não implementa failover automático. Se o primário cair, a porta de escrita ficará indisponível até que uma réplica seja promovida manualmente e o roteamento seja atualizado. Em produção, essa promoção costuma ser gerenciada por ferramentas como Patroni, repmgr ou pg_auto_failover.


Configurando a Replicação no Docker Compose

A imagem Bitnami PostgreSQL facilita muito a configuração via variáveis de ambiente. No primário:

postgres-primary:
  image: bitnami/postgresql:latest
  environment:
    POSTGRESQL_REPLICATION_MODE: master
    POSTGRESQL_REPLICATION_USER: replicator
    POSTGRESQL_REPLICATION_PASSWORD: replicator_password

Em cada réplica:

postgres-replica-1:
  image: bitnami/postgresql:latest
  depends_on:
    postgres-primary:
      condition: service_healthy
  environment:
    POSTGRESQL_REPLICATION_MODE: slave
    POSTGRESQL_MASTER_HOST: postgres-primary
    POSTGRESQL_MASTER_PORT_NUMBER: 5432
    POSTGRESQL_REPLICATION_USER: replicator
    POSTGRESQL_REPLICATION_PASSWORD: replicator_password

O depends_on com condition: service_healthy garante que as réplicas só sobem depois que o primário estiver pronto para aceitar conexões — necessário porque as réplicas precisam se conectar ao primário na inicialização para realizar a cópia inicial dos dados antes de começar a seguir o WAL.


Consumindo a Replicação: Três Abordagens em TypeScript

1. SQL Puro com pg

A abordagem mais explícita. Você cria dois pools de conexão separados e escolhe manualmente qual usar:

// projects/raw/db.ts
export const primaryPool = new pg.Pool({
  connectionString: process.env.DATABASE_WRITE_URL
});

export const replicaPool = new pg.Pool({
  connectionString: process.env.DATABASE_READ_URL
});

Escrita vai para o primário:

// write/create-user.ts
await primaryPool.query(
  `INSERT INTO users (name, email) VALUES ($1, $2) RETURNING *`,
  [`Raw User ${suffix}`, `raw-${suffix}@example.com`]
);

Leitura vai para as réplicas:

// read/list-users.ts
const result = await replicaPool.query(
  `SELECT id, name, email FROM users ORDER BY id DESC LIMIT 10`
);

Quando usar: quando você quer controle total e clareza máxima sobre o que está acontecendo. Ótimo para entender o mecanismo ou em sistemas onde o roteamento é crítico.


2. Prisma com @prisma/extension-read-replicas

O Prisma tem uma extensão oficial que faz o roteamento automaticamente. Você configura uma URL de escrita (via schema.prisma) e uma URL de leitura (via extensão), e a extensão roteia automaticamente a maior parte das operações:

  • operações de leitura como findMany, findFirst, count → réplica (na maioria dos contextos)
  • create, update, delete, transações → primário sempre
// projects/prisma/db.ts
export const prisma = new PrismaClient().$extends(
  readReplicas({
    url: process.env.DATABASE_READ_URL!
  })
);

O uso no código de aplicação é idêntico — sem mudanças na forma como você chama o Prisma:

// write/create-user.ts — vai automaticamente para o primário
const user = await prisma.user.create({
  data: { name: `Prisma User ${suffix}`, email: `...` }
});

// read/list-users.ts — vai automaticamente para a réplica
const users = await prisma.user.findMany({
  orderBy: { id: "desc" },
  take: 10
});

Quando usar: quando você usa Prisma e quer o roteamento automático sem mudar a interface do seu código. A extensão é oficial e bem mantida.


3. Drizzle com dois db explícitos

O Drizzle não tem uma abstração de read replicas embutida, mas o padrão idiomático é exportar um objeto db com as duas conexões nomeadas:

// projects/drizzle/db.ts
export const db = {
  write: drizzle(primaryPool),
  read: drizzle(replicaPool)
};

O código de aplicação explicita a intenção:

// write/create-user.ts
const [user] = await db.write
  .insert(users)
  .values({ name: `Drizzle User ${suffix}`, email: `...` })
  .returning();

// read/list-users.ts
const rows = await db.read
  .select()
  .from(users)
  .orderBy(desc(users.id))
  .limit(10);

Quando usar: quando você quer explicitação no código de qual banco está sendo usado. db.write vs db.read deixa claro para qualquer pessoa lendo o código onde cada operação vai.


Verificando que a Replicação Está Funcionando

Após subir o stack com npm run docker:up, você pode confirmar o estado da replicação diretamente:

No primário — ver réplicas conectadas:

docker exec -it postgres-primary psql -U app -d app -c \
  "SELECT application_name, state, sync_state, client_addr FROM pg_stat_replication;"

Você verá algo como:

 application_name |   state   | sync_state |  client_addr
------------------+-----------+------------+--------------
 walreceiver      | streaming | async      | 172.18.0.3
 walreceiver      | streaming | async      | 172.18.0.4

Como este projeto usa replicação assíncrona, o sync_state aparece como async. Ao configurar réplicas síncronas com synchronous_standby_names, esse valor mudaria para sync.

Em uma réplica — confirmar modo read-only:

docker exec -it postgres-replica-1 psql -U app -d app -c \
  "SELECT pg_is_in_recovery();"
 pg_is_in_recovery
-------------------
 t

t = está em recovery = é uma réplica funcionando.

Verificando o balanceamento de carga — confirmando o round-robin entre réplicas:

Execute esse comando várias vezes seguidas contra o endpoint de leitura:

psql "postgresql://app:app@localhost:6433/app" \
  -c "SELECT inet_server_addr() AS replica, pg_is_in_recovery() AS is_replica;"

Você verá os endereços IP alternando entre as duas réplicas a cada chamada:

  replica   | is_replica
------------+------------
 172.18.0.3 | t
(1 row)

  replica   | is_replica
------------+------------
 172.18.0.4 | t
(1 row)

Isso confirma que o HAProxy está distribuindo as conexões em round-robin entre replica-1 e replica-2, e que ambas estão em recovery mode.


O Trade-off Fundamental: Consistência vs. Disponibilidade

Replicação assíncrona (o padrão deste projeto) oferece:

  • Baixa latência de escrita: o COMMIT retorna assim que o primário confirma.
  • Alta disponibilidade: réplicas lentas ou offline não bloqueiam escritas.
  • Eventual consistency: leituras nas réplicas podem ver dados levemente desatualizados.

Se você faz um INSERT no primário e imediatamente lê da réplica, é possível — em condições normais muito raro localmente, mas possível em produção — receber um resultado sem o dado recém-inserido. Para esses casos, leia pelo DATABASE_WRITE_URL.

Em ambientes reais, a defasagem entre primário e réplica (replication lag) pode crescer sob alta carga de escrita, rede instável ou réplicas sobrecarregadas. Quanto maior o lag, mais "velhos" serão os dados retornados pelas réplicas. Monitorar o lag é essencial em produção — a view pg_stat_replication expõe write_lag, flush_lag e replay_lag para cada réplica conectada.

Replicação síncrona com remote_apply resolve a questão de consistência, mas cada escrita passa a depender da latência de rede até as réplicas. Em ambientes de nuvem com réplicas em zonas diferentes, isso pode adicionar dezenas de milissegundos em cada operação de escrita.

Não existe configuração "certa" — existe o trade-off que faz sentido para o seu caso de uso.


Rodando o Projeto

# Clone e configure o ambiente
cp .env.example .env

# Instale as dependências
npm run raw:install
npm run prisma:install
npm run drizzle:install

# Suba o PostgreSQL e o HAProxy
npm run docker:up

# Crie as tabelas via Prisma
npm run prisma:generate
npm run prisma:db:push

# Teste cada abordagem
npm run raw:write && npm run raw:read
npm run prisma:write && npm run prisma:read
npm run drizzle:write && npm run drizzle:read

# Ao terminar
npm run docker:down

Conclusão

Replicação de banco de dados não é mágica — é streaming de WAL, recovery mode e roteamento de conexões. Este projeto desmonta cada camada:

  • PostgreSQL cuida da sincronização via WAL streaming.
  • HAProxy cuida do roteamento das conexões da aplicação.
  • A aplicação decide conscientemente onde enviar cada operação.

As três abordagens TypeScript mostram que o padrão subjacente é sempre o mesmo — dois endpoints, um para escrita e um para leitura — e que a diferença está no nível de abstração que você quer expor no seu código: explícito com SQL puro e Drizzle, ou automático com Prisma.

O código completo está disponível no repositório, com instruções para subir tudo com um único comando.

Código: Github