Postgres LISTEN/NOTIFY 扩展性优化实践
Postgres 的 LISTEN/NOTIFY 名声不佳,部分原因是一篇广为流传的博客文章声称它无法扩展。如果真是这样,那未免太可惜了,因为 LISTEN/NOTIFY 是一个强大的工具,能让你用 Postgres 数据库实现低延迟的持久化通知、流和发布/订阅。这些指责并非空穴来风:NOTIFY 由于使用了全局锁,其性能表现既反直觉又缺乏文档。但「反直觉的行为」不等于「无法扩展」。本文将展示我们如何优化基于 LISTEN/NOTIFY 的流式方案,使其在单个 Postgres 服务器上达到每秒 6 万次写入,且延迟控制在毫秒级。
基于 LISTEN/NOTIFY 的低延迟流式处理
基于 Postgres 的流式设计很简单:创建一张 streams 表,每个流片段(例如 LLM 的响应 token)对应一行新记录,写入流时只需向表中插入数据。

棘手的部分在于从流中读取数据,因为你不知道下一个片段何时到达。一种解决方案是轮询:让每个读取器不断轮询流的末尾,检查是否有新片段。但轮询的可扩展性很差。如果轮询间隔设置得太长,交互式场景(如在线聊天)的延迟就过高;如果设置得太短,大量并发轮询又会压垮数据库。
更好的方案是 LISTEN/NOTIFY。它让读取器可以阻塞等待,直到写入者发出通知,告知新的片段已发布到流中。这样,读取器无需浪费资源轮询,而是能在新片段到达时立即被唤醒。
在我们最初基于 LISTEN/NOTIFY 的流实现中,在 streams 表上设置了触发器,每次写入新片段时,触发器会调用一个函数发送通知。读取器等待这些通知,并在收到通知时醒来处理新片段。

这个实现逻辑正确,延迟也低,但扩展后吞吐量就不行了。即便用的是大型 Postgres 数据库,每秒流写入也只能维持在 2.9K 以下。有意思的是,瓶颈出现时,Postgres 的 CPU、内存、IOPS 等资源并没有明显消耗。你可能已经猜到了,根本原因就是那个老问题——“LISTEN/NOTIFY 不可扩展”:Postgres 在 NOTIFY 时拿了一个全局锁。但 Postgres 为什么要这么做?我们又该如何优化,才能既保留 Postgres 通知的优势,又解决这个瓶颈?

LISTEN/NOTIFY 的排他锁
要理解这个问题,得先看看 Postgres 的 LISTEN/NOTIFY 到底是怎么工作的。
性能差的根本原因在于:在 Postgres 中,提交一个调用了 NOTIFY 的事务,需要先获取一个全局排他锁。这个锁在事务开始提交时获取,直到事务完全提交、并且内容通过 fsync() 刷到磁盘后才会释放。
这个锁是必须的,因为 Postgres 保证通知按事务提交的顺序发送。为了做到这一点,它把所有待发送的通知存到一个全局内部队列里,队列的顺序必须严格匹配发送通知的事务的提交顺序。向这个队列添加通知必须作为提交的一部分,以事务方式完成。但问题是,Postgres 直到事务提交完成之后,才会给事务分配一个提交顺序——因为提交本身耗时是不固定的。
这就产生了排序问题:包含通知的事务必须按提交顺序加入队列,但提交顺序在提交完成之前是未知的。解决办法就是全局锁:它让包含通知的事务序列化提交,这样一来,提交顺序就能提前确定,事务也能在内部通知队列里正确排序。
这把排他锁解释了为什么我们观察到性能不佳。由于我们在 streams 表的触发器里调用了 NOTIFY,每次流写入都会触发一次 NOTIFY。为了提交,每个流写入都需要获取全局锁,并在整个提交过程中(包括刷盘)一直持有它。这意味着流写入必须串行提交,使得 Postgres 无法利用其常规优化(如组提交,即多个事务通过一次 fsync() 同时提交)。结果就是,流写入的速度受限于 Postgres 提交事务的速度,从而形成瓶颈。这也解释了为什么我们没有看到任何 Postgres 资源(如 CPU 或磁盘)被大量消耗:因为根本没有资源消耗,所有事务都被全局锁串行化了。
顺便一提,网上有一些关于 Postgres 与该问题相关的补丁的讨论。这个补丁(将在 Postgres 19 中发布)并没有移除全局锁或修复我们观察到的瓶颈。它只是优化了一个更窄的场景:当通知频道很多,且每个监听者只等待特定频道时。
优化 LISTEN/NOTIFY
要加速基于 LISTEN/NOTIFY 的流,我们必须绕开这个瓶颈。关键在于:对于流,以及许多其他 LISTEN/NOTIFY 的应用场景,通知本身并不是数据源。它们只是用来通知读取方去检查数据库表(真正的数据源)是否有新数据。因此,通知不需要全局有序或完全持久化。我们可以通过将通知缓存在内存中、定期批量刷入单个事务来优化 NOTIFY,从而显著减少对全局锁的争用。
缓冲并批量处理 NOTIFY 可以避免瓶颈,因为全局锁只在缓冲区刷入时才需要获取,而不是每次单个流写入都要获取。这样一来,单个流写入可以快速进行,利用 Postgres 的组提交等优化获得高吞吐量,而缓冲区在后台刷入。
引入缓冲区后,又出现了一个新问题:当通知在缓冲区中积压时,进程崩溃会导致这些通知永远无法送达。为了解决这个问题,我们为流式读取器增加了回退机制:除了等待通知,它们还会定期轮询数据库,检查流是否在未发送通知的情况下被写入。由于这仅作为未送达通知的补救措施,轮询频率可以很低,因此不会显著影响性能。
对这个优化方案进行基准测试后,我们看到了性能的显著提升:在有并发读取器的情况下,每秒可完成多达 6 万次流写入(是之前的 20 倍),同时延迟仍保持在 15-100 毫秒。在最大吞吐量下,Postgres 的 CPU 被完全利用,说明数据库确实达到了饱和,而非由于争用而成为瓶颈。

