- Ao operar uma job queue no Postgres, este texto organiza as causas e as soluções para um problema crônico: o acúmulo de dead tuples, o consequente table bloat e a queda de desempenho
- Em tabelas de fila, a maioria das linhas passa rapidamente pelo ciclo inserir-ler-deletar, então o tamanho tende a permanecer estável, mas o throughput acumulado é muito alto
- Por causa da estrutura de MVCC do Postgres, linhas deletadas não são removidas imediatamente; elas permanecem como dead tuples e precisam ser limpas, tarefa que cabe ao autovacuum
- Se transações de longa duração ou queries analíticas sobrepostas fixarem o MVCC horizon, o autovacuum não consegue limpar os dead tuples, e o desempenho da fila cai
- O Traffic Control da PlanetScale (recurso da extensão Insights) é apresentado como uma forma prática de resolver isso, aplicando limites de recursos por classe de query
Características da carga de trabalho de filas
- A característica única de uma tabela de fila é que a maioria das linhas é transitória (transient) — são inseridas, lidas uma vez e deletadas
- O tamanho da tabela permanece quase constante, mas o throughput acumulado é enorme
- A principal vantagem de manter a job queue no Postgres é poder sincronizar o estado do job e outras lógicas do banco na mesma transação
- se o job falhar, a transação inteira é revertida
- ao usar um serviço externo de fila, sincronizar isso com o estado transacional da aplicação fica mais complexo
Exemplo de tabela de fila e funcionamento do worker
- O schema básico apresentado no texto
CREATE TABLE jobs (
id BIGSERIAL PRIMARY KEY,
run_at TIMESTAMPTZ DEFAULT now(),
status TEXT DEFAULT 'pending',
payload JSONB
);
CREATE INDEX idx_jobs_fetch ON jobs (run_at) WHERE status = 'pending';
- O worker abre uma transação e bloqueia o job pending mais antigo com
FOR UPDATE SKIP LOCKED, evitando processamento duplicado
- Se o trabalho for bem-sucedido, faz
DELETE e depois COMMIT; em caso de falha, ocorre rollback e a linha volta a ficar visível para outro worker
- Essa transação deve ser mantida o mais curta possível — quanto mais tempo ficar aberta, mais atrapalha o vacuum (o exemplo do texto assume workers em escala de submilissegundos)
O problema não é a performance em si
- Já está documentado que o Postgres consegue lidar com job queues em grande escala, então a capacidade em si não é o problema
- O problema real é a convivência com outras cargas de trabalho concorrendo no mesmo banco
- A saúde da tabela de fila depende não só da sua própria configuração, mas também do comportamento de todas as transações que rodam na mesma instância do Postgres
- O texto foca no tráfego de queries concorrentes no primário (o impacto de réplicas e replication slots é tratado à parte)
O verdadeiro problema: limpeza de dead tuples
- O Postgres usa MVCC para manter várias versões da mesma linha — linhas deletadas não são removidas imediatamente, mas ficam “marcadas como deletadas” e invisíveis para novas transações
- As linhas que ficam assim são dead tuples, e são limpas por uma operação de vacuum
- Dead tuples não aparecem no resultado de
SELECT, mas ainda geram custo
- Sequential scan: o executor lê dead tuples nas heap pages, verifica a visibilidade e os descarta
- Index scan (como no padrão da fila com
ORDER BY run_at LIMIT 1): o índice B-tree acumula referências a dead tuples, então o scan acaba passando também por entradas que apontam para linhas que já não são visíveis
- Cada dead index entry gera I/O adicional; a aplicação não vê isso diretamente, mas o custo cresce bastante conforme aumenta o número de dead tuples
- O ciclo de limpeza é regido por
autovacuum_naptime (padrão de 1 minuto), e a decisão de executar depende de autovacuum_vacuum_threshold e autovacuum_vacuum_scale_factor
Mecanismo interno dos dead tuples
- Três metadados da linha são essenciais
ctid: posição física do tuple na heap (page, offset)
xmin: ID da transação que inseriu a linha (XID)
xmax: ID da transação que deletou/bloqueou a linha; se for 0, não há marca de deleção
- Mesmo ao buscar 3 registros pending, pode acontecer de o executor escanear primeiro 6 dead tuples de registros já deletados e retornar apenas 3 linhas
- O mesmo vale para o índice: se uma leaf entry aponta para um heap tuple morto, o trabalho desperdiçado no scan se acumula
- Se o banco ficar mais lento para limpar do que para gerar dead tuples, entra em uma trajetória de falha
- Um cluster Postgres bem ajustado pode suportar dezenas de milhares de operações de fila por segundo
Quando o autovacuum perde a eficácia
- Principais motivos pelos quais o autovacuum falha em limpar dead tuples
- algum lock de tabela específico bloqueia o cleanup
- configuração inadequada do autovacuum
- e, mais comumente, transações ativas impedem a recuperação dos dead tuples
- O Postgres não faz vacuum em dead tuples que ainda podem ser visíveis para transações ativas
- a transação ativa mais antiga define o cutoff → o MVCC horizon
- até essa transação terminar, todos os dead tuples posteriores ao snapshot dela precisam ser mantidos
- Uma única transação de 2 minutos fixa o horizon por 2 minutos
- O mesmo padrão de falha pode acontecer também com queries de duração intermediária que se sobrepõem
- exemplo: se 3 queries analíticas de 40 segundos forem executadas de forma intercalada, com 20 segundos de intervalo, cada uma individualmente não estoura timeout, mas sempre haverá uma ativa, então o horizon não avança
- Ao seguir a filosofia “Just use Postgres” e colocar várias cargas no mesmo banco, o problema não é o processamento rápido dos jobs em si, e sim a limpeza de dead tuples ficar para trás por causa de queries lentas sobrepostas
Ferramentas existentes e limitações
- Opções de tuning do autovacuum:
autovacuum_vacuum_cost_delay, autovacuum_vacuum_cost_limit
- Timeouts para limitar queries longas
statement_timeout (Postgres 7.3): encerra uma instrução SQL individual que exceda o tempo configurado
idle_in_transaction_session_timeout (9.6): encerra sessões que ficam ociosas dentro de uma transação
transaction_timeout (17.0): encerra transações ativas ou inativas que excedam o tempo configurado
- Esses timeouts miram apenas a duração de uma query individual; eles não limitam concorrência nem custo de execução, então não servem bem para bloquear workloads que mantêm o MVCC horizon preso continuamente
- O que se precisa é de separação por classe de tráfego, preservando workloads de alta prioridade e controlando apenas o uso de recursos dos de baixa prioridade
Database Traffic Control™
- Recurso da extensão Insights desenvolvido pela PlanetScale, disponível apenas para PlanetScale Postgres
- É usado quando se quer controlar com mais granularidade a performance e o consumo de recursos de queries individuais
- Com Resource Budget, é possível atribuir limites de recursos às queries alvo → elas podem ser bloqueadas ao exceder o orçamento
- A estratégia de solução é limitar a concorrência e a frequência de queries lentas sobrepostas, para dar ao autovacuum margem de limpar dead tuples em um ritmo adequado
- Queries bloqueadas não devem ser rejeitadas permanentemente; a aplicação precisa ter lógica de retry
- A abordagem busca achatar a taxa de execução, mantendo o mesmo volume total de trabalho
Configuração da demo e contexto
- A inspiração deste texto vem do post de 2015 de Brandur Leach, "Postgres Job Queues & Failure By MVCC"
- ele registrou um modo de falha crítico de job queues baseadas em Postgres
- incluiu um test bench demonstrando que transações não encerradas fixam o MVCC horizon e impedem o cleanup
- O test bench original está disponível em
brandur/que-degradation-test
Reproduzindo o problema (com Postgres 18)
- O teste original era baseado em Ruby + Que gem v0.x + Postgres 9.4
- O autor o reescreveu em TypeScript + Bun para isolar e validar apenas o comportamento em nível de SQL
- Foram mantidos o mesmo padrão de recursive CTE, o mesmo schema, producer rate, duração do trabalho, número de workers e padrão de long-runner do Que
- Execução em um cluster PlanetScale PS-5 (a partir de US$ 5/mês)
- Resultado: degradação perceptível, mas administrável
- no teste original, o banco entrava em death spiral em 15 minutos; no PS-5, a fila dos workers ficou perto de 0 durante os mesmos 15 minutos
- porém, os dead tuples cresceram linearmente, sinalizando que o mesmo problema reapareceria com mais tempo
- graças a melhorias na limpeza do índice B-tree (bottom-up deletion para churn de versões, remoção de dead index tuples orientada por scan etc.), o problema foi atenuado, mas não eliminado
Tentativa de melhoria: SKIP LOCKED + processamento em lote
- Duas melhorias modernas que não existiam em 2015
FOR UPDATE SKIP LOCKED — substitui toda a recursive CTE por um único SELECT, pulando linhas bloqueadas por outros workers
- Processamento em lote (10 jobs por transação) — processa 10 itens com uma única aquisição de lock, diluindo o custo do index scan
- Mesmas condições: 8 workers, producer de 50 jobs/s, trabalho de 10ms, long-runner iniciado após 45 segundos
- Principais resultados
| Métrica |
original (recursive CTE) |
enhanced (SKIP LOCKED + batch) |
| Baseline lock time |
2–3ms |
1.3–3.0ms |
| End lock time (typical) |
10–34ms |
9–29ms |
| Worst spike |
84.5ms (dead tuple 33k) |
180ms (dead tuple 24k) |
| Queue depth |
0–100 (oscillating) |
0 (na maior parte do tempo) |
| Dead tuples at end |
42,400 |
42,450 |
| Throughput |
~89/s |
~50/s |
- A curva de degradação é quase idêntica — ambos os métodos escaneiam o mesmo índice B-tree e encontram os mesmos dead tuples
- A diferença de throughput não vem da estratégia de lock, e sim do desenho do teste (o worker com CTE puxa jobs mais rápido que o producer, enquanto o worker em batch esvazia a fila e depois entra em backoff sleep)
- Conclusão: o desenho de fila que 10 anos atrás matava o banco em 15 minutos agora aguenta mais tempo, mas o problema de fundo continua — se subir para 500 jobs/s, ele reaparece mais rápido
Resolvendo com Traffic Control
- Mecanismos de controle oferecidos pelo Resource Budget
- Server share & burst limit: proporção dos recursos do servidor e velocidade de consumo
- Per-query limit: tempo de execução permitido por query, em segundos, com base no uso do servidor
- Maximum concurrent workers: proporção em relação aos worker processes disponíveis
- A seleção de queries alvo é feita principalmente por metadados em tags do SQLCommenter (ex.:
action=analytics)
- Em vez de usar um long-runner capturado por
idle_in_transaction_session_timeout, o texto provoca degradação com um cenário mais realista: queries analíticas ativas e sobrepostas (caso que timeout de sessão não consegue capturar)
- Limitar Maximum concurrent workers das queries com
action=analytics a 1 worker (25% de max_worker_processes) → apenas 1 query analítica roda por vez
- Para induzir uma death spiral dentro da janela de 15 minutos, o producer foi elevado para 800 jobs/s
- No EC2, foram feitas 2 execuções da carga "enhanced" contra o mesmo banco PlanetScale
- 800 jobs/s
- 3 queries analíticas de 120 segundos executando em paralelo e escalonadas para sempre se sobreporem
- duração de 15 minutos
- Comparação dos resultados
| Métrica |
Traffic Control desativado |
Traffic Control ativado |
| Queue backlog |
155,000 jobs |
0 jobs |
| Lock time |
300ms+ |
2ms |
| Dead tuples at end |
383,000 |
0–23,000 (cycling) |
| Analytics queries |
3 concurrent, overlapping |
1 at a time, 2 retrying |
| VACUUM effectiveness |
Blocked (horizon fixo) |
Normal (janela de limpeza entre queries) |
| Outcome |
Death spiral |
Completely stable |
- O Traffic Control limita diretamente a concorrência de workloads específicos, oferecendo um controle que tuning de autovacuum e timeouts não conseguem fornecer
- Os relatórios analíticos continuaram rodando dentro da capacidade disponível e 15 execuções foram concluídas ao longo dos 15 minutos, enquanto a fila permaneceu saudável o tempo todo
Resumo
- O problema de dead tuples por MVCC em filas baseadas em Postgres não é uma relíquia de 2015
- O Postgres moderno oferece bastante folga com melhorias em B-tree e
SKIP LOCKED, mas o mecanismo fundamental continua o mesmo
- se o VACUUM não conseguir limpar dead tuples, eles se acumulam
- se transações longas ou sobrepostas fixarem o MVCC horizon, o VACUUM não consegue limpá-los
- Em ambientes que seguem o “Just use Postgres” e colocam fila, analytics e lógica da aplicação no mesmo banco, isso não é um risco teórico, e sim uma condição operacional comum
- A forma perigosa não é um crash dramático, e sim um estado de equilíbrio degradado e silencioso — lock time sobe aos poucos, jobs ficam mais lentos e nenhum alerta dispara
- As ferramentas de timeout do Postgres não conseguem diferenciar classes de workload nem limitar concorrência
- Se você roda a fila junto com outras workloads, a medida mais eficaz é garantir que o VACUUM consiga acompanhar, e o Traffic Control simplifica isso
1 comentários
Comentários do Hacker News
O Postgres ainda tem o problema do vacuum horizon. É o fenômeno em que consultas de longa duração impedem o vacuum em tabelas que mudam rapidamente. Esse problema já era bem conhecido desde 2015. O Postgres padrão não tem boas ferramentas para resolver isso, mas a versão customizada da empresa do autor tem um recurso que resolve. Em resumo, ainda não é sensato misturar trabalhos longos no estilo OLAP com trabalhos rápidos no estilo fila na mesma instância do Postgres. Dependendo dos requisitos, usar uma fila de mensagens como 0MQ ou RMQ pode ser uma solução mais simples
O texto foi bom, mas há alguns pontos a observar.
SELECT * FROM jobs WHERE status='pending' ORDER BY run_at LIMIT 1 FOR UPDATE SKIP LOCKED;realmente existe, mas pode ser mitigado adicionando uma coluna monotonicamente crescente e indexando-a. Assim, não é preciso considerar tuplas mortas, e só se desperdiça espaço, enquanto o desempenho de leitura se degrada menos. Ainda assim, como garantir monotonicidade em cenários de escrita concorrente depende do desenho da aplicaçãoSou o autor. Se tiverem perguntas, fiquem à vontade para perguntar
Parece propaganda, mas eu gostaria que houvesse pelo menos alguma explicação da abordagem técnica da solução
O Postgres realmente consegue fazer muita coisa. As pessoas escolhem Kafka ou SQS, mas na verdade há muitos trabalhos que podem ser atendidos perfeitamente com Graphile Worker
No Postgres, o problema fica muito mais sério quando se atualizam linhas. Se usar apenas inserções e exclusões, dá para aguentar por bastante tempo