trilha

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:

  1. marca a tupla antiga com xmax = minha_transação;
  2. insere uma tupla nova (na mesma página, se couber; em outra, se não);
  3. 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.
  • fillfactor menor 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:

  1. Marca o espaço das tuplas mortas como reutilizável (registra na Free Space Map).
  2. Atualiza a Visibility Map, marcando páginas "todas visíveis" — o que habilita index-only scan (5. Índices e o Planejador).
  3. 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ção

Ele 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


Parte de MOC PostgreSQL · MVCC (docs) · Routine Vacuuming

Buscar

Busca por título, seção e texto das notas