PostgreSQL LISTEN/NOTIFY 可以用极少配置驱动实时仪表盘和告警。了解它适合的场景、局限及何时引入消息代理。

产品 UI 中的“实时更新”通常意味着在某件事发生后,界面在不刷新页面的情况下很快发生变化。仪表盘上的数字增加,收件箱出现红色徽章,管理员看到新订单,或出现一个提示“构建完成”或“支付失败”。关键是时间感:它看起来是即时的,即便实际可能延迟一两秒。
许多团队从轮询开始:浏览器每隔几秒向服务器询问“有什么新的吗?”。轮询可行,但有两个常见弊端。
首先,轮询感觉滞后,因为用户只有在下一次轮询时才会看到变化。
其次,它可能成本高昂,因为即便没有变化你也在重复检查。把这放大到数千用户,就会变成噪音。
PostgreSQL 的 LISTEN/NOTIFY 为一个更简单的场景而生:“当某些东西改变时告诉我”。你的应用不再反复询问,而是等待并在数据库发送一个小信号时做出反应。
它适合那些只需要一个提醒就够的 UI。例如:
权衡在于简单性与保障。LISTEN/NOTIFY 易于添加,因为它已包含在 Postgres 中,但它不是一个完整的消息系统。通知是一个提示,而非持久记录。如果监听器断开连接,可能会错过信号。
一个实用的使用方式是:让 NOTIFY 将你的应用唤醒,然后让应用从表中读取真实数据。
把 PostgreSQL LISTEN/NOTIFY 想象成数据库内置的一个简单门铃。你的应用可以等待门铃响起,系统的另一部分在发生变化时按铃。
通知有两部分:通道名和可选载荷。通道像主题标签(例如 orders_changed)。载荷是你附带的一小段文本(例如订单 ID)。PostgreSQL 不强制结构,因此团队通常发送小的 JSON 字符串。
通知可以由应用代码触发(你的 API 服务器执行 NOTIFY),也可以由数据库本身通过触发器触发(触发器在插入/更新/删除后执行 NOTIFY)。
在接收端,你的应用服务器打开一个数据库连接并执行 LISTEN channel_name。该连接保持打开。当发生 NOTIFY channel_name, 'payload' 时,PostgreSQL 会向所有监听该通道的连接推送消息。你的应用随后做出反应(刷新缓存、查询变更的行、向浏览器推送 WebSocket 事件,等等)。
把 NOTIFY 理解为一个信号,而不是一个传递服务:
以这种方式使用时,PostgreSQL LISTEN/NOTIFY 可以在不增加额外基础设施的情况下驱动实时 UI 更新。
当你的 UI 只需要一个提示表示某些东西改变,而不是完整的事件流时,LISTEN/NOTIFY 很合适。考虑“刷新这个组件”或“有新项”,而不是“按顺序处理每一次点击”。
当数据库已经是你的事实来源并且你希望 UI 与其保持同步时,它效果最佳。常见模式是:写入行,发送包含 ID 的小通知,然后 UI(或 API)获取最新状态。
以下大多数条件成立时,LISTEN/NOTIFY 通常足够:
一个具体例子:内部支持仪表盘显示“未结工单”和“新备注”徽章。当客服添加备注时,后端将其写入 Postgres 并向 ticket_changed 发送包含工单 ID 的 NOTIFY。浏览器通过 WebSocket 收到该通知并重新获取该工单卡片。无需额外基础设施,UI 看起来仍然是实时的。
LISTEN/NOTIFY 刚开始可能运行良好,但它有硬性限制。当你把通知当作消息系统而不是轻量“拍肩膀”时,这些限制就会显现。
最大的问题是持久性。NOTIFY 不是排队的作业。如果没人监听,消息就会丢失。即便监听器已连接,崩溃、部署、网络故障或数据库重启也可能断开连接。你不会自动获得“错过”的通知。
对于面向用户的功能,断连特别痛苦。想象一个显示新订单的仪表盘。浏览器标签页进入睡眠,WebSocket 重连后 UI 看起来“卡住”,因为它错过了几条事件。你可以通过重取数据库来弥补,但这时的解决方案就不再是“仅仅 LISTEN/NOTIFY”:你需要通过查询重建状态,把 NOTIFY 仅用作刷新提示。
Fan-out(扇出)是另一个常见问题。一个事件可能唤醒成百上千个监听者(多个应用服务器、许多用户)。如果你使用一个嘈杂的通道如 orders,每个监听者都会被唤醒,即便只有一个用户关心该事件。这会在最糟糕的时候造成 CPU 和连接压力的突发。
载荷大小和频率也是陷阱。NOTIFY 的载荷较小,高频事件可能比客户端处理速度堆积得更快。
注意以下迹象:
此时,保留 NOTIFY 作为“提示”,并将可靠性移到表或专用消息代理上。
对 LISTEN/NOTIFY 的可靠模式是把 NOTIFY 当作提醒,而不是事实来源。数据库表才是真相;通知告诉你的应用何时去查看。
在事务内完成写入,并且仅在数据更改提交后发送通知。如果过早通知,客户端可能会被唤醒却找不到数据。
一种常见的设置是触发器在 INSERT/UPDATE 后触发并发送一条小消息。
NOTIFY dashboard_updates, '{\\\"type\\\":\\\"order_changed\\\",\\\"order_id\\\":123}'::text;
通道命名最好与系统的思考方式相匹配。例子:dashboard_updates、user_notifications,或者按租户划分如 tenant_42_updates。
保持载荷精简。放标识符和类型,而不是完整记录。一个有用的默认结构是:
type(发生了什么)id(变更了什么)tenant_id 或 user_id这能降低带宽并避免在通知日志中泄露敏感数据。
连接会断开。要为此做计划。
连接时,运行 LISTEN 订阅所有需要的通道。断开时,带短退避重连。重连后再次 LISTEN(订阅不会自动保留)。重连后,快速重取“最近的变更”以覆盖可能错过的事件。
对于大多数实时 UI 更新,重取是最安全的做法:客户端收到 {type, id} 后再向服务器请求最新状态。
增量补丁可能更快,但更容易出错(事件乱序、部分失败)。一个折中方案是:重取小范围(一个订单行、一个工单卡、一项徽章计数),把较重的聚合放在短时间轮询上。
当你从一个管理员仪表盘扩展到许多用户关注相同数字时,好习惯比巧妙的 SQL 更重要。LISTEN/NOTIFY 仍然可以很好用,但你需要塑造事件从数据库到浏览器的流动方式。
常见的基线是:每个应用实例打开一个长连接进行 LISTEN,然后把更新推送给已连接的客户端。若你的应用服务器数量较少并且能容忍偶发重连,这种“每实例一个监听器”设置简单且常常足够。
如果你有许多应用实例(或无服务器 worker),一个共享的监听服务可能更易管理。一个小进程监听一次,然后将更新扇出给其余栈。它也给你一个集中点用于批处理、指标和背压控制。
对浏览器来说,通常用 WebSocket(双向,适合交互式 UI)或 Server-Sent Events(SSE,单向,适合仪表盘)推送。无论哪种方式,避免发送“刷新所有内容”。发送像“订单 123 已变更”之类的紧凑信号,让 UI 只重取所需数据。
为了防止 UI 震荡,添加一些保护措施:
通道设计也很重要。不要只用一个全局通道,按租户、团队或功能分区,这样客户端只接收相关事件。例如:notify:tenant_42:billing 和 notify:tenant_42:ops。
LISTEN/NOTIFY 看起来很简单,这也是团队快速上线它的原因,然后在生产中被问题惊到。大多数问题来自把它当作有保障的消息队列。
如果你的应用重连(部署、网络抖动、数据库故障转移),任何在你断连期间发送的 NOTIFY 都会丢失。解决办法是把通知作为信号,然后重新检查数据库。
一个实用模式是:把真实事件存到表中(带 id 和 created_at),重连时抓取比最后已见 id 新的那些条目。
LISTEN/NOTIFY 的载荷不适合大 JSON 片段。大载荷会带来额外解析工作、更多出错机会,以及触及限制的风险。
把载荷用作小提示,例如 "order:123",然后应用从数据库读取最新状态。
常见错误是基于载荷内容设计 UI,好像那就是事实来源。这样会让模式变更和客户端版本管理变得痛苦。
保持清晰分工:通知某事已变,然后用正常查询获取当前数据。
在每行变更上触发 NOTIFY 的触发器会淹没系统,尤其是繁忙表。
只在有意义的转变上通知(例如状态变化)。若更新非常嘈杂,则对变更做批处理(每事务或每时间窗口仅发一次通知),或将这些更新移出通知路径。
即便数据库能发送通知,你的 UI 仍可能承受不住。不断在每个事件上重新渲染的仪表盘可能会卡死。
在客户端做防抖,合并突发为一次刷新,并倾向于“失效并重取”而非“应用每个增量”。例如:通知铃铛可以立即更新,但下拉列表最多每几秒刷新一次。
LISTEN/NOTIFY 适合在你需要一个小的“某东西改变了”信号以便应用重取最新数据时使用。它不是完整的消息系统。
在以它构建 UI 前,回答以下问题:
实用规则:如果你能把 NOTIFY 当作“去重新读取该行”的提示,而不是载荷本身,那么你基本在安全区。
例子:管理员仪表盘显示新订单。如果错过通知,下一次轮询或页面刷新仍显示正确计数,这就是合适的场景。但如果你发送的是“为这张卡收费”或“发货”之类的事件,丢失一条会造成严重事故。
想象一个小型销售应用:仪表盘显示今日收入、总订单数和“最近订单”列表。同时,每个业务员在其负责的订单付款或发货时应收到快速通知。
一个简单方法是把 PostgreSQL 作为事实来源,仅用 LISTEN/NOTIFY 作为提醒。
当订单创建或状态变化时,后端在同一请求中做两件事:写行(或更新),然后发送一个小的 NOTIFY(通常只包含订单 ID 和事件类型)。UI 不依赖 NOTIFY 的完整载荷。
一个实用流程如下:
orders_events,载荷如 {\\\"type\\\":\\\"status_changed\\\",\\\"order_id\\\":123}。这保持了 NOTIFY 的轻量并限制昂贵查询。
当流量增长时,问题会显现:事件峰值可能压垮单个监听器,重连期间会错过通知,你开始需要持久投递和重放。这通常是你加入更可靠层(外发表加 worker,然后视需要加入消息代理)的时机,同时仍把 Postgres 作为事实来源。
LISTEN/NOTIFY 在你需要一个快速的“某事改变了”信号时很好。它并非为完整消息系统而建。当你开始把事件当作事实来源时,就该引入 broker。
如果出现以下任何情况,引入 broker 会让事情更简单:
LISTEN/NOTIFY 不会为后续存储消息。它是推送信号,不是持久日志。它很适合“刷新这个仪表盘组件”,但如果是“触发计费”或“发货”,就风险太大。
消息代理给你一个真实的消息流模型:队列(待做的工作)、主题(广播给多人)、保留(保存消息从几分钟到几天)、以及确认(消费者确认已处理)。这让你能把“数据库改变”与“由此应发生的所有动作”分离开来。
你不必选择最复杂的工具。常见选项有 Redis(pub/sub 或 streams)、NATS、RabbitMQ、Kafka。具体选择取决于你是需要简单的工作队列、向许多服务的扇出,还是需要重放历史。
你可以在不大规模重写的情况下迁移。实用模式是保留 NOTIFY 作为唤醒信号,同时让 broker 成为投递来源。
先在与你的业务变更相同的事务中写入一条“事件行”,然后让 worker 将该事件发布到 broker。在过渡期间,NOTIFY 仍告诉 UI 层“去检查新事件”,而后台工作者从 broker 消费并处理重试与审计。
这样,仪表盘保持响应性,关键工作流停止依赖于尽力而为的通知。
选一个屏幕(一个仪表盘卡片、一个徽章计数或一个“新通知”提示)并端到端连通它。使用 LISTEN/NOTIFY 你能快速得到有用的结果,前提是范围保持紧凑并在真实流量下观察表现。
从最简单的可靠模式开始:写入行,提交,然后发出一个小提示。在 UI 中,收到提示后通过重取最新状态(或所需切片)来响应。这让载荷保持小并避免消息乱序到达时的微妙错误。
及早添加基本可观测性。你不需要复杂工具,但当系统变得嘈杂时你需要得到答案:
保持契约简单且书面化。决定通道名、事件名和任何载荷的格式(即使只是 ID)。仓库中的一份简短“事件目录”能防止规范漂移。
如果你想快速构建并保持栈简单,像 Koder.ai (koder.ai) 这样的平台可以帮助你用 React 前端、Go 后端和 PostgreSQL 快速交付第一个版本,然后随着需求明朗再迭代。
使用 LISTEN/NOTIFY 当你只需要一个快速信号表示某些东西改变,比如刷新徽章计数或仪表盘的某个卡片。将通知视为重新从表中拉取真实数据的提示,而不是数据本身。
轮询按照固定频率检查变化,因此用户常常在下一次轮询时才看到更新,而且服务器会在没有变化时也持续工作。LISTEN/NOTIFY 在变化发生时推送一个小提示,通常感觉更快,并避免大量空请求。
不,LISTEN/NOTIFY 是尽力而为的。如果监听器在 NOTIFY 发生时断开连接,可能会错过该信号,因为通知不会被存储以便重放。
保持简短并把它当作提示。一个实用的默认是一个小 JSON 字符串,包含 type 和 id,然后你的应用去查询 Postgres 获取当前状态。
常见做法是在写入提交后再发送通知。如果过早通知,客户端可能会醒来却找不到新行。
应用代码通常更容易理解和测试,因为它是显式的。触发器在许多写入者会修改同一表且你希望行为一致时很有用。
把重连当作常态来处理。重连时,为所有需要的频道重新运行 LISTEN,并快速重取最近状态以覆盖掉线期间可能错过的任何事件。
不要让每个浏览器直接连接 Postgres。典型做法是每个后端实例保持一个长连接监听,然后后端通过 WebSocket 或 SSE 将事件转发给浏览器,UI 再按需重取数据。
使用更窄的频道让只有合适的消费者被唤醒,并对噪声突发做批处理。对客户端做几百毫秒的防抖并合并重复更新,能防止 UI 和后端过载。
当你需要持久化、重试、消费者组、顺序保证或审计/重放时,就应该升级。如果漏掉一个事件会导致真实事故(计费、发货等),应使用外发表加 worker 或专用的消息代理,而不是单靠 NOTIFY。