Postgres que aguenta: índices, N+1 e connection pooling
A maioria dos bancos lentos não precisa de uma máquina maior — precisa de três correções baratas. Onde a lentidão realmente nasce e como resolvê-la.
Quando um produto começa a arrastar sob carga, a reação comum é aumentar a máquina do banco. Às vezes é isso mesmo. Na maioria das vezes que investigamos, o gargalo é um de três problemas baratos de corrigir — e uma máquina maior só adia a conta. São eles: falta de índice, o problema N+1 e ausência de connection pooling.
1. O índice que faltava
Sem um índice, uma busca por WHERE email = ... obriga o Postgres a ler a tabela inteira
linha por linha (sequential scan). Com 500 linhas, ninguém percebe. Com 5 milhões, cada
consulta varre 5 milhões de linhas.
A ferramenta para enxergar isso é o EXPLAIN ANALYZE, que mostra o plano real e o tempo
gasto:
EXPLAIN ANALYZE SELECT * FROM pedidos WHERE cliente_id = 42;
-- Seq Scan on pedidos ... rows=5000000 ← lendo tudo
Um índice na coluna filtrada troca a varredura por uma busca direta:
CREATE INDEX idx_pedidos_cliente ON pedidos (cliente_id);
-- Agora: Index Scan using idx_pedidos_cliente ... ← vai direto ao ponto
Dois cuidados que separam quem sabe usar índice de quem sai criando índice em tudo:
- Índice não é grátis. Cada índice deixa escritas (
INSERT/UPDATE) mais lentas e ocupa disco. Indexe as colunas que você filtra e ordena de fato, não todas. - Em índice composto, a ordem das colunas importa. Um índice em
(cliente_id, data)serve para filtrar porcliente_idsozinho ou porcliente_idedatajuntos — mas não para filtrar só pordata. Ordene as colunas do filtro mais frequente para o menos.
2. O problema N+1
Este é o mais silencioso, porque cada consulta individual é rápida — o problema é a quantidade delas. Acontece quando você busca uma lista e, para cada item, dispara outra consulta:
const pedidos = await db.pedidos.findMany() // 1 consulta
for (const pedido of pedidos) {
pedido.cliente = await db.clientes.find(pedido.clienteId) // + N consultas
}
Cem pedidos viram 101 idas ao banco. Cada uma some no log; juntas, elas dominam o tempo de resposta. A latência de rede entre app e banco, repetida N vezes, é o que mata.
A correção é buscar tudo de uma vez — com um JOIN, ou carregando os relacionados em um
único IN:
// Uma consulta, com os clientes já embutidos
const pedidos = await db.pedidos.findMany({ include: { cliente: true } })
O jeito de flagrar N+1 é observar o número de queries por request, não só a duração de cada uma. Uma rota que dispara centenas de consultas quase idênticas é o sintoma.
3. Connection pooling
Abrir uma conexão com o Postgres é caro: há handshake, autenticação e alocação de memória
no servidor. Um app que abre e fecha uma conexão por request desperdiça esse custo o
tempo todo — e o Postgres tem um teto de conexões simultâneas (max_connections,
tipicamente na casa das centenas). Estoure esse teto e novas requisições passam a receber
erro de conexão, não lentidão.
Um pool mantém um conjunto de conexões abertas e reaproveitadas:
import { Pool } from "pg"
const pool = new Pool({ max: 10 }) // reaproveita até 10 conexões
const { rows } = await pool.query("SELECT * FROM pedidos WHERE id = $1", [id])
Dois pontos que costumam pegar em produção:
- Serverless multiplica conexões. Cada instância serverless mantém o próprio pool;
sob pico, dezenas de instâncias somam conexões e estouram o
max_connections. Nesses ambientes, use um pooler externo (PgBouncer, ou o pooler gerenciado do seu provedor) entre o app e o banco. - Dimensione o pool com consciência. Pool grande demais só empurra a disputa para dentro do Postgres. O tamanho certo depende dos núcleos e do disco do banco — meça, não chute.
O diagnóstico antes do upgrade
Antes de pagar por uma instância maior, faça o barato primeiro: rode EXPLAIN ANALYZE nas
consultas lentas, conte quantas queries cada request dispara e confirme que existe um pool
com tamanho sensato. Na maior parte dos casos, o ganho vem daqui — e a máquina que você já
tem passa a aguentar bem mais do que parecia.
Há um limite honesto: quando os três estão resolvidos e a carga ainda excede o hardware, aí sim escalar (réplicas de leitura, particionamento, mais máquina) é a resposta certa. A ordem é que importa — otimizar antes de escalar, não o contrário.
Tem um banco que trava sob carga e você não sabe por quê? Fale com a gente.