githublinkedinemail
← /articles

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ã.

5 minpostgres

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:

  1. Marca a linha antiga como morta (tupla "dead").
  2. 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 Scan lê 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_tup na 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.

O que VACUUM realmente faz no Postgres
O que VACUUM realmente faz no Postgres