MÓDULO 03 · CONCEITO 07 DE 8

Replicação, sharding & particionamento

Primary-replica, leituras na réplica, lag. Sharding por chave, partitioning lógico, hot shards e o custo do rebalanceamento.

Tempo de leitura ~22 min Pré-requisito ACID + planos de execução Próximo ORMs e suas armadilhas

Banco em uma máquina é fácil de entender. Quando precisa escalar — porque uma máquina não dá conta da carga, ou porque a sobrevivência exige redundância — entram três técnicas distintas, frequentemente confundidas: replicação (mesmo dado em múltiplas máquinas para alta disponibilidade e leitura escalada), particionamento (a tabela é fisicamente dividida em partes menores, no mesmo nó ou em vários), e sharding (dado é segmentado entre nós distintos, cada um responsável por sua fatia).

Cada técnica resolve um problema diferente. Replicação não escala writes — apenas reads e disponibilidade. Particionamento não distribui o load entre máquinas se for tudo no mesmo nó. Sharding distribui mas paga em complexidade enorme. Saber qual é qual, e qual seu sistema realmente precisa, é trabalho de arquitetura.

Este conceito percorre as três, com Postgres como referência principal, e termina nos padrões e armadilhas — hot shards, lag de replicação, transações distribuídas, rebalanceamento. Para sistemas que ainda não chegaram nessa escala, é mapa preventivo: identifica decisões de modelagem que evitam dor no futuro, mesmo sem aplicar tudo agora.

Replicação — o que é, por que existe

Replicação é manter cópias do mesmo dado em múltiplas instâncias. Em Postgres, a forma canônica é "streaming replication": o WAL (Write-Ahead Log) gerado pelo primário é streamado para réplicas que aplicam as mudanças sequencialmente. Configuração via pg_basebackup + standby.signal + primary_conninfo.

Os dois objetivos típicos:

Alta disponibilidade (HA)

Quando o primário cai, uma réplica é promovida a primário. Failover pode ser manual ou automático (Patroni, repmgr, pg_auto_failover são as ferramentas comuns). RTO (recovery time objective) é função da automação; RPO (recovery point objective) depende de o quanto de WAL não foi replicado no momento da falha — em replicação assíncrona, milissegundos a segundos podem ser perdidos.

Escala de leitura

Réplicas servem queries de leitura. Aplicação roteia leituras para réplica e escritas para primário — leituras escalam adicionando réplicas. Cuidado: writes não escalam; todas precisam ir ao primário, que ainda é o gargalo de escrita.

Síncrona vs assíncrona — o trade-off central

Postgres permite configurar replicação como síncrona ou assíncrona por réplica.

Configuração comum em produção: replicação síncrona com a réplica mais próxima (mesmo data center) e assíncrona com réplicas geograficamente distantes. Compromisso entre durabilidade e latência.

Lag de replicação — o problema operacional

Em assíncrona, há sempre uma defasagem (lag) entre primário e réplica. Em condições normais, milissegundos. Sob carga ou problemas de rede, pode crescer minutos. As consequências:

Soluções padrão:

  1. Read-your-write via session: depois de write, próximas queries do mesmo usuário/sessão vão para primário por uma janela. Ferramentas como Bouncer e PgPool têm padrões para isso.
  2. Replicação síncrona em paths críticos: para operações onde RPO=0 importa, force réplica síncrona.
  3. LSN tracking: aplicação guarda o LSN do último write; antes de ler de réplica, espera réplica atingir esse LSN. Postgres expõe via pg_current_wal_lsn() no primário e pg_last_wal_replay_lsn() na réplica.
  4. Monitor lag obsessivamente: alertas em pg_stat_replication quando lag passa de threshold. Vital em produção.
armadilha em produção

Aplicação roteando todas as leituras para réplica para "aliviar primário" sem considerar lag. Um padrão de bug recorrente: criar entidade, redirect imediato para sua página, página lê de réplica, vê 404, usuária pensa que o sistema falhou. Implemente read-your-write antes de rotear leituras genericamente.

Particionamento — dividir tabela grande em pedaços

Particionamento quebra uma tabela grande em sub-tabelas ("partições") por algum critério: range (datas), list (categorias) ou hash (chave). Postgres suporta particionamento declarativo desde a versão 10 (2017), com melhorias substanciais em cada release subsequente.

O ganho não é só performance. Particionamento facilita:

-- Particionamento por range em criado_em
CREATE TABLE eventos (
    id BIGSERIAL,
    usuario_id BIGINT,
    tipo VARCHAR(50),
    payload JSONB,
    criado_em TIMESTAMPTZ NOT NULL,
    PRIMARY KEY (id, criado_em)  -- precisa incluir chave de partição
) PARTITION BY RANGE (criado_em);

-- Partições mensais
CREATE TABLE eventos_2026_01 PARTITION OF eventos
    FOR VALUES FROM ('2026-01-01') TO ('2026-02-01');
