VACUUM e Autovacuum no PostgreSQL – Entendendo bloat na prática

Muita gente que começa a trabalhar com PostgreSQL escuta falar de VACUUM o tempo todo.
Mas no começo parece algo meio abstrato.

Tabela cresce, autovacuum roda, dead tuples aparecem… e às vezes fica difícil entender
o que realmente está acontecendo por trás.

Então resolvi montar um laboratório simples para demonstrar na prática como o bloat
aparece e como o VACUUM atua dentro do PostgreSQL.

A ideia aqui é simular um cenário bem comum no dia a dia:
tabelas recebendo UPDATE e DELETE, gerando versões antigas de linhas e acumulando
dead tuples.

Ambiente do laboratório

PostgreSQL 17
Ambiente standalone
Tabela de teste com carga artificial de dados

1 – Criando um banco de laboratório

CREATE DATABASE lab_vacuum;

Conectando nele:

psql -U postgres -d lab_vacuum ou \c xxx

2 – Criando uma tabela de teste

CREATE TABLE teste_bloat (
id serial PRIMARY KEY,
nome text,
status text,
criado_em timestamp default now()
);

3 – Inserindo uma massa inicial de dados

INSERT INTO teste_bloat (nome, status)
SELECT
md5(random()::text),
'ativo'
FROM generate_series(1,200000);

Aqui criamos uma tabela com um volume razoável de dados para facilitar a simulação.

4 – Verificando o tamanho inicial da tabela

SELECT
relname,
pg_size_pretty(pg_total_relation_size(relid)) AS total_size
FROM pg_catalog.pg_statio_user_tables
WHERE relname = 'teste_bloat';

5 – Conferindo estatísticas da tabela

SELECT
schemaname,
relname,
n_live_tup,
n_dead_tup,
last_vacuum,
last_autovacuum,
vacuum_count,
autovacuum_count
FROM pg_stat_user_tables
WHERE relname = 'teste_bloat';

Nesse momento o esperado é:

n_live_tup alto
n_dead_tup próximo de zero

schemaname |   relname   | n_live_tup | n_dead_tup | last_vacuum |        last_autovacuum        | vacuum_count | autovacuum_count
------------+-------------+------------+------------+-------------+-------------------------------+--------------+------------------
 public     | teste_bloat |     200000 |          0 |             | 2026-03-16 21:29:22.468759-03 |            0 |                1

6 – Gerando bloat com UPDATE

UPDATE teste_bloat
SET status = 'inativo';

Aqui acontece algo importante no PostgreSQL.

O UPDATE não sobrescreve a linha existente.
Ele cria uma nova versão da linha e marca a anterior como morta.

Isso acontece por causa do MVCC (Multi Version Concurrency Control).

schemaname |   relname   | n_live_tup | n_dead_tup | last_vacuum |        last_autovacuum        | vacuum_count | autovacuum_count
------------+-------------+------------+------------+-------------+-------------------------------+--------------+------------------
 public     | teste_bloat |     200000 |     200000 |             | 2026-03-16 21:29:22.468759-03 |            0 |                1

7 – Gerando ainda mais bloat

UPDATE teste_bloat
SET nome = md5(random()::text);

Agora aumentamos ainda mais o número de versões antigas das linhas.

8 – Conferindo as dead tuples

SELECT
schemaname,
relname,
n_live_tup,
n_dead_tup,
ROUND(
(n_dead_tup::numeric / NULLIF(n_live_tup + n_dead_tup,0))*100,
2
) AS pct_dead
FROM pg_stat_user_tables
WHERE relname = 'teste_bloat';

Aqui já conseguimos observar o acúmulo de dead tuples na tabela.

schemaname |   relname   | n_live_tup | n_dead_tup | last_vacuum |        last_autovacuum        | vacuum_count | autovacuum_count
------------+-------------+------------+------------+-------------+-------------------------------+--------------+------------------
 public     | teste_bloat |     200000 |     400000 |             | 2026-03-16 21:29:22.468759-03 |            0 |            

9 – Conferindo novamente o tamanho da tabela

SELECT
relname,
pg_size_pretty(pg_total_relation_size(relid)) AS total_size
FROM pg_catalog.pg_statio_user_tables
WHERE relname = 'teste_bloat';

relname   | total_size
-------------+------------
 teste_bloat | 58 MB

Mesmo sem inserir novos dados úteis, a tabela pode crescer por causa das versões antigas.

10 – Gerando mais bloat com DELETE

DELETE FROM teste_bloat
WHERE id <= 50000;

Deletes também geram linhas mortas, que precisam ser limpas posteriormente.

schemaname |   relname   | n_live_tup | n_dead_tup | last_vacuum |        last_autovacuum        | vacuum_count | autovacuum_count
------------+-------------+------------+------------+-------------+-------------------------------+--------------+------------------
 public     | teste_bloat |     150000 |      50000 |             | 2026-03-16 21:30:52.773007-03 |            0 |                2

11 – Executando VACUUM ANALYZE

VACUUM ANALYZE teste_bloat;

O que acontece aqui:

VACUUM remove as linhas mortas internamente.
ANALYZE atualiza as estatísticas usadas pelo otimizador de consultas.

12 – Validando após o VACUUM

SELECT
schemaname,
relname,
n_live_tup,
n_dead_tup,
last_vacuum,
last_autovacuum,
vacuum_count,
autovacuum_count
FROM pg_stat_user_tables
WHERE relname = 'teste_bloat';

