Appearance
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 * 能看到所有 key | KEYS * 看不到任何 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. 小测验(自检)
回答这几个问题,答得上来就出师了:
- 在没有任何订阅者时
PUBLISH foo bar,返回什么?这条消息去哪了? - 为什么
KEYS *看不到你刚PUBLISH的频道? - 订阅者先断线 5 秒,这 5 秒里发布的消息,重连后能收到吗?为什么?
- 项目里为什么
publish和pubsub要用两个不同的 Redis 连接池? PUBSUB CHANNELS能列出"曾经存在但当前没人订阅"的频道吗?
点开看答案
- 返回
(integer) 0(0 个订阅者收到);消息彻底消失,没存在任何地方。 - 因为频道不是 key,Pub/Sub 不走 Redis 的 keyspace,根本不记录频道存在过。
- 收不到。Pub/Sub 发完即焚,没有持久化,断线期间的消息永久丢失。
- 因为
pubsub()会独占一条连接长期listen,和publish共池会耗尽共享池。 - 不能。
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
- Pub/Sub 的硬伤怎么补 → Redis Streams(
- 回看协作模式总结:
docs/协作模式/2026-07-04-后端实现总结-agent产物同步与实时协同.md第 3.5 节,现在你应该能完全看懂那张"跨实例广播"图了。