01
Por que um UPDATE deixa lixo: MVCC e tuplas mortas
O PostgreSQL não altera uma linha no lugar. Um UPDATE grava uma versão nova da linha e marca a antiga como encerrada por aquela transação, e um DELETE apenas marca a versão como encerrada. A versão antiga continua ocupando espaço na página, porque outras transações abertas podem precisar dela para enxergar os dados como estavam quando começaram. Quando nenhuma transação ativa pode mais vê-la, ela vira uma tupla morta, e só o VACUUM a remove, marcando o espaço como reutilizável para novas versões. Ele não devolve esse espaço ao sistema operacional: a tabela não encolhe, ela para de crescer.
| Operação | O que fica na tabela | O que fica nos índices |
|---|---|---|
| INSERT | Uma versão viva | Uma entrada em cada índice |
| UPDATE de coluna indexada | Versão nova viva e versão antiga que vai morrer | Uma entrada nova em cada índice, e a antiga continua lá até o VACUUM |
| UPDATE HOT, sem coluna indexada e com espaço na página | Versão nova na mesma página, encadeada à antiga | Nenhuma entrada nova |
| DELETE | Versão antiga que vai morrer | Entradas continuam lá até o VACUUM |
Tabelas de status são o caso mais sensível. Cada mensagem enviada gera uma linha em entregas e, em seguida, três ou quatro atualizações: enviada, entregue, lida, às vezes falhou. Como a coluna status é indexada para a consulta de pendentes, nenhuma dessas atualizações é HOT, e cada uma deixa uma versão morta na tabela e uma entrada morta em cada índice. Se o VACUUM não acompanha esse ritmo, as páginas passam a conter mais versões mortas que vivas, e o sintoma aparece no plano como o mesmo nó de índice lendo muito mais páginas para devolver as mesmas linhas.
EXPLAIN (ANALYZE, BUFFERS)
SELECT id, mensagem_id, tentativas
FROM entregas
WHERE status = 'pendente'
ORDER BY criado_em
LIMIT 200;
-- Tres semanas antes: 15 ms
-- Index Scan using entregas_status_criado_idx on entregas
-- (actual time=0.031..14.870 rows=200 loops=1)
-- Buffers: shared hit=1204 read=87
--
-- Hoje, mesmo plano, mesmas 200 linhas: 2,3 s
-- Index Scan using entregas_status_criado_idx on entregas
-- (actual time=0.044..2291.502 rows=200 loops=1)
-- Buffers: shared hit=48210 read=91022Nenhum ajuste no plano resolve isso, porque o plano está certo. O índice aponta para centenas de milhares de versões com status pendente que já foram atualizadas para entregue ou lida, e o executor precisa visitar cada uma na tabela para descobrir que ela não é mais visível. O custo da consulta passou a depender do lixo acumulado, e não dos dados.