Introdução
Em 2023, o time de plataforma da empresa onde trabalhava enfrentou o que toda equipe de backend teme: nosso PostgreSQL principal havia crescido para 10TB de dados e as queries começaram a degradar. Não linearmente — exponencialmente.
Neste artigo, vou documentar o que funcionou, o que não funcionou, e o que aprendi operando Postgres em escala real. Não é teoria — é um post-mortem com lições concretas.
"Você não tem problema de banco de dados. Você tem problema de design de schema que se manifestou no banco de dados." — conselho que recebi tarde demais.
O Pesadelo do Particionamento
Nossa tabela events tinha 4.2 bilhões de linhas. Queries simples por range
de data levavam 45 segundos. A solução óbvia parecia ser particionamento — mas a
implementação foi tudo menos óbvia.
O Postgres 14 introduziu melhorias significativas no Declarative Partitioning. A sintaxe é elegante:
CREATE TABLE events (
id BIGSERIAL,
user_id BIGINT NOT NULL,
event_at TIMESTAMPTZ NOT NULL,
payload JSONB
) PARTITION BY HASH (user_id);
CREATE TABLE events_p0 PARTITION OF events
FOR VALUES WITH (MODULUS 8, REMAINDER 0);
-- ... repeat for p1 through p7 O problema é que migrar uma tabela existente com dados para um modelo particionado exige zero downtime com logical replication. Fizemos isso em 4 dias com uma janela de manutenção de apenas 30 segundos.
Indexação em Larga Escala
Com o particionamento resolvido, o próximo gargalo foram os índices. Índices B-Tree em colunas de timestamp em tabelas de 500GB são caros de manter e lentos para queries de range.
A solução foi trocar para índices BRIN (Block Range INdexes) para colunas timestamp naturalmente ordenadas:
-- B-Tree: 12GB por partição
CREATE INDEX idx_events_at ON events (event_at);
-- BRIN: 150KB por partição, igualmente efetivo para range queries
CREATE INDEX idx_events_at_brin ON events
USING BRIN (event_at) WITH (pages_per_range = 128); Demo: EXPLAIN ANALYZE comparando B-Tree vs BRIN
8:23 min
Lições Aprendidas
- Configure
autovacuum_vacuum_scale_factor = 0.01para tabelas grandes — o default de 0.2 é mortal - Use PgBouncer em modo transaction pooling antes de qualquer otimização de query
- Monitore
pg_stat_user_tablesdiariamente — dead tuples acumulam silenciosamente - Índices parciais salvam vidas:
WHERE deleted_at IS NULLreduz 60% do tamanho - EXPLAIN (ANALYZE, BUFFERS) é seu melhor amigo — leia os hit/miss ratios de buffers
Platform Engineer / SRE
Platform Engineer & SRE com 6+ anos em Developer Experience e Platform Engineering. Palestrante no DevopsDays SP, Platform Days, DevPR Config e DevOps Summit. github.com/gabriel-dantas98