Postgres LISTEN/NOTIFY: Realmente escala
O Postgres LISTEN/NOTIFY tem uma má reputação, em parte, devido a um post popular que afirma que ele não escala. Se isso fosse verdade, seria uma pena, pois o LISTEN/NOTIFY é uma ferramenta poderosa, permitindo usar seu banco de dados Postgres para notificações duráveis de baixa latência, streams e pub/sub (publicação/assinatura, um padrão de comunicação onde remetentes de mensagens não as enviam diretamente a receptores específicos, mas sim as categorizam em tópicos). As acusações não estão erradas: o NOTIFY tem características de desempenho não intuitivas e não documentadas, decorrentes do uso de um lock global. Mas "comportamento não intuitivo" não é o mesmo que "não escalável".
Neste artigo, vamos mostrar como otimizamos streams baseados em LISTEN/NOTIFY em escala, alcançando 60 mil gravações por segundo em um único servidor Postgres com latência de milissegundos.
A arquitetura básica de streams baseados em Postgres é simples: cria-se uma tabela de streams onde cada "pedaço" do stream (por exemplo, um token de resposta de um LLM – pequenos pedaços de texto gerados por modelos de linguagem grandes) é uma nova linha, e então se escreve nos streams inserindo na tabela.
A parte complicada é ler do stream, pois você não sabe quando o próximo pedaço chegará. Uma solução é o polling (um método onde um sistema verifica repetidamente outro em intervalos regulares para ver se há novas informações): fazer cada leitor consultar o final do stream por novos pedaços. No entanto, o polling escala mal. Se o intervalo de polling for muito alto, a latência é muito alta para casos de uso interativos (como chats online). Mas se o intervalo de polling for muito baixo, pollers concorrentes sobrecarregam o banco de dados.
A solução melhor é o LISTEN/NOTIFY. Isso permite que os leitores bloqueiem esperando por uma notificação de um escritor de que um novo pedaço foi publicado no stream. Dessa forma, os leitores não desperdiçam recursos com polling, mas acordam imediatamente quando um novo pedaço do stream chega.
Em nossa implementação inicial de streams baseados em LISTEN/NOTIFY, um trigger na tabela de streams disparava uma função que enviava uma notificação toda vez que um novo pedaço do stream era gravado. Os leitores esperavam por essas notificações e acordavam para um novo pedaço do stream.
Essa implementação estava correta e entregava baixa latência, mas em escala, sua vazão era baixa. Mesmo usando um grande banco de dados Postgres, ela não conseguia sustentar mais de 2.9 mil gravações de stream por segundo. Curiosamente, ela atingia o gargalo sem consumir visivelmente nenhum recurso do Postgres (CPU, memória ou IOPS). Como você deve ter adivinhado, a causa raiz era o problema original do "LISTEN/NOTIFY não é escalável": um lock global que o Postgres assume durante o NOTIFY. Mas por que o Postgres faz isso, e como podemos otimizá-lo sem perder os benefícios das notificações do Postgres?
Desvendando o Nó: O Lock Exclusivo do LISTEN/NOTIFY
Para entender o problema, precisamos examinar como o LISTEN/NOTIFY do Postgres realmente funciona.
A causa raiz do baixo desempenho é que, no Postgres, a confirmação de uma transação que chama NOTIFY exige a obtenção de um global exclusive lock (um bloqueio exclusivo global, que impede que outras operações acessem ou modifiquem um recurso compartilhado no banco de dados). Este lock é tomado quando a transação começa a ser confirmada e não é liberado até que a transação esteja totalmente confirmada e seu conteúdo tenha sido gravado em disco com fsync() (uma chamada de sistema que garante que os dados em cache sejam gravados fisicamente no disco).
Este lock é necessário porque o Postgres garante que as notificações sejam enviadas na transaction commit order (a ordem em que as transações são permanentemente salvas no banco de dados). Para impor isso, ele armazena todas as notificações de saída em uma fila interna global cuja ordem deve corresponder exatamente à ordem de commit das transações que enviam essas notificações. Adicionar notificações a esta fila deve ser feito transacionalmente como parte do commit. No entanto, o Postgres não atribui uma ordem de commit às transações até que elas terminem de ser confirmadas, pois a confirmação pode levar um tempo variável.
Isso cria um problema de ordenação: as transações que contêm notificações devem se adicionar à fila na ordem de commit, mas a ordem de commit não é definida até que o commit esteja completo. A solução é o lock global, que serializa os commits de transações que contêm notificações, de modo que sua ordem de commit seja definida antecipadamente e elas possam se ordenar corretamente na fila de notificações interna.
Este lock exclusivo explica o baixo desempenho que observamos. Como chamamos NOTIFY de um trigger na tabela de streams, cada gravação de stream inclui uma chamada para NOTIFY. Para ser confirmada, cada gravação de stream precisa pegar o lock global e mantê-lo durante toda a duração de seu commit, incluindo a gravação em disco. Isso significa que as gravações de stream precisam ser confirmadas sequencialmente, impedindo as otimizações usuais do Postgres, como o group commit (que confirma muitas transações juntas em um único fsync()). Como resultado, as gravações de stream não podem ser concluídas mais rápido do que o Postgres pode confirmar transações, o que leva a esse gargalo. Isso também explica por que não vimos consumo significativo de nenhum recurso do Postgres, como CPU ou disco: não havia nenhum, porque todas as transações eram serializadas por um lock global.
A propósito, tem havido alguma discussão online sobre um patch do Postgres relacionado a este problema. Este patch (a ser lançado no Postgres 19) não remove o lock global nem corrige o gargalo que observamos. Em vez disso, ele otimiza o caso mais restrito onde há muitos canais de notificação e cada listener está esperando apenas em um canal específico.
A Solução: Otimizando o LISTEN/NOTIFY em Escala
Para tornar os streams baseados em LISTEN/NOTIFY mais rápidos, precisamos contornar esse gargalo. A observação chave é que, para streams, e para muitas outras aplicações de LISTEN/NOTIFY, as notificações não são, por si só, uma fonte de verdade. Em vez disso, elas apenas "pingam" um leitor para verificar uma tabela do banco de dados (a verdadeira fonte de verdade) em busca de novos dados. Como resultado, as notificações não precisam ser globalmente ordenadas ou perfeitamente duráveis, então podemos otimizar o NOTIFY bufferizando (armazenando temporariamente na memória) as notificações e periodicamente gravando-as em uma única batch transaction (uma transação que agrupa várias operações menores em uma única operação maior), reduzindo significativamente a contenção no lock global.
A bufferização e o batching de NOTIFYs evitam o gargalo porque o lock global só precisa ser tomado quando o buffer é gravado, e não para cada gravação individual do stream. Isso significa que as gravações individuais do stream podem prosseguir rapidamente, aproveitando as otimizações do Postgres, como o group commit, para obter alta vazão, enquanto o buffer grava em segundo plano.
Adotar um buffer introduz uma nova complicação: uma falha de processo pode levar à perda de notificações que ainda não foram gravadas. No entanto, dado que as notificações são apenas um "ping" para checar a fonte de verdade, isso geralmente é um risco aceitável, pois o leitor pode simplesmente re-sincronizar se perder uma notificação.
Por Que Isso Importa Para Você
A lição aqui é clara: ferramentas poderosas como o LISTEN/NOTIFY do Postgres, mesmo com suas peculiaridades, podem ser usadas de forma eficaz em cenários de alta performance. Em vez de descartar uma tecnologia baseada em mitos ou compreensões superficiais, vale a pena mergulhar nos detalhes de sua implementação.
Para desenvolvedores e arquitetos, entender os mecanismos internos do banco de dados, como a natureza dos locks e as otimizações de commit, é crucial para construir sistemas escaláveis. Com a estratégia certa, é possível transformar um suposto "gargalo" em um componente de alta vazão, abrindo caminho para aplicações em tempo real, como streaming de respostas de LLMs, chat e outras experiências interativas que exigem baixa latência e alta capacidade. Não deixe que uma má reputação impeça você de explorar o potencial total de suas ferramentas.
Fontes
📬 Gostou? Assine a newsletter do Vaccari's Code e receba as próximas tendências em software e IA direto no seu e-mail: Assinar aqui