Postgres 的 LISTEN/NOTIFY 确实可以扩展。
Postgres LISTEN/NOTIFY actually scales

原始链接: https://www.dbos.dev/blog/postgres-listen-notify-scalability

尽管 Postgres 的 `LISTEN/NOTIFY` 常因扩展性不佳而受诟病,但如果使用得当,它仍是实现低延迟流式传输的强大工具。常见的性能瓶颈源于 Postgres 使用的全局排他锁,该锁旨在确保通知按事务提交的严格顺序发送,这导致了写入操作的序列化,并阻碍了组提交(group commit)优化。 在高吞吐量场景下,对每一次单独的表插入操作都触发 `NOTIFY` 会强制这些写入操作按顺序提交,从而产生性能瓶颈。 为了克服这一问题,开发者可以将通知与事务解耦。通过在内存中缓冲通知并分批次刷新,全局锁在每个批次中仅会被占用一次,而非每次写入都占用。这使得单个流写入操作能够利用 Postgres 原生的组提交功能。为确保崩溃时的可靠性,读取端可采用低频轮询作为后备方案,以捕获任何漏掉的通知。 采用这种缓冲方法,性能可以得到显著提升。基准测试显示,吞吐量可从每秒约 2900 次写入提高到 60000 次,同时保持毫秒级的延迟,从而成功地将性能瓶颈从锁竞争转移到了实际的数据库资源饱和。

```Hacker News 最新 | 过往 | 评论 | 提问 | 展示 | 招聘 | 提交 登录 Postgres 的 LISTEN/NOTIFY 其实是可以扩展的 (dbos.dev) 22 点,由 KraftyOne 发布于 24 分钟前 | 隐藏 | 过往 | 收藏 | 1 条评论 帮助 dietr1ch 6 分钟前 | 上一条 [–] 我记得在第一个支持 LISTEN/NOTIFY 的版本中,它存在性能问题(如果我没记错的话是锁定机制不佳),而文中提到的那篇“糟糕的文章”在第一段之后就进行了勘误。既然该修正显然是在 5 月 8 日作出的,我认为 7 月 24 日发布的一篇文章或许应该承认,那篇声称该功能无法(当时无法?)扩展的流行文章并非出于恶意,甚至在当时看来,其观点未必是错的。 回复 考虑申请 YC 2026 秋季班!申请截止日期为 7 月 27 日。 准则 | 常见问题 | 列表 | API | 安全 | 法律 | 申请 YC | 联系 搜索: ```
相关文章

原文

Postgres LISTEN/NOTIFY has a bad reputation thanks in part to a popular blog post asserting it does not scale. If that were true, it would be a shame, because LISTEN/NOTIFY is a powerful tool, allowing you to use your Postgres database for low-latency durable notifications, streams, and pub/sub. The accusations aren’t wrong: NOTIFY has unintuitive and undocumented performance characteristics arising from its use of a global lock. But “unintuitive behavior” is not the same as “not scalable.” In this blog post, we’ll show how we optimized LISTEN/NOTIFY-backed streams at scale, achieving 60K writes per second on a single Postgres server with millisecond-scale latency.

Low-Latency Streaming with LISTEN/NOTIFY

The basic design of Postgres-backed streams is simple: create a streams table where each stream chunk (for example, an LLM response token) is a new row, then write to streams by inserting into the table.

The tricky part is reading from the stream because you don't know when the next chunk will arrive. One solution is polling: have each reader poll the end of the stream for new chunks. However, polling scales poorly. If the polling interval is set too high, latency is too high for interactive use-cases (e.g., online chats). But if the polling interval is set too low, concurrent pollers overwhelm the database.

The better solution is LISTEN/NOTIFY. This allows readers to block waiting for a notification from a writer that a new chunk has been published to the stream. That way, readers don’t waste resources polling, but wake up immediately when a new stream chunk arrives.

In our initial implementation of LISTEN/NOTIFY-based streams, a trigger on the streams table fired a function that sent a notification every time a new stream chunk was written. Readers waited for these notifications and woke up to a new stream chunk.

