Query lenta no Postgres: do pg_stat_statements ao índice certo
Como achar qual consulta derruba o banco, ler o plano de execução sem adivinhação e escolher o índice que resolve — em vez de criar cinco na esperança.
O erro tratado aqui
ERROR: canceling statement due to statement timeout (57014)Ambiente testado
- PostgreSQL 15
- pg_stat_statements
- Prisma 6.5
Contexto: o que estava rodando
Uma tela de pedidos com filtro por status e ordenação por data. Cerca de 900 mil linhas na tabela. Funcionou bem por um ano, ficou lenta, e então começou a quebrar.
O erro
PrismaClientKnownRequestError:
Invalid `prisma.pedido.findMany()` invocation:
Error occurred during query execution:
PostgresError { code: "57014",
message: "canceling statement due to statement timeout" }O statement_timeout estava fazendo o trabalho dele: matar consulta que passa do
limite antes que ela derrube o banco inteiro. O timeout é o sintoma, não a
doença — e uma consulta lenta também segura conexão do pool por mais
tempo, o que multiplica o estrago.
Diagnóstico
Achar a consulta certa, não a mais lenta
O primeiro instinto é procurar a consulta com maior tempo médio. É o critério errado. Uma consulta de 3 segundos que roda uma vez por dia é irrelevante; uma de 80 ms que roda dez mil vezes por minuto é o que consome o banco.
Ordene por tempo total:
-- Precisa da extensão habilitada. No Supabase: Database → Extensions.
create extension if not exists pg_stat_statements;
select
calls,
round(mean_exec_time::numeric, 2) as media_ms,
round(total_exec_time::numeric, 2) as total_ms,
rows,
round(100 * total_exec_time / sum(total_exec_time) over (), 1) as pct,
query
from pg_stat_statements
where query not ilike '%pg_stat_statements%'
order by total_exec_time desc
limit 10;O resultado foi direto ao ponto:
calls | media_ms | total_ms | rows | pct | query
--------+----------+-----------+--------+------+---------------------------------
184203 | 412.77 | 76039... | 18420 | 71.4 | select ... from pedidos where status = $1 order by criado_em desc limit $2
92100 | 8.11 | 746... | 921000 | 0.7 | select ... from clientes where id = $1Uma única consulta respondia por 71% do tempo de banco da aplicação.
Ler o plano de execução
Com a consulta identificada, EXPLAIN responde por que ela é lenta. Duas opções
importam: ANALYZE executa de verdade e mostra os números reais, e BUFFERS
mostra quantas páginas foram lidas.
explain (analyze, buffers, format text)
select id, cliente_id, total_cents, criado_em
from pedidos
where status = 'pendente'
order by criado_em desc
limit 50;Limit (cost=48291.02..48296.85 rows=50 width=32)
(actual time=406.118..406.131 rows=50 loops=1)
-> Sort (cost=48291.02..48512.44 rows=88568 width=32)
(actual time=406.116..406.123 rows=50 loops=1)
Sort Key: criado_em DESC
Sort Method: top-N heapsort Memory: 32kB
-> Seq Scan on pedidos (cost=0.00..45346.00 rows=88568 width=32)
(actual time=0.031..381.204 rows=91240 loops=1)
Filter: (status = 'pendente'::text)
Rows Removed by Filter: 812760
Buffers: shared hit=1204 read=34142
Planning Time: 0.142 ms
Execution Time: 406.170 msTrês linhas contam a história inteira:
Seq Scan on pedidos — o Postgres leu a tabela toda. Não havia índice
utilizável para status.
Rows Removed by Filter: 812760 — leu 904 mil linhas para devolver 50.
Descartou 812 mil, uma a uma.
Buffers: read=34142 — 34 mil páginas de 8 KB vindas do disco, cerca de
270 MB de leitura para uma tela de 50 itens.
Estimativa contra realidade
Vale conferir o par rows=88568 (estimativa) e actual rows=91240 (real). Aqui
estão próximos, então as estatísticas estão boas. Quando divergem por ordens de
grandeza, o problema não é falta de índice — é estatística desatualizada, e a
correção é analyze na tabela antes de qualquer outra coisa. É também o primeiro
passo obrigatório depois de uma carga grande, como a de uma migração.
analyze pedidos;
A solução
-
Índice composto, na ordem certa.
A consulta filtra por
statuse ordena porcriado_em. Um índice que cobre as duas coisas permite ao Postgres pular direto para as linhas certas já ordenadas — sem varredura e sem passo de ordenação.migracao/01-indice.sqlcreate index concurrently pedidos_status_criado_em_idx on pedidos (status, criado_em desc);A ordem das colunas não é arbitrária. A regra é: primeiro as colunas usadas em igualdade, depois as de ordenação ou intervalo. Invertido —
(criado_em, status)— o índice não serve para filtrar por status.O
descacompanha oorder byda consulta. Sem ele o índice ainda é usado, mas o Postgres precisa percorrê-lo ao contrário; com ele, a leitura é direta. -
Índice parcial quando o filtro é sempre o mesmo.
Se a tela só mostra pendentes e eles são 10% da tabela, o índice não precisa indexar os outros 90%:
create index concurrently pedidos_pendentes_idx on pedidos (criado_em desc) where status = 'pendente';Fica menor, cabe melhor em memória e torna as escritas de pedidos já concluídos mais baratas — elas não mexem neste índice.
-
Incluir as colunas lidas, quando valer a pena.
Mesmo achando a linha pelo índice, o Postgres vai à tabela buscar as demais colunas.
INCLUDEguarda esses valores no próprio índice e evita a segunda visita:create index concurrently pedidos_status_criado_em_cobrindo_idx on pedidos (status, criado_em desc) include (cliente_id, total_cents);Custo: índice maior e escrita mais cara. Vale quando a consulta é muito frequente e lê poucas colunas — exatamente o caso de uma listagem.
-
Matar o
count(*)da paginação.Este é o custo escondido de quase toda tela paginada.
select count(*) from pedidos where status = 'pendente'percorre todas as linhas que batem, e nenhum índice evita isso.Para “página 1 de 1.824”, troque por paginação por cursor:
consultas/pedidos-pagina.sql-- Primeira página select id, cliente_id, total_cents, criado_em from pedidos where status = 'pendente' order by criado_em desc, id desc limit 50; -- Próxima página: continua de onde parou, sem offset select id, cliente_id, total_cents, criado_em from pedidos where status = 'pendente' and (criado_em, id) < ($1, $2) -- último par da página anterior order by criado_em desc, id desc limit 50;O
idno desempate impede que linhas com o mesmo timestamp sejam puladas ou repetidas. E o custo passa a ser constante — a página 1.800 é tão rápida quanto a primeira, ao contrário dooffset, que precisa contar e descartar tudo que veio antes.Quando o total precisa mesmo aparecer, uma estimativa costuma bastar:
select reltuples::bigint as aproximado from pg_class where relname = 'pedidos';
Como confirmar que resolveu
O mesmo EXPLAIN, agora:
Limit (cost=0.42..12.71 rows=50 width=32)
(actual time=0.038..0.121 rows=50 loops=1)
-> Index Scan using pedidos_status_criado_em_idx on pedidos
(cost=0.42..21764.18 rows=88568 width=32)
(actual time=0.036..0.113 rows=50 loops=1)
Index Cond: (status = 'pendente'::text)
Buffers: shared hit=54
Planning Time: 0.198 ms
Execution Time: 0.149 msIndex Scan no lugar de Seq Scan, o passo de Sort desapareceu, 54 páginas em
vez de 34 mil, e 0,15 ms em vez de 406 ms. Duas mil e setecentas vezes mais
rápido.
O índice está sendo usado de verdade:
select indexrelname, idx_scan, idx_tup_read
from pg_stat_user_indexes
where relname = 'pedidos'
order by idx_scan desc;
idx_scan em zero depois de horas de tráfego significa índice que só custa
escrita. Considere remover.
Reveja o ranking depois de um dia:
select pg_stat_statements_reset();
-- espere o tráfego normal, e rode a consulta do início de novo
A consulta que respondia por 71% precisa sair do topo. Se outra assumiu o lugar, o processo recomeça — e agora você já sabe como.
Armadilhas que sobram depois disso
Índice demais é pior que índice de menos. Cada insert e update atualiza
todos os índices da tabela. Um índice não usado é custo puro em toda escrita.
Função na coluna anula o índice. where lower(email) = $1 não usa índice em
email. Ou você indexa a expressão — create index on usuarios (lower(email)) —
ou usa a coluna crua.
Tipo diferente também anula. Comparar varchar com text, ou bigint com
número literal fora de faixa, faz o planejador converter e abandonar o índice.
Seq Scan num filtro que deveria ser indexado costuma ser isto.
O plano depende dos dados. Com poucas linhas, o Postgres escolhe Seq Scan
de propósito, porque ler tudo é mais barato que consultar índice. Testar
otimização num banco de desenvolvimento com mil linhas não prova nada.
statement_timeout continua sendo boa ideia. Ele não é o problema, é a
proteção. Depois de corrigir a consulta, mantenha o limite — ele vai avisar da
próxima.
Continue por aqui
Dados & Migrações
RLS no Supabase: por que sua query retorna array vazio
Com Row Level Security ligada, o SELECT bloqueado não dá erro — devolve nada. Como depurar a política sem desligar a segurança para "testar".
Dados & Migrações
Supabase: porta 5432, 6543 ou conexão direta — qual usar
São três formas de conectar no mesmo banco, com limitações diferentes. Escolher errado gera erros que parecem de código e são de infraestrutura.
Dados & Migrações
prepared statement s0 already exists: por que o pooler quebra
O erro só aparece depois que você troca a conexão direta pelo pooler em modo transaction. A causa está em como o Postgres guarda statements preparados.