Skip to content

Redis Pub/Sub 入门教程(费曼风格 + 实操) ​

写给:用过 Redis 做缓存/分布式锁,但很少碰 Pub/Sub 的后端同学。 目标:读完这篇,你能徒手写出一个发布订阅 demo,并真正理解它"发完即焚"的特性,以及为什么协作模式用它做实时广播。 配套进阶篇:docs/协作模式/2026-07-04-redis-pubsub-进阶-Streams对比与项目读码.md。


0. 先用大白话讲清楚:Pub/Sub 到底是个啥 ​

0.1 一个生活类比:广播电台 ​

想象你要给一群人传递消息,有两种典型方式:

方式一:打电话(点对点) 你一个一个打给张三、李四、王五,告诉他们同一件事。每打一个人,你都要知道他的号码、拨号、等他接听。人多了你累死。

这就是普通的消息队列 / HTTP 调用 —— 发送方必须知道接收方是谁,挨个投递。

方式二:开一个广播电台 你架一个电台,频道叫 FM 99.6 新闻台。你只管对着麦克风说话(发布),完全不关心谁在听。想听的人自己把收音机调到 FM 99.6(订阅)。谁调了台谁就能听到,没调台的人什么都收不到。

这就是 Pub/Sub(发布/订阅):

  • 发送方(Publisher)不需要知道接收方是谁,只往一个**频道(Channel)**喊话。
  • 接收方(Subscriber)主动调到某个频道,才能收到这个频道的消息。
  • 中间的 Redis 就是那个电台塔,负责把消息转发给所有调到这个频道的人。

0.2 关键直觉:它和缓存(List/String)有啥不一样? ​

这是新手最容易卡的地方,所以单独拎出来:

你熟悉的 Redis 用法Pub/Sub
SET key value 存起来,GET key 取不存! 消息喊出去就没了
没消费的消息还在 List 里等着发的时候没人听 = 永远丢了
KEYS * 能看到所有 keyKEYS * 看不到任何 channel,因为 channel 不是 key
数据持久化在磁盘完全不持久化,Redis 重启就没了

一句话记住:Pub/Sub 不是"存消息再取",而是"广播消息给在线的听众"。它没有"未读消息"这个概念。

这也是为什么 backend/scripts/redis_debug.py 注释里写:"Pub/Sub 消息发完即焚、不落盘、KEYS 查不到,必须用本工具实时监听才能看到内容"。

0.3 那它适合干嘛? ​

既然发完即焚,它适合实时、在线、丢了也没关系的场景:

  • ✅ 实时广播:协作画布的"有人移动了光标"、"有新产物"——听不到就听不到,反正下一秒还有新的。
  • ✅ 在线状态:谁上线了、谁下线了。
  • ❌ 不适合:订单系统(消息绝对不能丢)、任务队列(消费者掉线了消息得留着)。

后者是 Redis Streams / List + 消费组的活儿,进阶篇会讲。


1. 三条核心命令 ​

只需要记住三条命令就够了:

命令谁用干啥类比
SUBSCRIBE 频道听众调台,开始听某个频道拧收音机到 FM 99.6
PUBLISH 频道 消息发言者往某频道喊一句话对着麦克风说话
PSUBSCRIBE 模式听众按模式调台(通配符)听所有 FM 9 开头的台

外加两个"查台"的命令(辅助理解):

命令干啥
PUBSUB CHANNELS列出现在有人订阅的所有频道
PUBSUB NUMSUB 频道查某频道有几个订阅者

注意:PUBSUB CHANNELS 只列当前有活跃订阅者的频道。没人订阅的频道,Redis 根本不记录它存在过——再次印证"不存东西"。


2. 实操一:用 redis-cli 双终端手敲(最直观) ​

目标:用眼睛看到"一条消息怎么从发布者飞到订阅者"。

2.1 准备一个 Redis ​

如果你本地没有 Redis,用 Docker 最省事(一行起一个,用完即弃):

bash
docker run --rm -p 6379:6379 --name redis-learn redis:7

--rm 表示容器停了就自动删,不污染你机器。如果你想用项目里现成的 Redis,跳过这步,直接进下一步。

2.2 打开两个终端,连同一个 Redis ​

两个终端都敲(让输出能显示中文):

bash
redis-cli -h 127.0.0.1 -p 6379

没装 redis-cli 的话,可以用 docker 临时起一个客户端: docker exec -it redis-learn redis-cli

2.3 第一幕:订阅 → 发布 ​

终端 A(扮演订阅者,先调台):

127.0.0.1:6379> SUBSCRIBE news.chat
Reading messages... (press Ctrl-C to quit)
1) "subscribe"        ← 调台成功的回执
2) "news.chat"
3) (integer) 1        ← 你当前订阅了 1 个频道

⚠️ 注意:敲完 SUBSCRIBE 后,这个终端就只能收消息,不能再敲别的命令了(redis-cli 的限制)。所以你需要第二个终端。

