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.
- Assíncrona (default): primário comita independente das réplicas. Latência de escrita igual à do primário sozinho. Risco: em crash, transações confirmadas no primário podem não ter chegado nas réplicas. RPO > 0.
-
Síncrona: primário só confirma após pelo
menos uma réplica receber/aplicar (configurável via
synchronous_commit:remote_write,on,remote_apply). Garante durabilidade cross-host. Custo: latência de escrita inclui round-trip para a réplica. RPO = 0.
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:
- Read-your-write: usuário cria pedido, navega para listagem, listagem lê de réplica, ainda não chegou — pedido "sumiu". Bug clássico.
- Consistência entre queries: dashboard com métricas inconsistentes porque cada query foi para uma réplica em estado diferente.
- Failover demorado: lag de 30 segundos significa que após failover você perdeu 30 segundos de writes (em assíncrona). Em sistemas críticos, intolerável.
Soluções padrão:
- 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.
- Replicação síncrona em paths críticos: para operações onde RPO=0 importa, force réplica síncrona.
-
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 epg_last_wal_replay_lsn()na réplica. -
Monitor lag obsessivamente: alertas em
pg_stat_replicationquando lag passa de threshold. Vital 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:
-
Drop de dados antigos: tabela
logs_2024_q1some viaDROP TABLEinstantâneo, em vez deDELETEde milhões de linhas que gera bloat. - Queries que filtram por chave de partição: Postgres faz "partition pruning" — só toca as partições relevantes. Tabela de 1B linhas vira efetivamente várias de 50M.
-
Operações de manutenção:
VACUUM,ANALYZE,REINDEXpodem rodar por partição em paralelo. - Bulk loads: novas partições já chegam prontas; antigas só são lidas, sem contenção.
-- 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:
- Toda chave única precisa incluir a coluna de partição. Postgres exige — isso pode reformatar sua chave primária.
- Foreign keys de outras tabelas para uma tabela particionada são suportadas a partir do 12; reverso (FK de tabela particionada) também. Em versões antigas, há restrições.
-
Crie partições com antecedência. Inserção
em data sem partição correspondente falha.
pg_partmanautomatiza criação rolling. - Não particione "por garantia". Particionamento adiciona complexidade. Faça quando há dor concreta de gestão de tamanho — geralmente acima de 100M linhas.
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:
- Transações cross-shard: precisariam de 2PC (two-phase commit) ou sagas. Caros, complexos, problemáticos. Padrão: desenhar o domínio para que transações respeitem o limite de shard.
- Joins cross-shard: muitas vezes impraticáveis em escala. Solução: denormalizar, ou fazer joins na aplicação, ou redesenhar para que os dados relacionados estejam no mesmo shard.
- Hot shards: chave mal-escolhida causa concentração. Um cliente popular gera 80% do load do shard. Sistema sofre apesar de N shards.
- Rebalanceamento: adicionar shard requer mover dados. Em sistema vivo, isso é dança coreografada de leituras-em-trânsito, escritas-em-trânsito, dual-write.
- Operação multi-shard: backup, restore, DDL, upgrade de versão — tudo multiplicado. Time precisa de ferramentas próprias.
- Distributed query routing: aplicação ou proxy precisa decidir para qual shard mandar cada query. Citus (extensão Postgres), Vitess (MySQL), CockroachDB oferecem essa camada — mas adicionam outra peça operacional.
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.
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:
- Citus: extensão Postgres que distribui tabelas em múltiplos nós. Permite "distribuir" tabelas específicas e manter "reference tables" replicadas. Postgres normal por baixo — você paga apenas em features que dependem de cross-shard.
- CockroachDB: SQL distribuído desde o primeiro dia. Compatível com Postgres wire protocol. Transações distribuídas via Raft. Trade-off: latência maior que Postgres single-node.
- Spanner / AlloyDB / Aurora: ofertas managed das nuvens com características diferentes. Spanner oferece consistência globalmente (TrueTime, atomic clocks); Aurora separa storage de compute para escalar leitura.
- Vitess: solução MySQL, originada no YouTube. Bom histórico em escalas absurdas. Não-trivial de operar.
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
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.
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.
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
- 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.
-
Particione uma tabela com 100M linhas.
Compare tempos de query antes/depois, comparativo
VACUUM, e teste o ganho deDROP PARTITIONem vez deDELETEde milhões. - 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
- livro Designing Data-Intensive Applications — Martin Kleppmann (2017).
- livro Database Reliability Engineering — Laine Campbell & Charity Majors (2017).
- livro Postgres High Performance — Gregory Smith (3ª ed., 2024).
- livro Database Internals — Alex Petrov (2019).
- paper Spanner: Google's Globally Distributed Database — Corbett et al. (2012).
- paper Consistent Hashing and Random Trees — Karger et al. (1997).
- artigo How Discord Stores Trillions of Messages — Discord Engineering.
- artigo Why we built our own database — Gitlab Engineering.
- artigo Lessons from Postgres at scale — Citus blog.
- docs PostgreSQL Documentation — High Availability, Load Balancing, and Replication.
- docs PostgreSQL Documentation — Table Partitioning.
- vídeo Sharding Patterns and Anti-Patterns — vários autores em PgConf/PGCon.