- O bloqueio exclusivo global do LISTEN/NOTIFY do Postgres limita a taxa de transferência de implementações simples, mas ao armazenar notificações em buffer e enviá-las em lote é possível processar até 60 mil gravações de stream por segundo em um único servidor
- A transação que chama
NOTIFYmantém um bloqueio global até que o commit e ofsync()terminem para garantir a ordem de commit das notificações, o que serializa os commits e também impede o uso de group commit - A implementação inicial, que chamava
NOTIFYpor trigger a cada gravação na tabela de streams, oferecia baixa latência, mas encontrava gargalo em 2.900 operações por segundo sem sequer usar plenamente CPU, memória e IOPS - Ao usar a tabela do banco de dados como fonte da verdade em vez da notificação, e enviar periodicamente as notificações acumuladas em memória dentro de uma única transação, é possível reduzir drasticamente o número de aquisições de bloqueio
- A possibilidade de perder notificações do buffer em caso de falha do processo é compensada com polling de baixa frequência, alcançando 15~100 ms de latência e 20 vezes mais throughput mesmo em ambientes com leituras simultâneas
Stream de baixa latência implementado com Postgres
- Um stream baseado em Postgres armazena cada fragmento do stream como uma nova linha na tabela
streams, e até tokens de resposta de LLM podem ser um desses fragmentos - No lado de leitura, não é possível saber quando o próximo fragmento vai chegar, então é difícil esperar de forma eficiente usando apenas consultas simples
- O polling periódico aumenta a latência em usos interativos, como chat online, se o intervalo for longo, e sobrecarrega o banco de dados com pollers simultâneos se o intervalo for curto
- Com LISTEN/NOTIFY, o processo de leitura pode ficar bloqueado esperando e despertar imediatamente com a notificação de que um novo fragmento foi gravado, evitando polling desnecessário
O gargalo do NOTIFY enviado a cada gravação
- Na implementação inicial, sempre que um novo fragmento era gravado na tabela
streams, um trigger executava uma função para enviar umNOTIFY, e o processo de leitura aguardava a notificação antes de ler o novo fragmento - A correção e a baixa latência estavam garantidas, mas mesmo em um grande banco Postgres não era possível sustentar mais de 2.900 gravações de stream por segundo
- Enquanto o gargalo ocorria, o uso de CPU, memória e IOPS não subia de forma significativa; a causa era o bloqueio global no caminho de commit do
NOTIFY
O bloqueio global que garante a ordem de commit
- A transação que chama
NOTIFYadquire um bloqueio exclusivo global no início do commit e só o libera quando estiver totalmente concluída e o conteúdo tiver sido gravado em disco comfsync() - O Postgres garante que as notificações sejam entregues na ordem de commit das transações, armazenando todas as notificações emitidas em uma fila interna global que precisa corresponder exatamente a essa ordem
- A adição de notificações à fila também precisa ser tratada transacionalmente como parte do commit, mas como o tempo de commit varia entre transações, não é possível definir a ordem antes de o commit terminar
- O bloqueio global serializa os commits das transações que incluem notificações para fixar essa ordem com antecedência e garantir que elas também sejam inseridas na fila interna de notificações na mesma sequência
Como a serialização de commits limita o throughput
- Como todas as gravações de stream chamam
NOTIFYvia trigger, cada transação de gravação mantém o bloqueio global durante todo o commit e a gravação em disco - Como as transações fazem commit uma por vez, não é possível aproveitar o group commit do Postgres, que processa várias transações com um único
fsync() - O throughput não pode ultrapassar a velocidade com que o Postgres faz commit de transações individuais, e as operações ficam esperando pelo bloqueio, sem usar CPU e disco plenamente
- O patch que entrará no Postgres 19 não remove o bloqueio global, então não resolve esse gargalo
- Em vez disso, ele otimiza casos mais restritos em que existem muitos canais de notificação e cada listener espera apenas um canal específico
Bufferização de notificações e envio em lote
- Em vários usos de LISTEN/NOTIFY, incluindo streams, a notificação não é a fonte da verdade, mas apenas um sinal para verificar a tabela onde os dados reais estão armazenados
- Nessa estrutura, a notificação em si não precisa ter ordenação global perfeita nem durabilidade completa, então ela pode ser armazenada em memória e enviada periodicamente em uma única transação em lote
- O bloqueio global é adquirido não a cada gravação individual do stream, mas apenas ao esvaziar o buffer
- As gravações individuais avançam rapidamente, separadas do envio de notificações em background, e o throughput aumenta ao aproveitar otimizações do Postgres, como group commit
Polling de baixa frequência para compensar perda de notificações
- Se o processo cair enquanto as notificações ainda estiverem na memória, elas podem não ser entregues
- O processo de leitura, ao mesmo tempo em que espera notificações, consulta periodicamente o banco de dados para verificar se há dados de stream gravados sem notificação
- Como esse polling é apenas um mecanismo auxiliar para recuperar notificações perdidas, ele pode rodar com baixa frequência e tem pouco impacto no desempenho
Throughput e latência
- A implementação otimizada processa até 60 mil gravações de stream por segundo em ambientes com processos de leitura simultâneos, registrando throughput 20 vezes maior que o da implementação inicial
- Mesmo com o throughput mais alto, a latência permanece na faixa de 15~100 ms
- No throughput máximo, a CPU do Postgres é totalmente utilizada, mostrando que o sistema chegou a uma saturação real do banco de dados, e não a contenção por bloqueio
- O código completo de benchmark está disponível em dbos-postgres-benchmark
1 comentários
Comentários do Hacker News
Escalabilidade é uma escala contínua, não uma dicotomia. 60 mil por segundo pode ser 100 mil vezes mais do que alguns sistemas precisam e 100 mil vezes menos do que outros precisam. Entre os erros comuns de desenvolvedores, eu apontaria escolher uma tecnologia cujas características de escala não combinam mais do que “otimização prematura”
Uma tecnologia pequena demais falha de forma evidente ao ultrapassar seus limites, mas uma tecnologia excessivamente escalável também traz carga operacional e restrições. Introduzir esse tipo de tecnologia em um sistema pequeno, onde um modelo mais rico poderia reduzir muito o esforço de desenvolvimento, também é uma má escolha
Os limites do LISTEN/NOTIFY são baixos o bastante para exigir cuidado, então é melhor deixar pelo menos 10 vezes de folga mesmo depois de calcular a carga máxima pessimista, mas ele é suficiente para muitos projetos. Pela integração com o banco de dados, disponibilidade e por não exigir operar um serviço separado, não é uma opção a ser descartada automaticamente; até os 2 mil por segundo citados anteriormente já são um número grande para um sistema que processa uma mensagem em escala de segundos
É melhor projetar para a escala realmente esperada com uma margem adequada e escolher algo além disso apenas quando a escalabilidade extra for praticamente de graça. Se der para comprar hardware maior por alguns milhares de dólares, ou se as opções forem equivalentes exceto pela escalabilidade, escolha a maior
Tive muito sucesso combinando LISTEN/NOTIFY com um broker de subscriptions GraphQL em Rust. Havia dezenas de milhares de subscriptions, mas apenas uma conexão LISTEN por host, 3 ou 4 no total
Enviávamos todas as alterações para cada host, e o host gerenciava as subscriptions reais dos usuários e decidia o que publicar. Trocar centenas de hosts Ruby ou Node por alguns hosts Rust pode simplificar muito a arquitetura, e abordagens consideradas não escaláveis podem funcionar bastante bem
Quando eu era CTO, passamos de cerca de 100 mil processamentos por dia em todos os serviços para milhões e, no fim, dezenas de milhões. Nesse processo, um engenheiro construiu uma fila sobre a semântica do LISTEN/NOTIFY para aproveitar a consistência forte com o modelo de dados. Não era difícil de entender e eliminava uma camada separada de armazenamento e entrega, então parecia razoável na época
Mas, ao escalar uma funcionalidade feita internamente, ficou muito incômodo ter de contornar o funcionamento interno do PostgreSQL, e deveríamos ter migrado para outro sistema mais cedo. A escalabilidade também não era boa; no RDS, houve muita contenção de disco difícil de diagnosticar, e o VACUUM daquela tabela era um pesadelo. Como uma fila familiar estava implementada de forma incomum usando recursos internos do PostgreSQL, outros engenheiros tinham receio e evitavam depurar e assumir ownership
Tirando detalhes como schema e índices, a principal lição é sempre começar por tecnologias simples e previsíveis. Se você não precisa de consistência de dados muito forte, é melhor usar uma fila com contrato de API simples, como SQS ou uma fila Redis, mesmo que isso acrescente um componente de infraestrutura, e adaptar o restante a ela. Quanto menos responsabilidades mecânicas o armazenamento principal de dados tiver, melhor
À medida que o ecossistema de sistemas distribuídos evolui, passamos a entender o que cada componente pode fazer. Começar com menos componentes e adicionar outros quando realmente necessário cria sistemas mais saudáveis
Continuo gostando da DBOS, que aproveita bem o Postgres e agora também o SQLite. Dá para introduzi-la em stacks CRUD existentes com quase nenhum esforço
Quando você começa a usar workflows duráveis, passa a ver lugares para aplicá-los o tempo todo. Recentemente, estou experimentando tratar cada email como um workflow durável, com o usuário, a outra parte, agentes e ferramentas como GitHub ou Attio participando do fluxo em sequência
https://housecat.com/blog/gmail-durable-workflows-sandbox-vm
Textos assim geralmente são resultado de cada pessoa avaliar seu problema, seu entendimento e sua solução de forma independente. É ambíguo chamar de falta de competência só porque alguém esperou determinado desempenho com a configuração padrão de uma ferramenta; todos continuam aprendendo com falhas
O fato de o experimento ter usado um servidor de banco de dados com 96 núcleos e 384 GB de RAM (https://github.com/dbos-inc/dbos-postgres-benchmark/blob/mai...) é muito importante e deveria ter sido declarado com clareza. Bancos de dados podem escalar verticalmente, mas isso também tem limite. Quem se conecta e de onde também afeta o desempenho e a latência total
60 mil por segundo pode parecer muito, mas o que derruba sistemas reais são picos repentinos de tráfego, não o tráfego normal. Se você não é uma grande empresa, não vai começar com um servidor enorme desses. Incluindo réplicas de leitura e redundância entre regiões, um único cluster de banco de dados em produção passa de US$ 100 mil
Parece ser um artigo relacionado: Postgres LISTEN/NOTIFY does not scale - https://news.ycombinator.com/item?id=44490510 - julho de 2025, 321 comentários
Parece faltar o ponto mais importante do artigo: um método de atribuição de offsets ou números de sequência que permita rastrear até onde o consumidor leu e consultar novas mensagens pelo caminho alternativo. Há várias formas de fazer isso, mas não é fácil resolver sem complexidade ou contenção de locks; se implementado incorretamente, também cria condições de corrida com os consumidores. Em geral, o produtor faria lock em uma única linha de uma tabela de estado, por exemplo, para atribuir o próximo número do tópico de eventos
Fico curioso para saber qual é a melhor abordagem. Uma ferramenta que leia Change Data Capture (CDC) e escreva os números de eventos atribuídos em outra tabela talvez seja aceitável, mas pode aumentar a latência, e esse processador de CDC também precisaria executar o NOTIFY
Do lado do consumidor, há usos em que o processamento em lote pode aumentar bastante a vazão. Nesse caso, basta executar o consumidor repetidamente sem LISTEN/NOTIFY, processar todas as novas mensagens ainda não processadas a cada iteração e salvar o último número de sequência entre as iterações
Pelo que me lembro, a release que primeiro deu suporte a LISTEN/NOTIFY tinha uma implementação de locks ruim, o que causava problemas de desempenho. O artigo antigo criticado aqui também corrigiu isso em uma errata logo após o primeiro parágrafo
Se a data da correção foi 8 de maio, então o artigo de 24 de julho precisa reconhecer que o texto famoso que dizia que esse recurso não escalava não foi escrito de má-fé e talvez nem estivesse errado para a época
Em vez disso, otimiza um caso mais restrito, em que há muitos canais de notificação e cada receptor espera apenas por um canal específico
Da última vez que verifiquei, LISTEN/NOTIFY tinha um limite de 8.000 bytes nos dados da notificação, então havia um aspecto em que claramente não escalava. Se forem dados que não podem ser armazenados como uma linha e transmitidos apenas por ID, fica difícil usar
Os eventos de um jogo web eram dados temporários que descreviam mudanças de estado, não havia motivo para armazená-los no banco de dados, e eles podiam passar de 8.000 bytes, então não servia para esse caso de uso
O artigo trata da contenção de locks na fila global, mas parece não mencionar outro problema da fila global de tamanho fixo. Um único receptor lento em um canal podia bloquear escritas em todos os canais. Pelo menos alguns anos atrás esse tipo de falha era possível; talvez isso tenha mudado agora