终端 B(扮演发布者,喊话):

127.0.0.1:6379> PUBLISH news.chat "你好,这是第一条"
(integer) 1            ← 返回值 = 收到这条消息的订阅者数量(1 个,就是终端 A)

回到终端 A,你会立刻看到:

1) "message"           ← 消息类型:这是一条真消息(不是回执)
2) "news.chat"         ← 来自哪个频道
3) "你好,这是第一条"   ← 消息内容

看到这一幕,你就理解了 Pub/Sub 的全部精髓:B 喊了一嗓子,A 听到了,中间消息没有存在任何地方。

2.4 第二幕:见证"发完即焚"(最关键的实验) ​

这一步是理解 Pub/Sub 的灵魂。顺序很重要,请严格按下面来:

步骤 1:在终端 B 先发一条消息(此时终端 A 没有订阅):

127.0.0.1:6379> PUBLISH news.chat "这条没人听到"
(integer) 0            ← 0 个订阅者收到!因为此刻没人订阅

步骤 2:再开一个终端 C,现在才订阅:

127.0.0.1:6379> SUBSCRIBE news.chat
Reading messages...

你会发现:终端 C 收不到 "这条没人听到" 那条消息。它彻底消失了。

💡 这就是"发完即焚"。Pub/Sub 没有"未读消息"概念,消息只在发布的那个瞬间投递给当时在线的订阅者。 错过了就是错过了。

如果你的业务需要"消费者掉线了消息也不能丢",那你要的是 Streams(进阶篇),不是 Pub/Sub。

2.5 第三幕:模式订阅(PSUBSCRIBE) ​

假设有这些频道:order.created、order.paid、order.shipped。我想监听所有 order 相关的,不想一个一个订阅。

终端 A:

127.0.0.1:6379> PSUBSCRIBE order.*
Reading messages...
1) "psubscribe"
2) "order.*"
3) (integer) 1

终端 B(发布到不同频道):

127.0.0.1:6379> PUBLISH order.created "订单123创建了"
127.0.0.1:6379> PUBLISH order.paid "订单123付款了"
127.0.0.1:6379> PUBLISH user.signup "张三注册了"   ← 不匹配 order.*,A 收不到

终端 A 会收到前两条,收不到第三条。

项目里的 redis_broadcaster.py 用的是普通 SUBSCRIBE(频道名是固定的 workspace:realtime:{workspace_id}),因为每个 workspace 一个固定 channel,不需要通配符。

2.6 第四幕:查台(PUBSUB) ​

在终端 B(别在订阅状态的终端敲):

127.0.0.1:6379> PUBSUB CHANNELS
1) "news.chat"
2) "order.*"   ← 注:这其实是 pattern,CHANNELS 默认只列精确频道,这里举例

127.0.0.1:6379> PUBSUB NUMSUB news.chat
1) "news.chat"
2) (integer) 1   ← news.chat 当前有 1 个订阅者

项目的 redis_debug.py channels 子命令就是用 PUBSUB CHANNELS workspace:realtime:* 列出"当前正在协作的所有 workspace",再用 PUBSUB NUMSUB 查每个 channel 的订阅者数。这就是把"查台"用到了线上排查。


3. 实操二:Python 实战(复刻一个迷你广播器) ​

目标:用 redis-py 写出和项目里 RedisBroadcaster 同构的最小实现,理解代码层面的发布/订阅。

3.1 装依赖 ​

bash
pip install redis

项目用的是 redis.asyncio(异步版),见 backend/core/redis.py。这里入门先用同步版 redis 讲清楚逻辑,进阶篇再上异步。

3.2 订阅者(后台监听) ​

新建 subscriber.py:

python
# subscriber.py —— 订阅者:调台并持续监听
import redis

r = redis.Redis(host="127.0.0.1", port=6379, decode_responses=True)

# 拿到 pubsub 对象(注意:它会独占一条连接)
pubsub = r.pubsub()
pubsub.subscribe("news.chat")   # 调台

print("已订阅 news.chat,等待消息... (Ctrl+C 退出)")
try:
    for message in pubsub.listen():       # 阻塞,持续收消息
        if message["type"] == "message":  # 过滤掉 "subscribe" 回执
            print(f"收到: {message['data']} (来自 {message['channel']})")
except KeyboardInterrupt:
    pubsub.close()

跑起来:

bash
python subscriber.py
# 已订阅 news.chat,等待消息... (Ctrl+C 退出)

3.3 发布者 ​

另开终端,新建 publisher.py:

python
# publisher.py —— 发布者:往频道喊话
import redis

r = redis.Redis(host="127.0.0.1", port=6379, decode_responses=True)

delivered = r.publish("news.chat", "你好,来自 Python 发布者")
print(f"已发送,送达 {delivered} 个订阅者")

跑:

bash
python publisher.py
# 已发送,送达 1 个订阅者

回到订阅者终端,你会看到:

收到: 你好,来自 Python 发布者 (来自 news.chat)