schemaname |   relname   | n_live_tup | n_dead_tup |          last_vacuum          |        last_autovacuum        | vacuum_count | autovacuum_count
------------+-------------+------------+------------+-------------------------------+-------------------------------+--------------+------------------
 public     | teste_bloat |     150000 |          0 | 2026-03-16 21:32:51.372299-03 | 2026-03-16 21:30:52.773007-03 |            1 |                2
(1 row)

Agora devemos ver uma redução significativa nas dead tuples.

13 – Um detalhe importante

Mesmo após o VACUUM, o tamanho da tabela pode não diminuir conforme vemos.

lab_vacuum=# SELECT
    relname,
    pg_size_pretty(pg_total_relation_size(relid)) AS total_size
FROM pg_catalog.pg_statio_user_tables
WHERE relname = 'teste_bloat';
   relname   | total_size
-------------+------------
 teste_bloat | 58 MB

Isso acontece porque o VACUUM libera o espaço internamente
para reutilização, mas não devolve o espaço ao sistema operacional.

14 – Quando o tamanho realmente diminui

Para reduzir fisicamente o tamanho da tabela, é possível usar:

VACUUM FULL teste_bloat;

lab_vacuum=# VACUUM FULL teste_bloat;
VACUUM
lab_vacuum=# SELECT
    relname,
    pg_size_pretty(pg_total_relation_size(relid)) AS total_size
FROM pg_catalog.pg_statio_user_tables
WHERE relname = 'teste_bloat';
   relname   | total_size
-------------+------------
 teste_bloat | 15 MB

Mas aqui existe um ponto importante:

VACUUM FULL reescreve a tabela inteira
pega lock exclusivo
não é recomendado executar em horário crítico de produção

Segue incrementado:

Um ponto importante: O Autovacuum

No laboratório acima executamos o VACUUM manualmente apenas para demonstrar o funcionamento, mas na prática quem faz esse trabalho no dia a dia é o Autovacuum.

O autovacuum é um processo automático do PostgreSQL responsável por:

  • remover dead tuples
  • atualizar estatísticas das tabelas
  • evitar crescimento desnecessário das tabelas (bloat)
  • prevenir problemas de transaction ID wraparound

Ou seja, ele roda em background mantendo o banco saudável sem intervenção do DBA.

Podemos verificar se ele já executou em uma tabela com a view pg_stat_user_tables:

SELECT
    schemaname,
    relname,
    n_live_tup,
    n_dead_tup,
    last_autovacuum,
    last_autoanalyze,
    autovacuum_count,
    autoanalyze_count
FROM pg_stat_user_tables
ORDER BY last_autovacuum DESC;

Por que precisamos ter cuidado com o Autovacuum

Apesar de ser essencial, o autovacuum também precisa ser bem ajustado. Se estiver mal configurado ou atrasado, alguns problemas podem aparecer:

  • acúmulo de dead tuples
  • crescimento excessivo das tabelas (bloat)
  • aumento de I/O e CPU
  • piora no desempenho das consultas

Por outro lado, se configurado de forma muito agressiva, o autovacuum pode competir por recursos com queries da aplicação.

Por isso é importante acompanhar parâmetros como:

ParâmetroDescrição
autovacuum_vacuum_scale_factor% de dead tuples para disparar o vacuum
autovacuum_analyze_scale_factor% de mudanças para disparar o analyze
autovacuum_max_workersnúmero máximo de workers paralelos
autovacuum_naptimeintervalo entre verificações
autovacuum_vacuum_cost_delaythrottle para não sobrecarregar I/O

Ajuste por tabela

Em ambientes com tabelas muito grandes ou com muita escrita, é comum ajustar o autovacuum por tabela:

ALTER TABLE tabela_critica SET(   autovacuum_vacuum_scale_factor = 0.01,    autovacuum_analyze_scale_factor = 0.005);

Assim o processo de limpeza acontece mais cedo e evita acúmulo de bloat.

Para monitorar tabelas que estão acumulando dead tuples e o autovacuum não está dando conta, use:

SELECT   schemaname,    relname,    n_dead_tup,   last_autovacuum,   ROUND(n_dead_tup::numeric / NULLIF(n_live_tup, 0) * 100, 2) AS pct_dead FROM pg_stat_user_tables WHERE n_dead_tup 1000 ORDER BY pct_dead DESC;

Tabelas com pct_dead alto são candidatas a ajuste fino do autovacuum ou a um VACUUM ANALYZE manual pontual.

Ao trabalhar com PostgreSQL é fundamental entender que operações como UPDATE e DELETE não removem imediatamente os dados antigos.
Por causa do MVCC, o banco mantém versões anteriores das linhas para garantir consistência de leitura, e essas versões acabam se tornando dead tuples.

É aí que entram o VACUUM e o Autovacuum.

O VACUUM é responsável por limpar essas linhas mortas e liberar espaço interno para reutilização, enquanto o autovacuum faz esse trabalho automaticamente em segundo plano, mantendo o banco saudável na maior parte do tempo sem intervenção manual.

Quando esse processo funciona bem, o PostgreSQL consegue manter tabelas e índices sob controle mesmo em ambientes com muita escrita.

Por outro lado, quando o autovacuum está mal ajustado ou atrasado, começam a aparecer sintomas conhecidos no dia a dia do DBA: crescimento inesperado de tabelas, aumento de I/O, consultas mais lentas e casos de bloat.

DBA Yan Spinelli

Deixe um comentário