Voltar ao blog
PostgreSQLBanco de dadosEscalabilidadePerformanceSaaS

Escalando PostgreSQL: índices, particionamento e réplicas de leitura na prática

2026-06-28
Escalando PostgreSQL: índices, particionamento e réplicas de leitura na prática

Quase todo banco PostgreSQL nasce rápido. Com dez mil linhas, qualquer query voa, qualquer índice parece supérfluo e o SELECT * sem filtro passa despercebido. O problema aparece depois: a tabela cruza os cem milhões de linhas, o relatório que rodava em 40ms agora leva 12 segundos, e a réplica que ninguém configurou começa a fazer falta às 9h da manhã de segunda-feira.

Escalar PostgreSQL não é trocar de banco nem "adicionar mais máquina". É entender onde o tempo é gasto e aplicar, na ordem certa, três alavancas: índices bem desenhados, particionamento de tabelas grandes e distribuição de carga de leitura entre réplicas. Este guia percorre as três com foco no que realmente muda o número no dashboard.

Antes de escalar: meça com EXPLAIN ANALYZE

A primeira regra de otimização de banco é não otimizar no escuro. O EXPLAIN ANALYZE mostra o plano real de execução de uma query: quais índices foram usados, quantas linhas foram varridas e onde o tempo foi gasto.

EXPLAIN (ANALYZE, BUFFERS)
SELECT * FROM pedidos
WHERE tenant_id = '8f3b...' AND status = 'pago'
ORDER BY criado_em DESC
LIMIT 50;

O que procurar na saída:

  • Seq Scan em tabela grande é quase sempre um sinal de índice faltando. O banco está lendo a tabela inteira.
  • rows estimado muito diferente do actual indica estatísticas desatualizadas; rode ANALYZE na tabela.
  • Buffers: shared read alto significa que o dado veio do disco, não do cache. Memória ou índice melhor resolvem.

Otimize com base no plano, nunca no palpite. Uma query lenta pode estar lenta por três motivos diferentes, e cada um tem uma cura distinta.

Índices: a alavanca de maior retorno

Um índice bem escolhido transforma uma varredura de milhões de linhas em algumas leituras de páginas. Mas índice não é grátis: cada um ocupa disco e torna INSERT/UPDATE mais lentos. A arte está em indexar o que as queries realmente filtram e ordenam.

Tipos de índice que importam

Tipo Quando usar Exemplo de caso
B-tree Igualdade e faixa (=, <, >, BETWEEN, ORDER BY) Filtro por data, status, id
GIN Busca em jsonb, arrays, full-text search Consultar chaves dentro de um campo JSON
BRIN Tabelas enormes com dados naturalmente ordenados Logs por timestamp, séries temporais
Hash Somente igualdade estrita Lookup por chave exata
Parcial Indexar só um subconjunto das linhas WHERE status = 'ativo'

Índices compostos e a regra da ordem

Em queries que filtram por várias colunas, um índice composto costuma vencer vários índices separados. A ordem das colunas importa: coloque primeiro a coluna de maior seletividade ou a usada em igualdade, e por último a de ordenação.

-- Serve para filtrar por tenant + status e já ordena por data
CREATE INDEX idx_pedidos_tenant_status_data
  ON pedidos (tenant_id, status, criado_em DESC);

Esse índice cobre a query do exemplo anterior por inteiro: o WHERE usa as duas primeiras colunas e o ORDER BY ... LIMIT aproveita a terceira sem precisar de um sort adicional.

Índices parciais e de cobertura

  • Índice parcial reduz drasticamente o tamanho quando você consulta sempre um recorte: CREATE INDEX ... ON pedidos (criado_em) WHERE status = 'pendente'. Se 95% dos pedidos já foram processados, o índice guarda só os 5% relevantes.
  • Índice de cobertura (INCLUDE) permite que o banco responda a query só pelo índice, sem tocar a tabela, num index-only scan.

O erro clássico é o oposto: indexar demais. Índices redundantes ou nunca usados só pesam na escrita. Consulte pg_stat_user_indexes para achar índices com zero leituras e removê-los.

Particionamento: quebrando a tabela gigante

Quando uma tabela cresce para centenas de milhões de linhas, mesmo com bons índices o VACUUM fica lento, os índices incham e certas queries perdem eficiência. O particionamento declarativo (nativo desde o PostgreSQL 10 e maduro nas versões atuais) divide uma tabela lógica em várias fisicamente menores.

Particionamento por range

Ideal para dados temporais. Cada partição guarda uma faixa de datas:

CREATE TABLE eventos (
  id         bigint,
  tenant_id  uuid,
  criado_em  timestamptz NOT NULL,
  payload    jsonb
) PARTITION BY RANGE (criado_em);

CREATE TABLE eventos_2026_06 PARTITION OF eventos
  FOR VALUES FROM ('2026-06-01') TO ('2026-07-01');