3.4 对照项目代码:你已经会了 80% ​

现在回头看项目里的 RedisBroadcaster,你会发现它就是你刚写的这个 demo 的"生产加强版":

python
# backend/services/workspace_realtime/redis_broadcaster.py(精简版)
async def publish(self, workspace_id, event):
    # 就是你 publisher.py 里 r.publish(...) 的异步版
    await self._redis.publish(_channel(workspace_id), json.dumps(event))

async def _pubsub_loop(self, workspace_id):
    async with self._pubsub_redis.pubsub() as pubsub:   # 就是 r.pubsub()
        await pubsub.subscribe(_channel(workspace_id))  # 就是 pubsub.subscribe()
        while True:
            message = await pubsub.get_message(timeout=1.0)  # 就是 pubsub.listen()
            ...

唯一的区别是它异步(redis.asyncio)、JSON 序列化、带重连退避、用独立连接池——这些都是工程化加成,核心和你的 demo 一模一样。


4. 理解几个关键细节(把直觉补完) ​

4.1 为什么 pubsub 要用独立连接池? ​

项目代码里特意为 pubsub 单独建了一个客户端:

python
# backend/services/workspace_realtime/redis_broadcaster.py
def _make_pubsub_redis():
    return aioredis.from_url(config.REDIS_URL, max_connections=64)

原因:pubsub() 会独占一条连接,直到你关闭它(因为你得一直 listen 着)。如果它和 publish 共用 max_connections=50 的连接池,等订阅的 workspace 一多,池子就被订阅连接占满了,publish 就拿不到连接报错。

4.2 SUBSCRIBE 之后这个连接"废了"? ​

是的。redis-cli 里你 SUBSCRIBE 后会发现敲别的命令没反应;代码里也一样——进入订阅态的连接只能收消息,不能再发普通命令。所以项目里给 publish 和 subscribe 用两个不同的 Redis 客户端,互不干扰。

4.3 消息会按顺序到达吗? ​

对同一个频道,同一个发布者发出的消息,订阅者按发布顺序收到。 但跨发布者、跨频道不保证全局顺序。实时协作场景对这个要求不高,所以够用。

4.4 订阅者断线重连后,错过的消息能补回来吗? ​

不能。 这就是 Pub/Sub 最大的硬伤。如果协作画布要求"我断线 10 秒,重连后要看到这 10 秒里画布的变化",Pub/Sub 单独是做不到的——需要配合"重连后拉一次最新画布状态"(REST 接口兜底)或用 Streams。协作模式用的是前者:重连走 REST 拉全量产物 + presence snapshot。


5. 一张图总结:Pub/Sub 的心智模型 ​

何时用 Pub/Sub(决策速查) ​

场景用 Pub/Sub?
实时通知多个在线客户端(协作、聊天在场态、光标)✅ 用
系统内模块解耦的事件广播(配置变更、缓存失效通知)✅ 用
任务队列(消费者处理失败要重试)❌ 用 Streams/List
订单/支付(消息绝对不能丢)❌ 用 Streams
消费者可能离线,上线后要补消息❌ 用 Streams

6. 小测验(自检) ​

回答这几个问题,答得上来就出师了:

  1. 在没有任何订阅者时 PUBLISH foo bar,返回什么?这条消息去哪了?
  2. 为什么 KEYS * 看不到你刚 PUBLISH 的频道?
  3. 订阅者先断线 5 秒,这 5 秒里发布的消息,重连后能收到吗?为什么?
  4. 项目里为什么 publish 和 pubsub 要用两个不同的 Redis 连接池?
  5. PUBSUB CHANNELS 能列出"曾经存在但当前没人订阅"的频道吗?
点开看答案
  1. 返回 (integer) 0(0 个订阅者收到);消息彻底消失,没存在任何地方。
  2. 因为频道不是 key,Pub/Sub 不走 Redis 的 keyspace,根本不记录频道存在过。
  3. 收不到。Pub/Sub 发完即焚,没有持久化,断线期间的消息永久丢失。
  4. 因为 pubsub() 会独占一条连接长期 listen,和 publish 共池会耗尽共享池。
  5. 不能。PUBSUB CHANNELS 只列当前有活跃订阅者的频道。

7. 下一步 ​

读到这里你已经掌握了 Pub/Sub 的全部基础。接下来:

  • 进阶篇:docs/协作模式/2026-07-04-redis-pubsub-进阶-Streams对比与项目读码.md
    • Pub/Sub 的硬伤怎么补 → Redis Streams(xadd/xread/消费组)
    • 为什么同一个项目里 Pub/Sub 和 Streams 都用了(实时广播 vs 聊天断线续接)
    • 带你逐行读 redis_broadcaster.py、redis_debug.py、resume/stream_service.py
  • 回看协作模式总结:docs/协作模式/2026-07-04-后端实现总结-agent产物同步与实时协同.md 第 3.5 节,现在你应该能完全看懂那张"跨实例广播"图了。