CREATE TABLE eventos_2026_02 PARTITION OF eventos
    FOR VALUES FROM ('2026-02-01') TO ('2026-03-01');
-- ...

-- Drop de dados velhos: instantâneo
DROP TABLE eventos_2025_01;

Cuidados que evitam dor:

Sharding — quando uma máquina não basta

Sharding é a fase final: dado distribuído entre múltiplos nós, cada um responsável por sua fatia (shard). Nenhum nó tem o conjunto inteiro. Escala writes proporcionalmente ao número de shards, mas paga em complexidade enorme.

Estratégias comuns de chave de shard:

Hash sharding

shard = hash(key) % N. Distribuição uniforme; fácil de entender. Problema: rebalanceamento ao adicionar shard exige re-hash de quase tudo. Consistent hashing (Karger et al., 1997) resolve isso parcialmente — re-hash afeta só uma fatia proporcional ao novo shard.

Range sharding

Por intervalo de chave: shard 1 tem usuários 0-1M, shard 2 tem 1M-2M, etc. Vantagem: queries de range eficientes. Desvantagem: skew se a distribuição é desigual (usuários novos vão todos para o último shard, hot spot).

Geographic sharding

Por região geográfica. Útil para sistemas globais (cliente BR em shard SP, cliente EU em shard FR). Reduz latência. Mas transações cross-region viram pesadelo.

Tenant-based sharding

Cada tenant (cliente B2B) em seu shard. Isolamento natural; compliance facilitada. Problemas: tenants gigantes ficam sozinhos em shard saturado; tenants pequenos sub-utilizam o shard.

Os custos do sharding

Antes de shardar, é importante saber o que você está assumindo:

A heurística clara: esgote alternativas antes de shardar. Verticalize (hardware maior), tunifique queries, adicione cache, particione no mesmo nó, adicione réplicas de leitura. Postgres em hardware moderno aguenta TBs de dado e dezenas de milhares de TPS. Quem shard cedo demais paga em complexidade que não recupera.

heurística do sênior

Antes de propor sharding, mostre o gargalo medido. pg_stat_statements + métricas de IO + load average + tamanho de tabela. Se o sistema fica em 30% de CPU e o problema é uma query mal-otimizada, sharding não resolve. Sharding endereça gargalos de write throughput em sistemas que já fizeram tudo o que era possível em um nó.

Citus, CockroachDB, Spanner — quando o gerenciado vence

Para sistemas que realmente precisam de escala distribuída, há opções que escondem boa parte da complexidade:

A questão prática é: o que você está construindo precisa de escala que justifica essa categoria? Para sistemas com volume de B2B SaaS típico (até dezenas de milhares de tenants), Postgres bem-tunado quase sempre basta. Para sistemas com volume Spotify/Uber/Stripe, conversa é outra.

Roteamento da aplicação — primário vs réplica

Aplicação precisa decidir, para cada query, se vai ao primário ou réplica. Padrões:

DataSource separado por intenção

Aplicação tem dois pools: writeDb (primário) e readDb (réplica, possivelmente load-balanceada entre várias). Convenção no código define qual usar.

Routing baseado em transação

Toda transação vai ao primário (writes precisam, leituras dentro de transação read-your-write). Queries fora de transação podem ir à réplica. Frameworks como Hibernate têm @ReadOnly que dispara essa decisão.

Routing baseado em "session affinity"

Após write, o usuário é "preso" ao primário por X segundos. Após X segundos sem write, próximas leituras voltam à réplica. Implementado em proxy (PgBouncer com plugin) ou na aplicação.

Importante: não exponha a decisão a cada chamador. Encapsule em uma camada de repositório/dao que recebe a query e decide. Cada chamador escrevendo "vou pra réplica" vira inconsistência rápido.

Roteamento nas três linguagens

C# — DataSource separado
public class CatalogDb {
    private readonly NpgsqlDataSource _writer;
    private readonly NpgsqlDataSource _reader;

    public CatalogDb(string writerConn, string readerConn) {
        _writer = NpgsqlDataSource.Create(writerConn);
        _reader = NpgsqlDataSource.Create(readerConn);
    }

    public NpgsqlConnection OpenWrite() => _writer.OpenConnection();
    public NpgsqlConnection OpenRead(bool readYourWrite = false) =>
        readYourWrite ? _writer.OpenConnection() : _reader.OpenConnection();
}

// Padrão para read-your-write: marcar contexto após write
public async Task CriarPedidoAsync(Pedido p) {
    using var conn = db.OpenWrite();
    await conn.ExecuteAsync(@"INSERT INTO pedidos ...", p);
    UserContext.RecentlyWrote = true;  // sticky por 5s
}

EF Core 9 pode ter dois DbContext apontando para conexões diferentes; o trade-off é manter a abstração coerente. Ferramentas como YugabyteDB Smart Driver fazem decisão automática baseada em endpoint discovery.

Python — engines separadas
writer = create_engine(WRITER_URL, pool_size=5)
reader = create_engine(READER_URL, pool_size=20)

