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 Scanem tabela grande é quase sempre um sinal de índice faltando. O banco está lendo a tabela inteira.rowsestimado muito diferente doactualindica estatísticas desatualizadas; rodeANALYZEna tabela.Buffers: shared readalto 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_replicatione 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 peloVACUUM. Sob escrita intensa, um autovacuum mal ajustado deixa a tabela inchar (bloat), degradando tudo. Monitoren_dead_tupempg_stat_user_tablese 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:
- Meça com
EXPLAIN ANALYZEe ache a query cara real. - Indexe o que ela filtra e ordena; remova índices mortos.
- Ajuste autovacuum e coloque um pooler na frente.
- Particione apenas as tabelas gigantes que doem.
- 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.