了解更多
所有基准测试代码可在 GitHub 上获取: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
近期文章
关于持久化执行、AI 工作流等的最新内容。
产品新闻2026年7月20日DBOS 新功能 — 2026年7月
持久化流性能改进、DBOS Transact for Java 1.0、审计日志、Kafka 集成优化等。
Qian Li教程Jun 22, 2026通过 OpenMetrics 集成工作流可观测性
DBOS OpenMetrics 端点正式发布——简化与 Datadog、GrafanaLabs 等工具的工作流可观测性集成。
Peter Kraft
产品新闻Jun 18, 2026DBOS 2026年6月新功能
DBOS 新增:RBAC 支持、OpenMetrics、批量工作流分支、Google ADK 插件等。
Qian Li
返回洞察Postgres LISTEN/NOTIFY 其实可以扩展
Peter KraftJuly 24, 2026教程
Postgres LISTEN/NOTIFY 名声不佳,部分原因是一篇热门博客宣称它不可扩展。如果真是这样,那就太可惜了,因为 LISTEN/NOTIFY 是一个强大工具,能让你的 Postgres 数据库实现低延迟的持久化通知、流式处理和发布/订阅。这些指责并非空穴来风:NOTIFY 由于使用了全局锁,其性能特性既反直觉又缺乏文档说明。但"反直觉行为"并不等于"不可扩展"。在这篇博客中,我们将展示如何优化基于 LISTEN/NOTIFY 的流式处理,使其在单台 Postgres 服务器上实现每秒 6 万次写入,延迟在毫秒级。
使用 LISTEN/NOTIFY 实现低延迟流式处理
基于 Postgres 的流设计很简单:创建一个 streams 表,每个流数据块(例如 LLM 响应令牌)作为新的一行,然后通过插入数据来写入流。

麻烦的是如何读取流——你无法预知下一个数据块何时到达。一种方案是轮询:每个读取者不断查询流的末尾,检查是否有新数据块。但轮询的扩展性很差。若轮询间隔太长,交互式场景(如在线聊天)的延迟会过高;若间隔太短,大量并发轮询又会压垮数据库。
更好的方案是 LISTEN/NOTIFY。它允许读取者阻塞等待写入者发来的通知,告知新数据块已发布到流中。这样读取者无需浪费资源轮询,一旦有新数据块就能立即被唤醒。
在我们最初基于 LISTEN/NOTIFY 的流实现中,每当有新的流数据块写入时,streams 表上的触发器会调用一个函数发送通知。读取者等待这些通知,收到后立即处理新数据块。

这个实现正确且延迟低,但在大规模场景下吞吐量很差。即便使用大型 Postgres 数据库,每秒也只能承受约 2.9K 次流写入。有趣的是,瓶颈发生时,Postgres 的 CPU、内存、IOPS 等资源均未明显占用。你可能已经猜到,根本原因正是那个老问题——"LISTEN/NOTIFY 不可扩展":Postgres 在 NOTIFY 时会加一个全局锁。但 Postgres 为什么要这么做?我们又该如何优化,才能既不丢失 Postgres 通知的优势,又能提升性能?

LISTEN/NOTIFY 的排他锁
要理解这个问题,我们需要深入 Postgres LISTEN/NOTIFY 的具体工作原理。
性能不佳的根本原因在于,Postgres 中提交一个调用 NOTIFY 的事务需要获取全局排他锁。这个锁在事务开始提交时获取,直到事务完全提交且其内容通过 fsync() 刷入磁盘后才释放。
这个锁是必要的,因为 Postgres 保证通知按事务提交顺序发送。为了确保这一点,它将所有待发送的通知存储在一个全局内部队列中,队列顺序必须与发送通知的事务的提交顺序完全一致。向该队列添加通知必须作为提交的一部分事务性地完成。然而,Postgres 只有在事务提交完成后才会分配提交顺序,因为提交所需时间可能不同。
这就产生了一个排序问题:包含通知的事务必须按提交顺序将自己加入队列,但提交顺序在提交完成前是未知的。解决方案是全局锁,它序列化包含通知的事务的提交,从而预先定义提交顺序,使它们能在内部通知队列中正确排序。
这个排他锁解释了为什么我们观察到性能不佳。由于我们从 streams 表的触发器调用 NOTIFY,每次 stream 写入都包含一个 NOTIFY 调用。为了提交,每个 stream 写入需要获取全局锁并保持整个提交过程,包括刷盘。这意味着 stream 写入必须顺序提交,无法使用 Postgres 通常的优化手段,比如组提交(将多个事务一起通过一次 fsync 提交)。结果,stream 写入的速度无法超过 Postgres 提交事务的速度,这就形成了瓶颈。这也解释了为什么我们没有看到 Postgres 的任何资源(如 CPU 或磁盘)被大量消耗:因为根本没有,所有事务都被全局锁序列化了。
顺便一提,网上有一些关于 Postgres patch 的讨论。这个预计在 Postgres 19 中发布的 patch 并没有移除全局锁,也没有修复我们观察到的瓶颈,而是针对一个更狭窄的场景做了优化:通知通道很多,且每个监听者只等待特定通道。