class Database:
    def __init__(self):
        self.WriterSession = sessionmaker(bind=writer)
        self.ReaderSession = sessionmaker(bind=reader)

    def session_for(self, write: bool, read_your_write: bool = False):
        if write or read_your_write:
            return self.WriterSession()
        return self.ReaderSession()

# SQLAlchemy >= 1.4 oferece "binds" por mapper:
# engine_map = {Pedido: writer, Catalog: reader}
# session = Session(binds=engine_map)
# permite usar a mesma session com routing automático por entidade.

SQLAlchemy oferece binds para roteamento por tabela ou tipo de operação. Para read-your-write, manter flag em context (Flask g, request scope) e escolher writer durante a janela de stickiness.

Go — pools separados
type DB struct {
    Write *pgxpool.Pool
    Read  *pgxpool.Pool
}

func New(writeURL, readURL string) (*DB, error) {
    w, err := pgxpool.New(ctx, writeURL)
    if err != nil { return nil, err }
    r, err := pgxpool.New(ctx, readURL)
    if err != nil { return nil, err }
    return &DB{Write: w, Read: r}, nil
}

// Repositório expõe duas variantes:
func (r *PedidoRepo) Criar(ctx context.Context, p Pedido) error {
    _, err := r.db.Write.Exec(ctx, `INSERT INTO pedidos ...`, ...)
    return err
}

func (r *PedidoRepo) ListarPorCliente(ctx context.Context,
    clienteID int64, opts ReadOpts) ([]Pedido, error) {
    pool := r.db.Read
    if opts.ReadYourWrite { pool = r.db.Write }
    rows, _ := pool.Query(ctx, `SELECT * FROM pedidos WHERE cliente_id=$1`, clienteID)
    return scanPedidos(rows)
}

Em Go, separar pools é literal. Não há abstração mágica; repositórios decidem explicitamente. A clareza vem do custo — mas torna o caminho do código auditável em troca.

Como praticar

  1. Configure replicação primário-réplica via docker-compose. Crie aplicação que escreve no primário e lê da réplica. Force lag artificial (parando a réplica por alguns segundos) e veja o efeito real em queries.
  2. Particione uma tabela com 100M linhas. Compare tempos de query antes/depois, comparativo VACUUM, e teste o ganho de DROP PARTITION em vez de DELETE de milhões.
  3. Modele um sharding hipotético do projeto do módulo. Escreva um ADR: qual chave de shard, quais queries seriam afetadas, quais joins precisariam ser reescritos, quais transações seriam quebradas. Não implemente — só mapeie. O exercício é ver o custo escondido.

Referências para aprofundar

  1. livro Designing Data-Intensive Applications — Martin Kleppmann (2017). Cap. 5 (Replication) e 6 (Partitioning) são tratamentos canônicos. Kleppmann distingue conceitos com clareza incomum.
  2. livro Database Reliability Engineering — Laine Campbell & Charity Majors (2017). Capítulos sobre replicação operacional, failover, capacity planning. Visão prática de DBA moderno.
  3. livro Postgres High Performance — Gregory Smith (3ª ed., 2024). Cobre tuning, replicação e particionamento em detalhe. Material referência para quem opera Postgres em escala.
  4. livro Database Internals — Alex Petrov (2019). Cap. sobre consensus (Paxos, Raft) explicam como CockroachDB, Spanner e ferramentas distribuídas mantêm consistência.
  5. paper Spanner: Google's Globally Distributed Database — Corbett et al. (2012). research.google — paper do Spanner. Mostra como TrueTime + Paxos viabilizam consistência forte em escala global.
  6. paper Consistent Hashing and Random Trees — Karger et al. (1997). Paper original de consistent hashing. Curto, formativo. Aparece em quase todo sistema distribuído desde então.
  7. artigo How Discord Stores Trillions of Messages — Discord Engineering. discord.com/blog — case study de migração de Cassandra para ScyllaDB com sharding por canal. Volume real, decisões reais.
  8. artigo Why we built our own database — Gitlab Engineering. about.gitlab.com/blog — Gitlab tem ótima documentação sobre evolução de scaling Postgres com pgbouncer e read replicas.
  9. artigo Lessons from Postgres at scale — Citus blog. citusdata.com/blog — vários posts sobre quando shardar e como Citus implementa distribuição transparente.
  10. docs PostgreSQL Documentation — High Availability, Load Balancing, and Replication. postgresql.org/docs/current/high-availability.html — capítulo 27. Cobre streaming replication, logical replication, failover.
  11. docs PostgreSQL Documentation — Table Partitioning. postgresql.org/docs/current/ddl-partitioning.html — capítulo 5.11. Cobertura oficial completa de range/list/hash partitioning.
  12. vídeo Sharding Patterns and Anti-Patterns — vários autores em PgConf/PGCon. YouTube. Talks anuais cobrindo casos práticos de Postgres em escala. Procure os do Citus/Microsoft team.