CREATE TABLE eventos_2026_07 PARTITION OF eventos
  FOR VALUES FROM ('2026-07-01') TO ('2026-08-01');

O ganho vem do partition pruning: uma query com WHERE criado_em >= '2026-07-01' toca só a partição de julho, ignorando todo o resto. Outro benefício enorme é o descarte por DROP: expirar dados antigos vira um DROP TABLE eventos_2025_01, instantâneo, em vez de um DELETE que gera milhões de tuplas mortas.

Particionamento por hash e por lista

  • Hash distribui as linhas uniformemente entre N partições, útil para espalhar carga de escrita quando não há uma chave temporal natural. Comum em SaaS multi-tenant, particionando por tenant_id.
  • Lista agrupa por valores discretos, como região ou país.

O que observar ao particionar

  • A chave de partição precisa estar nas queries para o pruning funcionar; caso contrário o banco varre todas as partições.
  • Índices e constraints são definidos por partição, então automatize a criação de novas partições (por exemplo, com pg_partman) para não esquecer a de "mês que vem".
  • Particionar cedo demais é overengineering. Reserve para tabelas que realmente doem: histórico, eventos, logs, séries temporais.

Réplicas de leitura: distribuindo a carga

Índices e particionamento otimizam o trabalho; réplicas distribuem esse trabalho. A replicação em streaming do PostgreSQL mantém uma ou mais réplicas quase em tempo real com o primário, aplicando o WAL (Write-Ahead Log) continuamente.

O padrão de uso é direto: o primário recebe as escritas; as réplicas absorvem as leituras. Relatórios, dashboards, buscas e exportações (tudo que só lê) vão para as réplicas, aliviando o primário para o caminho crítico transacional.

Réplica síncrona vs. assíncrona

  • Assíncrona (padrão): o primário confirma o commit sem esperar a réplica. Rápido, mas há uma janela de replication lag: a réplica pode estar alguns milissegundos (ou segundos, sob carga) atrás.
  • Síncrona: o commit só confirma quando a réplica também gravou. Garante zero perda, ao custo de latência de escrita maior.

O cuidado central: lag de replicação

O maior tropeço com réplicas é ler dado desatualizado. Um usuário salva um registro (vai ao primário) e, na tela seguinte, a leitura vai para uma réplica que ainda não recebeu a mudança, e o registro "some". Estratégias para lidar:

  • Read-your-writes: após uma escrita do usuário, force as leituras seguintes dele ao primário por um curto período.
  • Monitore o lag com pg_stat_replication e tenha alerta quando ele passar de um limiar aceitável.
  • Roteie por natureza da query, não por acaso: dados que exigem consistência forte vão ao primário; relatórios toleram segundos de atraso e vão às réplicas.

Connection pooling e VACUUM: o básico que sustenta a escala

Duas peças sem glamour que, ignoradas, derrubam qualquer estratégia acima:

  • Connection pooling. Cada conexão do PostgreSQL consome memória e um processo. Aplicações modernas abrem centenas de conexões simultâneas e o banco sufoca. Um pooler como o PgBouncer em modo transaction multiplexa milhares de clientes sobre poucas conexões reais. É praticamente obrigatório acima de algumas dezenas de conexões.
  • VACUUM e autovacuum. O modelo MVCC do PostgreSQL cria uma nova versão a cada UPDATE/DELETE; as versões antigas (dead tuples) precisam ser recolhidas pelo VACUUM. Sob escrita intensa, um autovacuum mal ajustado deixa a tabela inchar (bloat), degradando tudo. Monitore n_dead_tup em pg_stat_user_tables e ajuste os limiares de autovacuum nas tabelas quentes.

A ordem certa de escalar

Escalar PostgreSQL é uma escada, não um interruptor. Suba um degrau de cada vez e meça antes de subir o próximo:

  1. Meça com EXPLAIN ANALYZE e ache a query cara real.
  2. Indexe o que ela filtra e ordena; remova índices mortos.
  3. Ajuste autovacuum e coloque um pooler na frente.
  4. Particione apenas as tabelas gigantes que doem.
  5. Replique para tirar a carga de leitura do primário.

Só depois disso a conversa vira sharding ou troca de tecnologia, e a maioria dos produtos nunca chega lá, porque os cinco passos acima resolvem ordens de magnitude de crescimento. O PostgreSQL aguenta muito mais do que a maioria dos times explora dele.


Seu banco está começando a doer sob carga? Na ConstructorTI, tratamos escalabilidade de dados como engenharia, não como tentativa e erro em produção: diagnóstico com EXPLAIN, plano de indexação, particionamento e topologia de réplicas desenhados para o seu padrão de acesso real. Se as queries estão ficando lentas conforme os clientes crescem, fale com a nossa engenharia e receba um diagnóstico técnico do seu PostgreSQL.