Postgres LISTEN/NOTIFY 实际上可扩展
摘要
这篇博文驳斥了 Postgres LISTEN/NOTIFY 无法扩展的传言,展示了如何优化它以实现每秒6万次写入,延迟为毫秒级。
暂无内容
查看缓存全文
缓存时间: 2026/07/24 20:03
# Postgres LISTEN/NOTIFY 实际上具备可扩展性
来源:https://www.dbos.dev/blog/postgres-listen-notify-scalability
Postgres 的 LISTEN/NOTIFY 名声不佳,部分原因在于一篇流行的博文声称它不具备可扩展性(https://www.recall.ai/blog/postgres-listen-notify-does-not-scale)。如果真是这样,那将是一种遗憾,因为 LISTEN/NOTIFY 是一个强大的工具,允许你将 Postgres 数据库用于低延迟、持久化的通知、流和发布/订阅。这些指责并非没有道理:NOTIFY 由于其全局锁的使用,存在不直观且未记录的性能特征。但“不直观的行为”并不等于“不可扩展”。在这篇博文中,我们将展示在大规模场景下如何优化基于 LISTEN/NOTIFY 的流,在单个 Postgres 服务器上实现每秒 6 万次写入,延迟保持在毫秒级别。
### 使用 LISTEN/NOTIFY 实现低延迟流
基于 Postgres 的流的基本设计很简单:创建一个流表,其中每个流块(例如,LLM 响应令牌)都是一行新数据,然后通过向表中插入数据来写入流。
读取流的难点在于你不知道下一个块何时到达。一种解决方案是轮询:让每个读取器轮询流的末尾以获取新块。然而,轮询的可扩展性很差。如果轮询间隔设置得太高,交互式用例(例如在线聊天)的延迟就会过高。但如果轮询间隔设置得太低,并发轮询者会压垮数据库。
更好的解决方案是使用 LISTEN/NOTIFY。这允许读取器阻塞等待写入器发来的通知,告知流中已发布新块。这样,读取器无需浪费资源进行轮询,而是当新流块到达时立即被唤醒。
在我们最初基于 LISTEN/NOTIFY 的流实现中,流表上的触发器会触发一个函数,该函数在每次写入新流块时发送一个通知。读取器等待这些通知,并在新流块到达时被唤醒。
这个实现是正确的,并且提供了低延迟,但在大规模场景下吞吐量很差。即使使用大型 Postgres 数据库,它也只能维持每秒 2.9K 次流写入。有趣的是,它在没有明显消耗任何 Postgres 资源(CPU、内存或 IOPS)的情况下成为瓶颈。正如你可能已经猜到的,根本原因是原始的“LISTEN/NOTIFY 不可扩展”问题:Postgres 在 NOTIFY 期间使用的全局锁。但为什么 Postgres 要这样做,以及我们如何在不损失 Postgres 通知优势的情况下优化它呢?
### LISTEN/NOTIFY 排他锁
要理解这个问题,我们需要研究 Postgres 的 LISTEN/NOTIFY 实际工作方式。
性能不佳的根本原因是,在 Postgres 中,提交一个调用 NOTIFY 的事务需要获取一个全局排他锁。这个锁在事务开始提交时被获取,并且直到事务完全提交并且其内容已通过 fsync() 刷新到磁盘后才释放。
这个锁是必需的,因为 Postgres 保证通知按照事务提交顺序发送。为了强制执行这一点,它将所有发出的通知存储在一个全局内部队列中,该队列的顺序必须与发送这些通知的事务的提交顺序完全一致。将通知添加到这个队列必须以事务方式进行,作为提交的一部分。然而,Postgres 直到事务完成提交时才为事务分配提交顺序,因为提交可能需要不同的时间。
这就产生了一个排序问题:包含通知的事务必须按照提交顺序将自己添加到队列中,但提交顺序只有在提交完成后才确定。解决方案就是全局锁,它序列化包含通知的事务的提交,这样它们的提交顺序就可以提前确定,并且它们可以正确地按顺序排列在内部通知队列中。
这个排他锁解释了我们观察到的糟糕性能。因为我们从流表的触发器中调用 NOTIFY,所以每次流写入都包含一次对 NOTIFY 的调用。为了提交,每次流写入都需要获取全局锁,并在整个提交过程中持有它,包括刷新到磁盘。这意味着流写入需要顺序提交,从而排除了 Postgres 的常规优化(如组提交,即在一个 fsync() 中一起提交多个事务)。因此,流写入的速度不会超过 Postgres 提交事务的速度,这导致了瓶颈。这也解释了为什么我们没有看到任何显著的 Postgres 资源消耗,如 CPU 或磁盘:因为所有事务都被一个全局锁序列化了。
顺便提一下,网络上有一个关于 Postgres 补丁(https://github.com/postgres/postgres/commit/282b1cde9dedf456ecf02eb27caf086023a7bb71)与此问题相关的讨论。这个补丁(将在 Postgres 19 中发布)并没有移除全局锁或修复我们观察到的瓶颈。相反,它优化了一个更窄的情况:当有很多通知通道并且每个监听器只等待特定通道时。
### 优化 LISTEN/NOTIFY
为了加快基于 LISTEN/NOTIFY 的流的速度,我们必须绕过这个瓶颈。关键观察点是:对于流以及 LISTEN/NOTIFY 的许多其他应用,通知本身并非数据源。相反,它们只是 ping 读取器去检查数据库表(真正的数据源)中是否有新数据。因此,通知不必全局排序或完全持久化,所以我们可以通过将通知缓冲在内存中,并周期性地在单个批处理事务中刷新它们来优化 NOTIFY,从而显著减少对全局锁的争用。
缓冲和批处理 NOTIFY 避免了瓶颈,因为全局锁只需要在刷新缓冲区时获取,而不是每次单独的流写入。这意味着单独的流写入可以快速进行,利用 Postgres 的优化(如组提交)来获得高吞吐量,同时缓冲区在后台刷新。
采用缓冲区引入了一个新的复杂性:如果通知在缓冲期间进程崩溃,这些通知将永远不会被传递。为了解决这个问题,我们为流读取器添加了一个回退机制:除了等待通知外,它们还会定期轮询数据库,以检查流是否在没有通知的情况下被写入。由于这仅仅是针对未传递通知的回退,轮询频率可以很低,因此不会显著影响性能。
对优化后的解决方案进行基准测试,我们看到了性能的极大提升:在存在并发读取器的情况下,我们可以在保持 15-100ms 延迟的同时,实现每秒高达 6 万次流写入(是之前的 20 倍)。在最大吞吐量下,Postgres CPU 被完全利用,表明数据库实际上已经饱和,而不是因为争用而成为瓶颈。
### 了解更多
所有基准测试代码均可在 GitHub 上获取:github.com/dbos-inc/dbos-postgres-benchmark (http://github.com/dbos-inc/dbos-postgres-benchmark)
如果你喜欢构建可扩展、可靠的系统,我们期待你的来信。在 DBOS,我们的目标是让基于 Postgres 的持久化执行尽可能简单和高效。欢迎了解:
- 快速入门:https://docs.dbos.dev/quickstart
- GitHub:https://github.com/dbos-inc
- Discord 社区:https://discord.gg/eMUHrvbu67
相似文章
让Postgres队列实现可扩展性
一篇详细的技术博文,解释如何使用SKIP LOCKED和适当的事务隔离级别来扩展基于PostgreSQL的队列,实现每秒3万次工作流执行。
扩展PostgreSQL以支持8亿ChatGPT用户
OpenAI分享了扩展PostgreSQL以支持8亿ChatGPT用户及每秒数百万查询的技术见解,采用了单主架构搭配50个只读副本,同时通过分片和优化策略管理写入密集型工作负载带来的挑战。
我们将PgBouncer扩展到4倍吞吐量
ClickHouse Managed Postgres通过运行一组使用SO_REUSEPORT的进程,将PgBouncer扩展到4倍吞吐量,实现了多核利用率,并通过对等连接解决了取消转发问题。
在副本上读取自己的写入
本文讨论了数据库副本中的读取自己的写入问题,并重点介绍了PostgreSQL 19的新命令WAIT FOR LSN作为解决方案,以提高一致性和减少延迟。
Show HN: Honker – 为 SQLite 带来 Postgres NOTIFY/LISTEN 语义
Honker 是一个 SQLite 扩展,为 SQLite 添加 PostgreSQL 风格的 NOTIFY/LISTEN 语义,无需外部代理或轮询即可实现持久化的发布/订阅与任务队列。