Índice é uma das poucas coisas em backend onde uma linha de SQL muda uma resposta de oito segundos pra oito milissegundos. Também é uma das poucas onde criar demais tem custo real e invisível.
O padrão que eu encontro em projeto que cresceu rápido é sempre o mesmo: vinte índices, doze deles nunca lidos, e a query mais cara do sistema fazendo sequential scan numa tabela de quatro milhões de linhas.
Primeiro, descubra o que está doendo
Antes de criar qualquer coisa, ligue a extensão que responde isso.
create extension if not exists pg_stat_statements;
select
calls,
round(mean_exec_time::numeric, 2) as media_ms,
round(total_exec_time::numeric / 1000, 1) as total_s,
query
from pg_stat_statements
order by total_exec_time desc
limit 20;
Ordene por tempo total, não por tempo médio. Uma query de 4 segundos chamada dez vezes por dia importa muito menos que uma de 80 milissegundos chamada duzentas mil vezes. A segunda é a que está consumindo o seu banco, e ela nunca aparece em reclamação de usuário.
Leia o plano, não o palpite
explain (analyze, buffers)
select * from ledger_entry
where account_id = '...' and created_at >= now() - interval '30 days';
Três coisas pra procurar na saída.
Seq Scan em tabela grande com filtro seletivo. É o caso óbvio de índice faltando.
Divergência entre rows= estimado e actual rows=. Se o planejador acha que vem 30 linhas e vêm 300 mil, ele escolheu a estratégia errada com base em estatística velha. Um ANALYZE resolve, e se voltar a acontecer, o problema é a frequência do autovacuum.
Rows Removed by Filter alto. O índice foi usado, trouxe muita linha e o banco jogou quase tudo fora. Falta uma coluna no índice.
A ordem das colunas em índice composto
É o erro conceitual mais comum, e o mais caro.
create index on orders (created_at, status, tenant_id);
Esse índice não serve pra where tenant_id = ? and status = ?. Índice composto funciona como lista telefônica ordenada por sobrenome, depois nome. Buscar só pelo nome não aproveita a ordenação.
A regra: colunas de igualdade primeiro, a de faixa por último.
-- para: where tenant_id = ? and status = ? and created_at >= ?
create index on orders (tenant_id, status, created_at);
Um índice (a, b, c) já atende consultas por a, e por a, b. Então não crie os três separados. Isso sozinho costuma eliminar metade dos índices redundantes de um projeto.
Índice parcial: o mais subestimado
Quando você quase sempre consulta um subconjunto pequeno, indexe só ele.
-- 98% dos pedidos já foram processados e ninguém consulta eles por status
create index on orders (tenant_id, created_at)
where status in ('pending', 'processing');
O índice fica uma fração do tamanho, cabe na memória e é bem mais rápido de manter na escrita. Em tabela com muito registro histórico e consulta concentrada no recente, a diferença é grande.
O mesmo vale pra soft delete. Se todas as consultas filtram deleted_at is null, o índice deveria ter essa condição.
O que ninguém remove
Índice não é gratuito. Cada INSERT e UPDATE atualiza todos os índices da tabela. Uma tabela de escrita intensa com oito índices escreve nove vezes.
Ache os inúteis:
select
relname as tabela,
indexrelname as indice,
idx_scan as leituras,
pg_size_pretty(pg_relation_size(indexrelid)) as tamanho
from pg_stat_user_indexes
where idx_scan < 50
order by pg_relation_size(indexrelid) desc;
idx_scan perto de zero em índice antigo significa que ele nunca foi usado. Antes de derrubar, confirme que as estatísticas não foram zeradas recentemente e que não é um índice de constraint única. Depois, derrube.
Criar índice sem travar a produção
Em tabela grande, create index comum trava escrita durante a criação inteira.
create index concurrently idx_orders_tenant_status
on orders (tenant_id, status, created_at);
CONCURRENTLY é mais lento e não bloqueia escrita. Ele também não pode rodar dentro de transação, o que é justamente o problema numa migração de TypeORM ou de qualquer ferramenta que embrulhe tudo em transação. A saída é marcar essa migração como sem transação.
E confira depois: se falhar no meio, o índice fica com estado invalid, presente e não usado.
select indexrelid::regclass from pg_index where not indisvalid;
Quando índice não é a resposta
Nem todo problema de performance é índice.
Se a query traz cem mil linhas pra aplicação filtrar, o problema é a query. Se é N+1, o problema é a camada de acesso a dados. Se roda a cada requisição e o dado muda uma vez por dia, o problema é falta de cache. Se soma a tabela inteira todo request, o problema é modelagem, e provavelmente falta uma tabela de agregado.
Índice acelera busca. Ele não conserta desenho.
O ritual mínimo
Uma vez por mês: olhar o top 20 do pg_stat_statements, conferir índices nunca lidos e rodar explain analyze na query mais cara.
Vinte minutos. É a melhor relação entre esforço e resultado que existe em performance de backend, e quase ninguém faz.