Postgres LISTEN/NOTIFY 实际上是可扩展的
Postgres LISTEN/NOTIFY 的声誉不佳,部分原因在于一篇流行的博客文章声称它不具备可扩展性。如果这是真的,那将很遗憾,因为 LISTEN/NOTIFY 是一个强大的工具,可以让您使用 Postgres 数据库进行低延迟的持久化通知、流和发布/订阅。指责并非错误:NOTIFY 具有非直观和未记录的性能特征,这是由于其使用全局锁造成的。但“非直观行为”并不等同于“不可扩展”。在这篇博客文章中,我们将展示如何在规模上优化 LISTEN/NOTIFY 支持的流,实现单个 Postgres 服务器每秒 6 万次写入,具有毫秒级的延迟。 低延迟流与 LISTEN/NOTIFY Postgres 支持的流的基本设计很简单:创建一个流表,其中每个流块(例如,一个 LLM 响应字令)都是一行,然后通过向表中插入数据来写入流。 棘手的部分是从流中读取数据,因为您不知道下一个块何时会到达。一种解决方案是轮询:让每个读取者轮询流的末尾以获取新块。然而,轮询的可扩展性很差。如果轮询间隔设置得太长,交互式用例(例如,在线聊天)的延迟就会太高。但如果轮询间隔设置得太短,多个并发轮询者会淹没数据库。更好的解决方案是 LISTEN/NOTIFY。这允许读取者阻塞,等待写入者的通知,通知下一个块已发布到流中。这样,读取者在没有新流块到达时不会浪费资源进行轮询,而是在新流块到达时立即醒来。 在我们最初的 LISTEN/NOTIFY 基于流的实现中,流表上的触发器在写入新流块时触发一个函数,发送通知。读取者等待这些通知,并在收到新块时醒来。这种实现是正确的,并且提供了低延迟,但在大规模下它的吞吐量很差。即使使用大型 Postgres 数据库,它每秒也无法维持超过 2.9K 的流写入。有趣的是,它在没有明显消耗任何 Postgres 资源(CPU、内存或 IOPS)的情况下出现了瓶颈。您可能猜到了,根本原因是最初的“LISTEN/NOTIFY 是不可扩展的”问题:NOTIFY 时 Postgres 采取的全局锁。 但为什么 Postgres 会这样做,我们如何在不失去 Postgres 通知优点的前提下优化它? LISTEN/NOTIFY 独占锁 要理解这个问题,我们需要检查 Postgres LISTEN/NOTIFY 实际是如何工作的。性能不佳的根本原因在于,在 Postgres 中,调用 NOTIFY 的事务提交需要获取一个全局独占锁。这个锁在事务开始提交时被获取,并且在事务完全提交并其内容使用 fsync() 刷写到磁盘之前不会释放。这个锁是必要的,因为 Postgres 保证通知按事务提交顺序发送。为了强制执行这一点,它将所有外发通知存储在一个全局内部队列中,该队列的顺序必须与发送这些通知的事务的提交顺序完全一致。将通知添加到这个队列必须在事务中作为提交的一部分完成。然而,Postgres 在这些事务完成提交之前并不会分配事务的提交顺序,因为提交可能需要不同的时间。这导致了一个排序问题:包含通知的事务必须按照提交顺序自己添加到队列中,但提交顺序直到提交完成后才被定义。解决方法是使用全局锁,该锁序列化包含通知的事务的提交,因此它们的提交顺序在时间上是可以预定义的,这样它们就可以在内部通知队列中正确排序。这个独占锁解释了我们观察到的性能不佳。因为我们从流表上的触发器调用 NOTIFY,所以每个流写入都包括对 NOTIFY 的调用。为了提交,每个流写入需要获取全局锁并在它的提交过程中持有这个锁,包括刷写到磁盘。这意味着流写入需要按顺序提交,从而排除了 Postgres 通常的优化,例如组提交(将多个事务一起提交到单个 fsync() 中)。因此,流写入完成的速度不能快于 Postgres 提交事务的速度,这导致了这个瓶颈。这也解释了为什么我们没有看到显著的 Postgres 资源(如 CPU 或磁盘)的消耗:因为没有任何资源消耗,因为所有事务都被全局锁串行化。 作为一个旁注,网上对与此问题相关的 Postgres 补丁进行了一些讨论。这个补丁(将在 Postgres 19 中发布)并不消除全局锁或修复我们观察到的瓶颈。相反,它优化了更狭窄的情况,即有多个通知通道和每个侦听者仅等待特定通道的情况。 优化 LISTEN/NOTIFY 为了使 LISTEN/NOTIFY 支持的流更快
本站免费、广告极少。如果觉得有帮助,可以请我们喝杯咖啡 —— 任何金额都对持续运营有实际帮助。
☕请我喝杯咖啡