01
Por que o COUNT(*) do Postgres varre o que você filtrou
O PostgreSQL não guarda o número de linhas de uma tabela em lugar nenhum que possa ser lido com garantia de exatidão. Isso não é descuido, é consequência do MVCC: cada transação enxerga um snapshot próprio, e uma linha inserida por uma transação ainda aberta existe para ela e não existe para as outras. Não há um único total correto; há um total para cada snapshot. Para responder COUNT(*), o banco precisa percorrer as linhas que atendem ao filtro e conferir, uma a uma, se cada versão é visível para a transação atual.
Um índice ajuda menos do que parece. Com um índice em (loja_id, status), o planejador pode usar index-only scan, mas ainda visita cada entrada que casa com o filtro: 12 milhões de pedidos pagos são 12 milhões de entradas lidas. E o index-only scan só evita o heap nas páginas marcadas como all-visible no visibility map. Em uma tabela de pedidos, onde status muda o dia inteiro, uma parte das páginas está sempre suja, e o banco volta ao heap para conferir visibilidade. Sem índice útil, vira parallel seq scan, que termina mais rápido em relógio e consome CPU de vários workers ao mesmo tempo.
Antes de mudar a tela, meça. A primeira consulta abaixo mostra quanto do tempo total do banco vai para contagens, a segunda mostra o plano da contagem mais comum com o volume de buffers lidos e a terceira mostra quanto da tabela está all-visible, o que define se o index-only scan está trabalhando de verdade.
-- Quanto do tempo total do banco vai para contagens.
WITH geral AS (SELECT sum(total_exec_time) AS tempo FROM pg_stat_statements)
SELECT left(regexp_replace(s.query, '\s+', ' ', 'g'), 80) AS consulta,
s.calls AS chamadas,
round(s.mean_exec_time::numeric, 1) AS media_ms,
round((100 * s.total_exec_time / geral.tempo)::numeric, 1) AS pct_do_tempo
FROM pg_stat_statements s, geral
WHERE s.query ILIKE '%count(%'
ORDER BY s.total_exec_time DESC
LIMIT 10;
-- O plano da contagem que a tela dispara a cada troca de filtro.
EXPLAIN (ANALYZE, BUFFERS)
SELECT count(*)
FROM pedidos
WHERE loja_id = 42
AND status = 'pago'
AND criado_em >= now() - interval '90 days';
-- Quanto da tabela esta marcado como all-visible no visibility map.
-- Abaixo de 90%, o index-only scan volta a ler o heap para conferir visibilidade.
SELECT s.relname,
s.n_live_tup,
s.n_dead_tup,
s.last_autovacuum,
round(100.0 * c.relallvisible / nullif(c.relpages, 0), 1) AS pct_all_visible
FROM pg_stat_user_tables s
JOIN pg_class c ON c.oid = s.relid
WHERE s.relname = 'pedidos';| Métrica na tela de pedidos | Valor medido |
|---|---|
| Linhas na tabela pedidos | 48 milhões |
| Listagem de 50 pedidos (p95) | 9 ms |
| COUNT(*) com filtro de loja e status (p95) | 4,2 s |
| Contagens por minuto no horário comercial | 1.900 |
| Participação das contagens no tempo do banco | 41% |
| Páginas all-visible em pedidos | 63% |