Indexação no PostgreSQL para Serviços de Alto Volume
A maioria dos incidentes de "o banco está lento" que persegui terminaram no mesmo lugar: uma query fazendo um sequential scan sobre uma tabela que tinha crescido além do ponto onde isso era aceitável. Adicionar o índice certo resolveu em segundos. Mas "adicione um índice" é um conselho fácil de errar em três direções diferentes — o índice errado, um índice que não pode ser usado, ou um índice que silenciosamente taxa toda escrita. Isto é o que aprendi mantendo o Postgres rápido sob volume real.
Leia o EXPLAIN Antes de Tocar em Qualquer Coisa
Nunca adicione um índice por palpite. Pergunte ao Postgres o que ele está fazendo:
EXPLAIN ANALYZE
SELECT * FROM orders
WHERE customer_id = 42 AND status = 'paid'
ORDER BY created_at DESC
LIMIT 20;
As palavras que importam na saída: Seq Scan significa que leu a tabela inteira; Index Scan significa que usou um índice para achar linhas; Index Only Scan significa que respondeu inteiramente pelo índice sem tocar a tabela (o caso mais rápido). ANALYZE roda a query e mostra tempos e contagens reais — uma diferença grande entre linhas estimadas e reais geralmente significa estatísticas desatualizadas (ANALYZE orders;) e vale corrigir antes de culpar o índice.
A Ordem das Colunas num Índice Composto É o Jogo Inteiro
Para a query acima, o movimento tentador são três índices separados em customer_id, status e created_at. O Postgres consegue combiná-los com um bitmap, mas um único índice composto bem ordenado é muito melhor:
CREATE INDEX idx_orders_cust_status_created
ON orders (customer_id, status, created_at DESC);
A regra que demorei demais para internalizar: colunas de igualdade primeiro, depois a coluna de range/ordenação por último. A query filtra customer_id e status por igualdade e ordena por created_at. Com esta ordem de colunas, o Postgres pula direto para a fatia customer_id = 42, status = 'paid' e a lê já em ordem created_at DESC — sem passo de sort separado. Inverta a ordem para (created_at, customer_id, status) e o índice fica quase inútil para esta query, porque a coluna líder não é a que você está filtrando. Um índice composto pode servir qualquer query que use um prefixo de suas colunas, então a ordem das colunas não é um detalhe — ela decide quais queries o índice pode ajudar.
Índices Covering: Responder Sem a Tabela
Um index scan normalmente acha as linhas correspondentes no índice, depois visita a tabela (o "heap") para buscar as colunas que você selecionou. Se o índice já contém toda coluna que a query precisa, o Postgres pula o heap inteiramente — um index-only scan. Desde o Postgres 11 você pode adicionar colunas não-chave puramente para habilitar isso:
CREATE INDEX idx_orders_cust_covering
ON orders (customer_id, status) INCLUDE (total, created_at);
Agora SELECT total, created_at FROM orders WHERE customer_id = 42 AND status = 'paid' pode ser respondido só pelo índice. Numa query quente sobre uma tabela larga, cortar a busca no heap é um ganho real. O custo é um índice mais gordo — você está duplicando essas colunas — então reserve índices covering para caminhos genuinamente quentes, não para tudo.
Índices Parciais: Indexe Só o Que Você Consulta
Se suas queries sempre filtram por um valor, não indexe as linhas que você nunca procura:
CREATE INDEX idx_orders_pending
ON orders (created_at)
WHERE status = 'pending';
Numa tabela de pedidos que é 95% completed, um índice parcial sobre as linhas pendentes é uma fração do tamanho, fica em memória, e é mais barato de manter. Este é um dos melhores recursos do Postgres e o mais subutilizado — sempre que você tem um "subconjunto quente" (jobs não processados, sessões ativas, soft-deleted = false), um índice parcial mira exatamente nele.
O Custo Que Ninguém Menciona
Todo índice deixa as escritas mais lentas. Um INSERT ou UPDATE que toca uma coluna indexada tem que atualizar todo índice relevante, sincronamente, dentro da transação. Numa tabela write-heavy, cinco índices significam cinco operações de manutenção de índice por escrita. Já vi um bem-intencionado "vamos indexar tudo que o planner possa querer" transformar um caminho de ingestão rápido num gargalo.
Índices também sofrem bloat. Sob churn pesado de update/delete, índices B-tree acumulam entradas mortas e crescem; REINDEX CONCURRENTLY (Postgres 12+) os reconstrói sem travar escritas. E construir um índice numa tabela viva de alto volume a trava contra escritas a menos que você use CREATE INDEX CONCURRENTLY — mais lento de construir, mas não tira sua tabela do ar.
Trade-offs
Index type Read benefit Write/space cost Use when
-------------- ------------------------- ------------------- ----------------------------
B-tree single equality/range on 1 col low simple lookups
Composite multi-col filter + sort medium fixed query shape, order matters
Covering index-only scan higher space hot query over a wide table
Partial tiny, hot-subset lookups low queries always filter a subset
O modelo mental que me mantém honesto: um índice é uma aposta de que a economia de leitura supera o custo de escrita, paga em toda escrita para sempre. Para uma tabela lida mil vezes por escrita, indexe generosamente. Para um write-firehose lido ocasionalmente, indexe com relutância e meça. "Adicione um índice" nunca é de graça — é uma troca, e EXPLAIN ANALYZE mais uma olhada na sua taxa de escrita é como você a precifica antes de se comprometer.