如果你写过 Postgres 的 LISTEN/NOTIFY,大概率听过一句忠告:这东西不能扩展,生产环境慎用。
这个标签不是凭空来的。你看到过流行的 benchmark 显示它撑死了每秒 2.9K 写入------对大部分后端场景确实不够看。
但 DBOS 团队用一套优化把这个数字推到了 60K 写入/秒(20x),延迟 15-100ms。更值钱的是 Hacker News 上 300 多分的讨论,从多个角度挖出了幕后真相。
问题是:60K/s 意味着 LISTEN/NOTIFY 真的「扩展了」吗?还是说藏着什么条件?
一、LISTEN/NOTIFY 的常见应用
sql
-- 订阅端
LISTEN channel_name;
-- 发布端
NOTIFY channel_name, 'payload';
NOTIFY 不是在调用时立即发送,而是在 事务提交之后 才发出。事务回滚→通知不发,保证了事务一致性。典型用途:
| 场景 | 做法 |
|---|---|
| 缓存失效 | 数据变更后 NOTIFY → 订阅方清除本地缓存 |
| 实时通知 | 新消息写入后 NOTIFY → 在线用户收到推送 |
| 流式处理 | 新数据到达后 NOTIFY → 消费者批量拉取 |
| 实时协作 | 消息写入后 NOTIFY → 其他参与者收到更新 |
但问题就出在这个事务提交通知机制上。
二、全局锁:2.9K/s 的罪魁祸首
sql
-- naive 实现:每行 insert 都 NOTIFY
CREATE FUNCTION notify_stream_chunk() RETURNS trigger AS $$
BEGIN
PERFORM pg_notify('stream_channel', NEW.id::text);
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
DBOS 最初就是这样做的,然后发现无论你怎么调参数,吞吐始终卡在 2.9K/s,但 CPU、内存、IOPS 全部没有负载。
根因:NOTIFY 的事务提交需要拿全局排它锁(LWLock:NotifyQueueLock)。持有到 fsync 完成才释放。
为什么要有这个锁?因为 PG 要保证通知按事务提交顺序发送。但「提交顺序」在提交完成后才能确定。PG 的解法:用锁串行化所有含 NOTIFY 的提交。后果是 group commit 失效,每个 fsync 都变成了一场串行等待。
顺带一提,Postgres 19 即将发布的 patch 不解决这个问题------它优化的是多 channel 场景下 listener 的唤醒效率,全局锁还在。
json
// 瓶颈速览
{
"naive 吞吐": "2.9K 写入/秒",
"瓶颈": "LWLock:NotifyQueueLock(全局排它锁)",
"后果": "group commit 失效,每个提交串行 fsync",
"PG 19 patch": "只解决多 channel 场景,不涉及全局锁"
}
三、优化方案:buffer + batch + fallback
核心洞察:NOTIFY 只是信号(ping),不是数据本身。 缓存失效场景下,客户端收到通知后会去查数据库拿最新数据。通知丢了一个,等到下次 polling 也能补上。它不需要全局有序,也不需要完全持久化。
第一步:内存 buffer
不直接 NOTIFY,把通知放到内存队列里。
第二步:批量 flush
后台线程每 50ms 把 buffer 一次性 flush 出去。全局锁只在 flush 时拿一次。
第三步:降级轮询
读者除了 LISTEN,每 30 秒 polling 兜底。防止进程 crash 导致通知丢失。
python
# 伪代码:核心逻辑
notify_buffer = []
def flush_notify_buffer():
if not notify_buffer:
return
ids = notify_buffer[:]
notify_buffer.clear()
db.execute("SELECT pg_notify('channel', $1)", [json.dumps(ids)])
四、60K/s 值得庆祝吗?
| 方案 | 吞吐 |
|---|---|
| Naive trigger → NOTIFY | 2.9K/s |
| Buffer + batch + polling | 60K/s |
但是 HN 评论区挖出了一个你需要注意的关键信息:benchmark 跑在 96 vCPU、384GB RAM 的机器上。 所以 60K/s 是巨型单机压出来的上限。有人直言:「这么大的机器才跑 60K,恰恰说明这个方案扩展能力有限。」
另外还有一些值得关注的边界:
- 进程 crash 丢通知:30 秒 polling fallback 对某些场景不可接受
- 单慢 reader 阻塞所有 channel 的写入------全局队列是共享的
- 8000 bytes payload 上限:不适合传大消息
- 代码复杂度增加:buffer + timer + polling fallback 的维护成本和加一个 Redis 差不多
有人在 HN 上说:「我在生产里用过,高负载下会丢消息。」还有人说:「看完这个优化代码,第一反应是这能调试吗?」
五、工程选择
| 场景 | 推荐度 | 理由 |
|---|---|---|
| 缓存失效通知 | ✅ 推荐 | 丢了 polling 能补,最自然场景 |
| 消息队列/流式 | ⚠️ 可用 | buffer 方案可上,但要权衡复杂度 |
| 实时协作/编辑 | ❌ 谨慎 | buffer crash 丢通知不可接受 |
| 精确一次传递 | ❌ 不推荐 | 上 Kafka / Pulsar |
如果你每秒写入低于 2000,naive 实现完全够。到了瓶颈再上 buffer + batch 方案,约 50 行代码。
六、总结
LISTEN/NOTIFY 的「不可扩展」标签不是谣言------全局锁确实有硬限制。但你仔细看下来会发现,它也不是废物。当你的业务天然就是「通知可丢,polling 能补」的场景,用它比多维护一个消息中间件省心。
关键是:你技术决策别只读一个标题就下结论。你真正需要的是读懂瓶颈在哪,对号入座。那 50 行代码写得值不值,取决于你是在给 2000 写入/秒的系统加优化,还是为一个 100 写入/秒的系统提前操心。