← 返回信息流
AI 资讯Hacker News·2 小时前

Multigres 上线跨池连接 Listen/Notify 支持

原标题:Multigres Supports Listen/Notify Across Pooled Connections

速览

Multigres 新增跨池化连接的 Listen/Notify 支持,允许在多个数据库连接池之间实时传递消息。该特性适用于需要高并发和分布式架构的场景,如微服务事件驱动。它简化了 PostgreSQL 集群中的通知机制,减少轮询开销,对构建实时数据管道有积极意义。

AI 深度解读

背景

Postgres 内置的发布/订阅机制 LISTEN/NOTIFY 是一个按会话(per-session)工作的功能。当客户端数量增加时,这个功能会产生严重的性能瓶颈:监听同一通道的客户端越多,每次通知的传递速度就越慢——一旦监听客户端达到数千个,通知延迟会呈数量级增长。Multigres 是一个连接池化方案,它原本会从客户端手中夺走后端连接的“所有权”,而这恰恰是 LISTEN/NOTIFY 所依赖的核心前提。如何在池化环境下依然保持该功能的正常工作,同时解决性能问题,正是本文要介绍的内容。

核心内容

LISTEN/NOTIFY 快速回顾

LISTEN/NOTIFY 是 Postgres 自带的发布/订阅机制。一个会话可以订阅一个命名的通道,另一个会话可以向该通道发布消息。所有当前正在监听该事件的会话都会收到一条异步消息,其中包含通知后端的 PID、通道名称和载荷。在传输层,服务器会以 NotificationResponse(消息类型 'A')的形式将通知推送给客户端,独立于正常的请求/响应流程。应用通常利用此机制实现缓存失效、任务队列和实时扇出,因为它可以避免轮询。

LISTEN 将当前会话注册为监听者,该注册在会话结束时自动清除——这是基于连接的状态。而投递(NOTIFY)则范围更广:一条 NOTIFY 会到达同一数据库中所有监听该通道的会话,无论它们处于哪个后端连接上。Postgres 通过一个集群范围的磁盘队列(pg_notify/)来实现,为每条通知标记发送者的数据库 OID 以保持数据库本地性,并信号通知每个监听的后端来读取消息。简言之:注册是 per-session 的,投递是 per-database 的。

为什么连接池会破坏它

注意 Postgres 将 LISTEN/NOTIFY 绑定到“会话”上。而连接池的核心职责就是阻止客户端拥有一个独占的会话。Multigres 故意将客户端连接与单个 Postgres 后端解耦:客户端的不同语句可能落在不同的后端连接上。由于没有持久的会话存活,LISTEN 没有地方可以附着:

  • LISTEN 无法在借用的连接上运行。如果 LISTEN 语句恰好在一个空闲的后端上执行,那么下一个语句可能被路由到其他地方,而注册信息将遗留在一个客户端不再持有的连接上。
  • 通知将到达客户端无法看到的地方。Postgres 会忠实地将通知投递给执行 LISTEN 的那个后端,但在连接池中,那个后端并不归客户端所有。消息会落在一个已经归还给池子并被其他人重用的连接上。

Postgres 本身没有问题:只要后端连接共享同一实例上的同一数据库,Postgres 可以完美地在不同后端连接之间传递通知。Multigres 正是依赖这一行为——但前提是解决了上述的会话所有权问题。一个朴素的修复方案(将每个监听客户端固定到一个专用的后端连接)会丢弃连接池的优势,而这恰好是长时间监听的工作负载最需要连接池的地方。我们需要让监听者继续保持可池化。

共享监听连接

核心思路:客户端不再拥有用于监听的 Postgres 会话,拥有者是连接池。

每个池化器(pooler)维护一个单独的、长寿命的监听器连接(listener connection)连接到其 Postgres 后端,与查询池分开。这个连接是惰性建立的(在第一个客户端发出 LISTEN 时创建,而非之前),并且被保留(reserved),因此只要还有人在监听,池子就将其视为永久签出状态,绝不回收它。

这个单一的连接代表连接到该池化器的所有客户端进行监听。监听器使用引用计数(refcount)跟踪通道:第一个订阅者触发一个真实的 LISTEN channel 命令到后端,最后一个取消订阅触发匹配的 UNLISTEN。这样,无论有多少客户端需要同一个通道,我们只对每个不同的通道发出一次后端订阅。

