
Guia de sobrevivência PostgreSQL para startups foca em índices e query planner
O básico: schemas, leituras e escritas
Alexander Belanger, co-fundador da Hatchet, compilou dois anos de batalhas com Postgres em produção num guia prático para startups. O ponto de partida é simples: se uma query está lenta, provavelmente falta um índice.
Schemas são a parte mais difícil de mudar depois do deploy. As regras de ouro: use identity columns ou UUIDs nativos como chaves primárias, sempre timestamptz, sempre chaves primárias, e foreign keys com cascading deletes apenas em tabelas de baixo volume. Normalização formal (1NF/2NF/3NF) às vezes conflita com eficiência de query - às vezes um jsonb resolve melhor.
Leituras rápidas dependem de índices (btree por padrão, lookup em ~log(n)). Sem índice, o Postgres faz sequential scan - imperceptível abaixo de ~20k linhas, mas destrutivo acima disso. Para joins, trate a cláusula ON com o mesmo cuidado de um WHERE: use índices. Índices compostos devem ter as colunas do ORDER BY por último, alinhadas à ordenação.
Escritas performantes exigem transações curtas (nunca chame serviços externos no meio de uma transação) e locks mínimos. Ao criar índices em tabelas grandes, sempre use CREATE INDEX CONCURRENTLY - o comando simples bloqueia todas as escritas.
Migrations devem ser aditivas (não delete colunas) e rodar em transação quando possível. Operações com ALTER TABLE merecem atenção extra: adicionar check constraints em tabelas grandes pode bloquear escritas (use NOT VALID para evitar).
Conexões são caras. Connection storms causam edge cases difíceis de debugar. Use poolers externos (pgbouncer) ou in-memory (pgxpool para Go).
Intermediário: query planner, bulk writes e autovacuum
O query planner é descrito como "a mais leaky das abstrações" - opera com informação limitada (estatísticas de tabela coletadas via ANALYZE/autovacuum) e às vezes escolhe planos subóptimos. Quanto mais você micro-otimiza uma query, maior o risco do planner "sair dos trilhos". Stick to primary keys e índices quando possível.
Para debugar queries lentas: EXPLAIN (ANALYZE, COSTS, VERBOSE, BUFFERS, FORMAT JSON) + visualização em explain.dalibo.com.
Às vezes o seq scan é a escolha certa - index scans têm overhead porque os dados do índice ficam separados do heap.
Bulk writes: empacotar rows em batches (ex: SendBatch no pgx) pode dar ~10× de throughput ao reduzir round-trips e overhead de locks internos.
Autovacuum com settings padrão pode matar o banco em cenários de alta escrita. Dead tuples se acumulam quando o autovacuum não acompanha o ritmo. Se um autovacuum roda por mais de ~1 hora, ajuste os settings. O pior caso: transaction id wraparound, que causa downtime significativo.
Bloat vem em três formas: dead tuples (resolvido com autovacuum tuning), table bloat (páginas parcialmente preenchidas - use pg_repack; Postgres 19 trará REPACK...CONCURRENTLY), e index bloat (resolvido com REINDEX INDEX CONCURRENTLY).
Avançado
FOR UPDATE SKIP LOCKED permite reservar rows para uma transação sem interferir em outras queries - útil para job queues e distribuição de leases entre instâncias.
Particionamento permite subdividir tabelas por timestamp ou hash. Benefícios: autovacuum independente por partição e deleção quase instantânea de dados antigos (basta dropar a partição). Desvantagem: overhead em reads se o planner não fizer partition pruning.
Migrações de tabelas grandes: transações longas impedem autovacuum e causam bloat. A solução da Hatchet é usar triggers do Postgres + backfill em batches fora de transação, com unique constraints para prevenir duplicatas.