This implementation was correct and delivered low latency, but at scale its throughput was poor. Even using a large Postgres database, it could not sustain more than 2.9K stream writes per second. Interestingly, it bottlenecked without visibly consuming any Postgres resource (CPU, memory, or IOPS). As you may have guessed, the root cause was the original “LISTEN/NOTIFY is not scalable” issue: a global lock Postgres takes during NOTIFY. But why does Postgres do that, and how can we optimize it without losing the benefits of Postgres notifications?

The LISTEN/NOTIFY Exclusive Lock

To understand the problem, we’ll need to examine how Postgres LISTEN/NOTIFY actually works.

The root cause of the poor performance is that in Postgres, committing a transaction that calls NOTIFY requires taking a global exclusive lock. This lock is taken as the transaction begins to commit, and is not released until the transaction is fully committed and its contents have been flushed to disk with fsync().

This lock is necessary because Postgres guarantees that notifications are sent in transaction commit order. To enforce this, it stores all outgoing notifications in a global internal queue whose order must exactly match the commit order of the transactions sending those notifications. Adding notifications to this queue must be done transactionally as part of the commit. However, Postgres doesn’t assign transactions a commit order until those transactions are done committing, as committing can take a variable amount of time.

This creates an ordering problem: transactions containing notifications must add themselves to the queue in commit order, but commit order isn’t defined until the commit is complete. The solution is the global lock, which serializes commits of transactions containing notifications, so their commit order is defined ahead of time and they can correctly order themselves in the internal notifications queue. 

This exclusive lock explains the poor performance we observed. Because we call NOTIFY from a trigger on the streams table, every stream write includes a call to NOTIFY. In order to commit, each stream write needs to take the global lock and hold it for the entire duration of its commit, including the flush to disk. This means that stream writes need to commit sequentially, precluding Postgres’s usual optimizations like group commit (which commits many transactions together in a single fsync()). As a result, stream writes can complete no faster than Postgres can commit transactions, which leads to this bottleneck. This also explains why we did not see significant consumption of any Postgres resource such as CPU or disk: there wasn’t any, because all transactions were serialized by a global lock.

As an aside, there’s been some online discussion of a Postgres patch related to this issue. This patch (to be released in Postgres 19) does not remove the global lock or fix the bottleneck we observed. Instead, it optimizes the narrower case where there are many notification channels and each listener is waiting only on a specific channel.

Optimizing LISTEN/NOTIFY

To make LISTEN/NOTIFY-backed streams faster, we have to work around this bottleneck. The key observation is that for streams, and for many other applications of LISTEN/NOTIFY, the notifications aren’t themselves a source of truth. Instead, they just ping a reader to check a database table (the real source of truth) for new data. As a result, notifications don’t have to be globally ordered or perfectly durable, so we can optimize NOTIFY by buffering notifications in memory and periodically flushing them in a single batch transaction, significantly reducing contention on the global lock.

Buffering and batching NOTIFYs avoids the bottleneck because the global lock only needs to be taken when the buffer is flushed, not for each individual stream write. This means that individual stream writes can proceed quickly, taking advantage of Postgres optimizations like group commit to obtain high throughput, while the buffer flushes in the background.

Adopting a buffer introduces a new complication, which is that a process crash while notifications are buffered leads to those notifications never being delivered. To solve this issue, we add a fallback to stream readers: in addition to waiting for notifications, they also periodically poll the database to check if the stream was written to without a notification. The frequency of this polling can be low (because it is only a fallback for undelivered notifications), so it does not significantly affect performance.

Benchmarking this optimized solution, we see massively improved performance: in the presence of concurrent readers, we can perform up to 60K stream writes per second (20x more than before) while still obtaining 15-100ms latency. At maximum throughput, Postgres CPU is fully utilized, showing the database is actually saturated instead of bottlenecked on contention.

Learn More

All benchmark code is available on GitHub: github.com/dbos-inc/dbos-postgres-benchmark

If you like building scalable, reliable systems, we’d love to hear from you. At DBOS, our goal is to make Postgres-backed durable execution as simple and performant as possible. Check it out:

联系我们 contact @ memedata.com