01
O custo que não aparece em nenhuma consulta lenta
Índice não é de graça só porque nenhuma leitura o usa. Toda inserção na tabela vira uma inserção em cada árvore de índice, cada uma com seu registro de WAL e suas páginas disputando espaço no shared_buffers. A atualização é pior: se qualquer coluna indexada mudar, ou se não houver espaço na página para a nova versão da linha, o Postgres não consegue fazer um update HOT e precisa inserir uma entrada nova em todos os índices da tabela, inclusive nos que não têm relação com a coluna alterada.
No ERP, o índice em atualizado_em tinha sido criado para um job de sincronização desativado havia um ano. Como toda atualização de pedido grava atualizado_em, nenhuma delas podia ser HOT: a taxa de updates HOT da tabela era de 4%, e cada mudança de status de pedido gerava 22 inserções em índices. O custo não aparece em pg_stat_statements como uma consulta lenta. Ele se espalha por todas as escritas, pelo vacuum, pela réplica e pelo backup.
| Operação | O que cada índice a mais adiciona | Observação |
|---|---|---|
| INSERT | Uma inserção na árvore, com WAL e páginas no cache | Cresce linearmente com o número de índices |
| UPDATE que muda coluna indexada ou não cabe na página | Uma entrada nova em todos os índices | Um único índice em coluna muito atualizada desliga o HOT da tabela inteira |
| UPDATE HOT | Nada | Só acontece se nenhuma coluna indexada mudar e houver espaço na página |
| DELETE | Nada na hora, limpeza depois | O vacuum precisa remover as entradas mortas de cada índice |
| VACUUM | Uma varredura completa do índice a cada passada de limpeza | Com 22 índices, a fase de índices dominava as seis horas |
| Réplica e backup | Replay do WAL do índice e gigabytes a mais em cada cópia | Atraso de réplica e tempo de restauração crescem juntos |
-- Indices sem nenhuma varredura desde o ultimo reset, com tamanho e restricoes.
-- E so a lista de suspeitos: o numero vale para ESTA instancia e para ESTE periodo.
SELECT s.schemaname || '.' || s.indexrelname AS indice,
s.relname AS tabela,
s.idx_scan,
s.last_idx_scan, -- Postgres 16 ou superior
pg_size_pretty(pg_relation_size(s.indexrelid)) AS tamanho,
i.indisunique,
i.indisprimary,
(SELECT string_agg(c.conname, ', ')
FROM pg_constraint c
WHERE c.conindid = s.indexrelid) AS restricoes
FROM pg_stat_user_indexes s
JOIN pg_index i ON i.indexrelid = s.indexrelid
WHERE s.idx_scan = 0
ORDER BY pg_relation_size(s.indexrelid) DESC;
-- Desde quando esses contadores valem nesta instancia.
SELECT stats_reset FROM pg_stat_database WHERE datname = current_database();
-- O que a tabela paga para manter os indices: proporcao de updates HOT.
SELECT relname,
n_tup_ins,
n_tup_upd,
n_tup_hot_upd,
round(100.0 * n_tup_hot_upd / nullif(n_tup_upd, 0), 1) AS pct_hot,
pg_size_pretty(pg_relation_size(relid)) AS heap,
pg_size_pretty(pg_indexes_size(relid)) AS indices
FROM pg_stat_user_tables
WHERE relname = 'pedidos';A primeira consulta lista suspeitos, não culpados. No primário do ERP, ela trouxe 9 índices com idx_scan = 0, somando 31 GB. Dois deles eram exatamente os que o fechamento fiscal usava na réplica. A diferença entre essa lista e uma lista segura para apagar é o assunto das próximas seções.