13 pontos por GN⁺ 12 일 전 | 1 comentários | Compartilhar no WhatsApp
  • 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
    1. FOR UPDATE SKIP LOCKED — substitui toda a recursive CTE por um único SELECT, pulando linhas bloqueadas por outros workers
    2. 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

 
GN⁺ 12 일 전
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

    • A menos que o banco de dados permita controlar o cache ou o buffer pool de forma granular, misturar cargas tão diferentes assim é sempre uma má escolha. Porque não há como impedir que tabelas analíticas poluam todo o cache
  • O texto foi bom, mas há alguns pontos a observar.

    1. A explicação do MVCC horizon parece contraditória. Se as transações começam em momentos diferentes, os snapshots serão diferentes, e quando a primeira transação terminar o vacuum deveria poder prosseguir
    2. O problema de desempenho da query 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ção
    3. Resumindo, a lição é: “não use transações muito longas junto com uma frequência muito alta de transações no Postgres”
    • Mesmo com um índice adicional, o Postgres ainda precisa manter as tuplas mortas até conseguir limpá-las completamente. Haverá algum ganho de velocidade, mas no fim o disco vai encher, e o vacuum de outras tabelas também pode ser prejudicado
  • Sou o autor. Se tiverem perguntas, fiquem à vontade para perguntar

    • A implementação de fila do blog parece manter a transação aberta enquanto o trabalho está em andamento. Entendo o conselho de mantê-la o mais curta possível, mas queria saber se não daria para evitar a transação longa em si atualizando a coluna de estado para “processing”
    • Queria saber se vocês testaram definir o fillfactor da tabela da fila para menos de 100. Com HOT updates, talvez seja possível reutilizar espaço morto sem criar novas entradas de índice ao mudar o estado, o que poderia adiar o momento em que o problema aparece
    • Queria saber como essa solução se compara ao uso de pg_squeeze. Nós também enfrentamos o mesmo problema em um sistema de filas e estamos testando o pg_squeeze, e parece funcionar muito bem
  • 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

    • A filosofia de “vamos fazer tudo com Postgres” é boa para manter a simplicidade em pequena ou média escala, mas quando a escala cresce, é melhor deixar para um banco SQL o mínimo possível de trabalho. Porque ele costuma ser o gargalo na maioria dos sistemas
    • O SQS é muito simples e se integra facilmente em ambientes AWS. O Kafka é bem mais complexo, mas é uma ótima escolha se você precisa dos recursos dele. Ainda assim, o custo operacional é alto, e há muitos projetos em que ele foi adotado mais para enfeitar currículo. Já o SQS é usado como ferramenta prática. Mas se você pretende sair da AWS, essa dependência pode virar um problema
  • 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

    • No Postgres, UPDATE é na prática uma combinação de INSERT com DELETE