Tecnologias/02 - Bancos de Dados/01 - Relacionais/01 - PostgreSQL6 min
MVCC, Tuplas e VACUUM
Conceito
Esta é a nota do Postgres. Quase todo comportamento estranho do produto — bloat,
VACUUM, wraparound, índice que não é usado, UPDATE caro — sai daqui.
A tupla e seus dois campos mágicos
No Postgres, uma linha física é uma tupla, e o cabeçalho dela carrega:
| Campo | Significado |
|---|---|
xmin |
id da transação que criou esta versão |
xmax |
id da transação que deletou/substituiu esta versão (0 = viva) |
ctid |
endereço físico: (página, item) |
Um UPDATE não altera a linha. Ele:
- marca a tupla antiga com
xmax = minha_transação; - insere uma tupla nova (na mesma página, se couber; em outra, se não);
- insere a nova entrada em todos os índices da tabela.
Antes: [xmin=100, xmax=0 | id=1, saldo=100]
UPDATE saldo=70 (txid 150):
[xmin=100, xmax=150 | id=1, saldo=100] ← morta, mas ocupa espaço
[xmin=150, xmax=0 | id=1, saldo=70 ] ← viva
E a regra de visibilidade é simples: sua transação vê uma tupla se xmin já estava
confirmado quando seu snapshot foi criado e xmax não estava. Nenhum lock envolvido — é
por isso que leitores nunca bloqueiam escritores. Ver
3. MVCC e Isolamento na Prática.
HOT update: a otimização que salva
Se o UPDATE não toca nenhuma coluna indexada e há espaço na mesma página, o Postgres
faz um HOT update (Heap-Only Tuple): cria a nova versão na própria página e a liga à
antiga por uma cadeia, sem tocar nos índices.
UPDATE pedidos SET visualizado_em = now() WHERE id = 1;
→ coluna não indexada + espaço na página = HOT: nenhum índice atualizado 🎉
UPDATE pedidos SET status = 'pago' WHERE id = 1; -- status é indexado
→ NÃO é HOT: nova entrada em todos os índices
Duas consequências de projeto que quase ninguém aproveita:
- Índice a mais custa escrita, não só espaço: ele pode destruir a chance de HOT update.
fillfactormenor que 100 (ex.: 90) deixa espaço livre na página de propósito, aumentando a taxa de HOT em tabelas de atualização intensa.
VACUUM: o que ele faz de verdade
VACUUM não devolve espaço ao sistema operacional (salvo VACUUM FULL, que reescreve
a tabela com lock exclusivo). Ele faz três coisas:
- Marca o espaço das tuplas mortas como reutilizável (registra na Free Space Map).
- Atualiza a Visibility Map, marcando páginas "todas visíveis" — o que habilita index-only scan (5. Índices e o Planejador).
- Congela (
freeze) tuplas antigas, avançando o contador de transações para evitar wraparound.
ANALYZE é outra coisa: coleta estatísticas para o planejador. autovacuum faz os
dois.
XID wraparound: o problema que derruba o banco
O contador de transações é de 32 bits (~4,2 bilhões). Se ele der a volta, transações antigas pareceriam do futuro e o banco perderia a noção de visibilidade — corrupção lógica. O Postgres se defende de forma dramática:
autovacuum_freeze_max_age (200M) → autovacuum "anti-wraparound" força a limpeza,
mesmo em tabelas que ninguém alterou
...
~40M restantes → WARNING nos logs
~3M restantes → o banco entra em modo somente-leitura e EXIGE VACUUM manual
Um banco que para de aceitar escrita por wraparound é um incidente de horas. A defesa é
monitorar age(relfrozenxid) antes de o alarme soar.
Trade-offs
MVCC por versionamento na tabela dá rollback grátis e cobra em bloat. Cancelar uma
transação de 10 milhões de UPDATE no Postgres é instantâneo (só descarta as versões
novas); no MySQL/Oracle o rollback precisa
reverter pelo undo, e pode levar mais tempo que a própria transação. Do outro lado:
Postgres tem VACUUM, eles não.
UPDATE no Postgres é um INSERT disfarçado. Uma tabela com muitos UPDATE em
colunas indexadas gera write amplification (heap + N índices) e bloat. Isso é medível e
gerenciável — mas precisa ser sabido, e muda decisões de modelagem (ex.: separar colunas
"quentes" de contador em outra tabela).
Autovacuum padrão é conservador demais para tabelas grandes.
autovacuum_vacuum_scale_factor = 0.2 significa "limpe quando 20% da tabela estiver
morta". Numa tabela de 100 milhões de linhas, são 20 milhões de tuplas mortas antes
de começar. Para tabelas grandes e quentes, ajuste por tabela.
O trade-off central: o Postgres trocou "desfazer é caro" por "limpar é obrigatório". Ele nunca perde tempo revertendo, e em troca você herda um processo de manutenção contínua que não é opcional — é parte de como o banco funciona.
Exemplo prático
Medir bloat e idade de transação
-- tuplas mortas e último vacuum, por tabela
SELECT relname,
n_live_tup, n_dead_tup,
round(100.0 * n_dead_tup / nullif(n_live_tup + n_dead_tup, 0), 1) AS pct_morto,
last_autovacuum, last_autoanalyze
FROM pg_stat_user_tables
WHERE n_dead_tup > 10000
ORDER BY n_dead_tup DESC LIMIT 20;
-- risco de wraparound: monitore isto ANTES do warning
SELECT relname, age(relfrozenxid) AS idade_xid,
round(100.0 * age(relfrozenxid) / 2000000000, 1) AS pct_do_limite
FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE relkind = 'r' AND nspname NOT IN ('pg_catalog','information_schema')
ORDER BY 2 DESC LIMIT 10;
Ajuste por tabela, não global
-- tabela grande e quente: limpe a partir de 1% + 20 mil tuplas mortas
ALTER TABLE eventos SET (
autovacuum_vacuum_scale_factor = 0.01,
autovacuum_vacuum_threshold = 20000,
autovacuum_vacuum_cost_limit = 4000,
fillfactor = 90 -- espaço para HOT update
);
-- tabela de contadores: fillfactor menor ainda, e nenhum índice supérfluo
ALTER TABLE contadores SET (fillfactor = 70);
O inimigo número 1: transação aberta
-- Isto impede o VACUUM de limpar QUALQUER tupla mais nova que a transação
SELECT pid, state, now() - xact_start AS transacao_aberta,
now() - state_change AS neste_estado, left(query, 50)
FROM pg_stat_activity
WHERE state IN ('idle in transaction', 'idle in transaction (aborted)')
OR (state = 'active' AND now() - xact_start > interval '5 min')
ORDER BY xact_start;
A defesa é declarativa, e deveria estar em todo postgresql.conf:
idle_in_transaction_session_timeout = '5min' # mata a sessão esquecida
statement_timeout = '30s' # por app/role, não global no superuser
lock_timeout = '3s' # essencial antes de DDL
VACUUM FULL não é a soluçãoEle reescreve a tabela inteira e toma ACCESS EXCLUSIVE — nada lê nem escreve
enquanto roda, e você precisa do dobro do espaço em disco. Para desinchar sem parar o
serviço, use pg_repack (ou pg_squeeze), que faz o mesmo trabalho com lock
apenas no final. VACUUM FULL só em janela de manutenção declarada.
Como isso falha em produção
| Sintoma | Causa |
|---|---|
| Tabela de 200 MB de dado ocupando 8 GB | bloat: autovacuum não acompanhou o ritmo de UPDATE |
VACUUM roda e n_dead_tup não cai |
transação antiga (idle in transaction) segurando o xmin |
| Índice enorme e lento numa tabela pequena | bloat de índice; REINDEX CONCURRENTLY |
| Banco em modo somente-leitura | XID wraparound — incidente grave |
UPDATE de uma coluna ficou caro |
a coluna foi indexada e o HOT update deixou de acontecer |
| Query nova está lenta | falta ANALYZE: estatísticas não refletem os dados |
| Autovacuum consumindo I/O demais | cost_limit alto num disco lento; ajuste por tabela |
Aprofundamento aplicado
Laboratório de snapshot antigo
Em uma tabela de estudo com uma linha, abra duas sessões:
A: BEGIN ISOLATION LEVEL REPEATABLE READ;
A: SELECT * FROM tabela_estudo; -- estabelece o snapshot
B: UPDATE tabela_estudo SET valor = valor + 1;
B: COMMIT;
A: SELECT * FROM tabela_estudo; -- mantém a visão anterior
A: COMMIT;
O snapshot de A precisa continuar encontrando a versão compatível. O banco não pode
recuperar indiscriminadamente versões necessárias a leitores. Após o fim dessas referências,
a limpeza pode reaproveitar espaço; isso não implica devolver todo o espaço ao sistema
operacional. VACUUM comum e operações de reescrita têm efeitos distintos.
Referência: objetivos de VACUUM.
Como observar: correlacione transações antigas, estimativa de tuplas mortas e atividade do autovacuum. Uma estimativa alta, sozinha, não prova a causa nem mede com precisão os bytes desperdiçados.
Resultado esperado: explique por que encerrar o snapshot libera uma restrição à limpeza, mas não significa que o arquivo diminui imediatamente.
Relacionado
- 1. PostgreSQL
- 2. Arquitetura de Processos e Memória
- 3. WAL, Checkpoint e Replicação
- 5. Índices e o Planejador
- 3. MVCC e Isolamento na Prática
- 3. Redo, Undo e Locks
- 6. Transações e ACID
Parte de MOC PostgreSQL · MVCC (docs) · Routine Vacuuming