O que VACUUM realmente faz no Postgres
MVCC, dead tuples, bloat, autovacuum, transaction wraparound. VACUUM não é faxina — é parte de como o Postgres funciona. Ignorar é acordar 3h da manhã.
O que VACUUM realmente faz no Postgres
Quase todo dev backend acha que VACUUM é "limpeza de banco".
Não é.
VACUUM é parte do mecanismo de funcionamento do Postgres. Sem ele, o banco para. Literalmente.
E é por causa dele que sua tabela de 2 GB tem 18 GB em disco.
Antes de tudo: MVCC
Postgres não atualiza linhas in-place.
Quando você faz UPDATE, ele:
- Marca a linha antiga como morta (tupla "dead").
- Insere uma nova linha com os valores atualizados.
DELETE é parecido: a linha não some, ela é só marcada como morta.
Por quê? Porque transações concorrentes podem estar enxergando a versão anterior. MVCC (Multi-Version Concurrency Control) precisa manter as duas até que ninguém mais precise da antiga.
| id | name | xmin | xmax |
|----|-------|------|------|
| 1 | Vini | 100 | 200 | ← morta (substituída)
| 1 | Vini2 | 200 | 0 | ← viva
Sem alguém limpando as linhas mortas, elas ficam ali. Pra sempre.
Esse alguém é o VACUUM.
Dead tuples: a base do problema
Cada UPDATE, cada DELETE — gera dead tuple.
Tabela com muito write, sem VACUUM rodando bem:
Tabela: orders
- 10M linhas vivas
- 40M dead tuples acumuladas
- Tamanho em disco: 6x o necessário
Isso chama-se bloat.
E aí começa o drama:
Seq Scanlê tudo, vivas e mortas (depois filtra).- Índice tem entradas mortas também — index bloat.
- Cache do Postgres se enche de páginas com pouco dado útil.
- Query que deveria ser rápida fica lenta sem motivo aparente.
A query não mudou. O plano não mudou. A tabela é que apodreceu.
O que VACUUM faz, de verdade
Três coisas, principalmente:
1. Marca espaço de dead tuples como reutilizável
Ele não devolve o espaço ao SO. Marca como "pode escrever em cima".
Próximo INSERT/UPDATE usa esse espaço. A tabela para de crescer.
2. Atualiza o visibility map
Mapa que diz "essa página só tem linhas visíveis pra todo mundo".
Permite index-only scans — Postgres lê o índice sem visitar o heap. Performance grátis.
3. Atualiza estatísticas (quando é VACUUM ANALYZE)
Histograma de distribuição, n_distinct, correlation. É o que o planner usa pra decidir Seq vs Index.
Estatística podre = plano errado = query lenta. Visto isso no artigo anterior.
Autovacuum: o herói anônimo
Você raramente roda VACUUM manualmente. O autovacuum roda sozinho.
Defaults aproximados:
autovacuum_vacuum_threshold = 50
autovacuum_vacuum_scale_factor = 0.2
Tradução: roda VACUUM numa tabela quando ela tem mais de 50 + 0.2 * total_de_linhas dead tuples.
Numa tabela com 100M linhas:
50 + 0.2 * 100_000_000 = 20_000_050 dead tuples
Vinte milhões de linhas mortas antes do autovacuum se mexer. Tarde demais.
Regra de ouro:
Em tabelas grandes e quentes, abaixe o scale_factor.
ALTER TABLE orders SET (
autovacuum_vacuum_scale_factor = 0.02,
autovacuum_analyze_scale_factor = 0.01
);
Quando autovacuum não dá conta
Sintomas:
- bloat só cresce, mesmo com autovacuum ligado
- queries cada vez mais lentas
pg_stat_user_tables.n_dead_tupna lua
Causas comuns:
1. Workload de write muito alto
↓ autovacuum roda, mas não termina a tempo
2. Long-running transactions
↓ enquanto a transação X vive, dead tuples não podem ser limpas
(Postgres não sabe se X ainda vai querer ver a versão antiga)
3. Replication slots órfãos
↓ slot inativo segura o xmin do banco inteiro
4. Tabela grande com poucos workers de autovacuum
↓ autovacuum_max_workers padrão é 3 — pouco
A causa #2 é a campeã. Alguém abriu BEGIN no console, foi almoçar, e o banco está congelado por dentro há 3 horas.
Diagnóstico rápido:
SELECT pid, state, xact_start, query
FROM pg_stat_activity
WHERE state IN ('idle in transaction', 'active')
ORDER BY xact_start;
Achou alguém com xact_start de 2h atrás? Mata.
Como saber se a tabela está com bloat
SELECT
relname,
n_live_tup,
n_dead_tup,
round(n_dead_tup::numeric / NULLIF(n_live_tup, 0), 2) AS dead_ratio,
last_autovacuum,
last_autoanalyze
FROM pg_stat_user_tables
ORDER BY n_dead_tup DESC
LIMIT 20;
dead_ratio acima de 0.2 já é alerta. Acima de 1.0 (mais mortas que vivas) é incêndio.
Pra estimativa de bloat em bytes, existe a query do pgstattuple:
CREATE EXTENSION pgstattuple;
SELECT * FROM pgstattuple('orders');
Te dá dead_tuple_percent, free_percent, tuple_count. Lentinha de rodar, mas precisa.
Freezing e transaction wraparound
Aqui a coisa fica séria.
Cada transação no Postgres tem um ID de 32 bits — XID. Quando chega no limite (~2 bilhões), ele dá volta. Wraparound.
Se nada for feito, transações antigas viram "do futuro" e os dados ficam invisíveis. Catástrofe.
A solução do Postgres: marcar tuplas antigas como "congeladas" (frozen) — visíveis pra todas as transações, presentes e futuras.
Isso também é trabalho do VACUUM. Especificamente do autovacuum em modo emergency.
Sinais que você está perto do wraparound:
SELECT
datname,
age(datfrozenxid) AS xid_age
FROM pg_database
ORDER BY xid_age DESC;
Quando passa de 200 milhões, autovacuum entra em modo to prevent wraparound — não pode ser cancelado, vai segurar a tabela trancada se preciso.
Quando passa de 2 bilhões, o banco se recusa a aceitar novas transações.
Você acorda às 3 da manhã.
VACUUM FULL: o nuclear
VACUUM normal não devolve espaço pro SO. VACUUM FULL devolve — reconstrói a tabela inteira do zero.
VACUUM FULL orders;
Problemas:
- trava a tabela inteira com
ACCESS EXCLUSIVE - ninguém lê, ninguém escreve
- demora proporcional ao tamanho da tabela
Em produção, com tabela grande, isso é inaceitável.
A alternativa civilizada: pg_repack.
pg_repack -t orders mydb
Faz o mesmo trabalho do VACUUM FULL — reescreve a tabela compactada — mas sem lock exclusivo. Mantém uma cópia, sincroniza com triggers, troca no final.
Regra: nunca rode VACUUM FULL em prod numa tabela quente. Use pg_repack.
VACUUM vs ANALYZE vs VACUUM ANALYZE
Confusão clássica:
VACUUM → limpa dead tuples, atualiza visibility map
ANALYZE → atualiza estatísticas (não toca em dead tuples)
VACUUM ANALYZE → ambos
VACUUM FULL → reescreve a tabela (libera espaço pro SO)
Autovacuum roda os dois separadamente (autovacuum e autoanalyze), com thresholds próprios.
Você quase nunca precisa rodar manualmente. Quando precisa, é porque:
- carregou um milhão de linhas e quer estatística fresca já
- está debugando um plano e quer eliminar "estatística podre" da hipótese
ANALYZE orders;
Como bloat mata performance silenciosamente
Cenário real:
Semana 1: query roda em 30ms
Semana 4: 60ms
Semana 8: 150ms
Semana 12: 800ms — alguém abre ticket
Nada mudou na query. Nada mudou no schema.
A tabela acumulou bloat. Cada Seq Scan agora lê 5x mais páginas. Cada Index Scan visita páginas mortas. O cache cabe menos dado útil.
Em EXPLAIN (ANALYZE, BUFFERS):
Buffers: shared hit=200 read=18000
Quase tudo do disco. Antes era quase tudo do cache.
Sem VACUUM honesto, o banco apodrece de dentro pra fora.
Checklist mínimo
[ ] Autovacuum ligado (default, mas confirma)
[ ] Tabelas grandes/quentes com scale_factor reduzido
[ ] Monitorar pg_stat_user_tables.n_dead_tup
[ ] Monitorar age(datfrozenxid) — alerta em 500M
[ ] Sem long-running transactions esquecidas
[ ] Replication slots ativos (ou removidos)
[ ] pg_repack disponível para tabelas que precisam
Sete linhas. Cada uma já te livrou de uma noite ruim.
A grande virada de chave
VACUUM não é manutenção opcional.
É parte do contrato do MVCC. O Postgres só funciona porque alguém remove o lixo que ele cria por design.
Ignorar VACUUM é ignorar como o banco funciona.
E o banco, em silêncio, vai cobrar:
- queries lentas sem motivo claro
- disco cheio sem dado a mais
- autovacuum em emergência segurando produção
- wraparound às 3 da manhã
Conclusão
VACUUM é o sistema imunológico do Postgres.
Você não precisa entender cada detalhe — mas precisa saber que ele existe, que ele roda, que ele às vezes não dá conta, e que quando não dá conta, você é quem precisa ajudar.
Configure o autovacuum agressivo nas tabelas certas. Monitore n_dead_tup. Mate transações zumbi. Tenha pg_repack à mão.
A diferença entre o time que dorme tranquilo e o time que apaga incêndio é, mais vezes do que parece, alguém que entendeu o que VACUUM faz.
Não é limpeza.
É como o Postgres respira.