因为监听器连接既要发送 LISTEN/UNLISTEN 命令,又要同时接收连续不断的事件通知流,它采用单 sockets 上的读写分离模型:一个 reader goroutine 负责消耗传入的消息,同时一个事件循环写入订阅变更。这避免了每次通道集合变化时都要拆除并重新建立连接,后者会产生丢失通知的时间窗口。

NOTIFY 的自然处理

NOTIFY 的执行自然得多了。Multigres 在运行时并不将其视为特殊操作——规划器(planner)将其作为一个普通查询路由到单个固定目标。这个固定路由是刻意的:Postgres 的通知永远不会从一个实例跨到另一个单独的实例,因此每条 NOTIFY 必须落在监听器连接所监视的同一实例上。NOTIFY 在一个普通的后端连接上运行,Postgres 将通知投递给该数据库中监听该通道的所有会话,包括池化器的共享监听器(它只是同一实例上的另一个后端)。这一步完全依赖 Postgres 原生的跨后端投递能力:发布者和监听者是不同的物理连接,Postgres 负责桥接它们。通知到达监听器连接后,Multigres 接手后续投递。

扇出路径

将通知从 Postgres 后端送回到正确的客户端需要两层扇出:一次在池化器内部,一次在网关内部。

从底向上描述:

  • 池化器 → 网关:网关通过一个长寿命的 gRPC 流(StreamNotifications)订阅池化器,该流接受一组通道。当池化器的监听器收到通知时,它会扇出到该通道的所有注册订阅者,流将通知传递给每个感兴趣的网关。
  • 网关 → 客户端:网关为每个客户端维护 per-connection 状态。每个监听连接有自己的通知队列,网关的通知管理器将输入的流扇出到订阅了该通道的所有连接上。每个队列有大小限制(几百条待处理通知)。
  • 通知投递是异步的,一个慢客户端绝不能阻塞共享投递路径。如果某个连接的缓冲区满了,溢出的通知会被丢弃并记录日志,而不是阻塞——这也符合 Postgres 本身对 NOTIFY 的“即发即忘”态度(通知发出后是 best-effort,不是持久消息)。
  • 写到传输层:每个客户端连接运行一个后台 writer,从自己的通知通道中拉取消息,并发出 NotificationResponse'A')消息。通知可能在任何时刻到达,包括查询执行期间。因此 writer 在发送每个包时会获取连接缓冲区锁,以防止 'A' 消息与正在写入 socket 的其他内容交错。任何在查询飞行期间缓冲的通知,都会在连接发送 ReadyForQuery 之前刷新出去,从而确保客户端在事务边界不会看到过时的订阅视图。

处理边界情况

共享监听器和扇出树解决了主流程。剩下的工作是在各种角落情形中让行为与 Postgres 完全一致,这往往是兼容性最容易出问题的地方。

事务内的 LISTEN

在 Postgres 中,LISTENUNLISTEN 是事务性的:在 BEGIN 内部执行它们,只有在事务提交时才会生效;回滚则相当于从未执行过。Multigres 必须尊重这一点,尽管实际的订阅工作是在一个完全不在客户端事务内部的共享连接上完成的。

网关通过缓冲来解决。在事务内部发出的 LISTEN/UNLISTEN 不会立即生效——而是被记录为连接状态上的待定动作(pending action)。在提交时,Multigres 按顺序回放这些待定动作,并计算...(原文在此被截断,根据上下文推测后续应是:计算最终的通道变更集,然后在共享监听器上执行实际的 LISTEN/UNLISTEN,如果事务回滚则丢弃待定列表)。

(注:原文尾部被截断,但根据已给出的内容,关于事务内 LISTEN 及后续边界情况的完整描述应包含:在共享监听器上应用这些变更,同时保持对回滚的正确处理,以及确保通知不会在事务内被提前投递等。这里忠于已提供原文,不编造剩余部分。)

关键要点

  • LISTEN/NOTIFY 是 per-session 功能,而连接池会剥离客户端对会话的拥有权,导致传统实现失效。
  • Postgres 本身可以在同一数据库的不同后端连接之间自由投递通知,但关键在于注册(
查看原文 →multigres.com