Redis Pub/Sub 广播:服务间消息扩散
IM 服务端有一条典型的"一对多"内网路径:一条消息要通知到多个进程——持有接收方连接的所有接入机、关注该会话的逻辑副本、需要刷新在线态的路由分片。基础命令(SUBSCRIBE、PUBLISH、PSUBSCRIBE)在 Redis 章节已讲过,本章只讲它在 IM 里怎么落地:频道怎么命名、订阅者怎么分组、丢了消息怎么兜。
用在哪、不用在哪
| 场景 | 是否用 Pub/Sub | 原因 |
|---|---|---|
| 下行唤醒(通知某接入机"有新消息待下发") | 用 | 毫秒级实时,丢了还有收件箱兜底(11 章) |
| 在线态(presence)变更广播 | 用 | 允许丢,客户端重连或下次查询会覆盖 |
| 配置与黑白名单热更新 | 用 | 变动少,未收到可定时全量拉 |
| 消息本体投递("确保对端一定收到") | 不用 | 无持久化,可靠路径只能是落库 + 拉取 |
| 需要重放/审计的事件流 | 不用 | 用 Redis Stream 或 MQ(15 章) |
一句话原则:Pub/Sub 只做"快路径提醒",不做"可靠传输"。任何依赖它的正确性,都必须能在它全丢的情况下由落库 + 客户端拉取兜回来。
频道命名
格式 im:{域}:{类型}:{标识},一眼看出作用域:
| channel | 作用域 | 订阅者 | 消息体 |
|---|---|---|---|
im:push:gw:{gatewayID} | 单台接入机 | 该接入机自身 | {uid, max_seq} 唤醒信号 |
im:push:shard:{n} | 接入机分片组 | 组内所有接入机 | 广播型通知(如配置刷新) |
im:conv:{convID} | 单会话 | 关注该会话的逻辑副本 | 会话级事件(新消息摘要、撤回) |
im:conv:shard:{n} | 会话分片 | 负责该分片的逻辑副本 | 分片内会话事件,避免逐会话订阅 |
im:user:{uid} | 单用户 | 处理该用户分片的逻辑进程 | 多端已读、强制踢端 |
im:presence:{shard} | 在线态分片 | 路由层各分片 | uid → 在线/离线变迁 |
两条纪律:channel 里不放正文(广播带宽要留给"提醒"本身,接收方按 uid/seq 回源取正文);不为每个用户建 channel(百万用户等于百万频道,频道表与模式匹配都会退化)。
订阅者分组:让每个进程只收该收的
逻辑层 A(负责 conv 哈希 0~1023)
└─ SUBSCRIBE im:conv:shard:0 … im:conv:shard:3 ← 只订阅自己负责的分片
接入机 gw-7
└─ SUBSCRIBE im:push:gw:gw-7 ← 只收自己的唤醒
└─ SUBSCRIBE im:cfg:prod ← 全局配置
分组原则:用"分片订阅"替代"全量订阅 + 本地过滤"。全量订阅实现更简单,但每条消息都要在每个进程上被反序列化、判断一次"是不是我的",连接一多就把网络与 CPU 吃满;分片订阅把过滤成本挪给了发布方——发布方本来就知道该发到哪个 channel。
发布侧要注意写放大(fan-out):给 5000 个用户各发一条唤醒就是 5000 次 PUBLISH。批量场景改用聚合唤醒:往 im:push:gw:{id} 发一条 {uids:[...], max_seq},或者只发"该会话有新消息"让接入机自己去拉收件箱。
丢消息的三层兜底
| 失败模式 | 现象 | 兜底手段 |
|---|---|---|
| 发布时无订阅者 | 消息直接丢弃 | 接收方上线后按 seq > cursor 拉收件箱(11 章) |
| 订阅者断线重连窗口 | 窗口期内消息全丢 | 重连成功后立刻补偿拉取,并记录重连时间用于监控 |
| Redis 主从切换/重启 | 订阅关系丢失、消息丢 | 守护协程定期核对订阅状态,掉线自动重订 + 补偿 |
| 慢订阅者被断开(output buffer 超限) | 该订阅者丢一批消息 | 靠重连后的补偿拉取补齐,同时告警订阅处理耗时 |
| 发布成功但消费端处理失败 | 唤醒丢了,消息没人下发 | 看门狗:定期比对 seen_seq 与 max_seq,差值超阈值主动拉取 |
最后一行是关键:接入机每 30s 上报自己各会话的 seen_seq,与收件箱的 max_seq 比对。Pub/Sub 正常时延迟是毫秒级;Pub/Sub 全挂时延迟退化成 30s,但消息一条不少。正是这个看门狗(watchdog)让"用 Pub/Sub 走核心链路"变得可以接受。
与 Stream / MQ 的组合
需要"提醒且不许丢"时,把唤醒通道换成 Redis Stream:
生产者:XADD im:stream:deliver * uid 123 max_seq 4567
接入机:XREADGROUP GROUP deliver gw-7 COUNT 100 BLOCK 2000 STREAMS im:stream:deliver >
处理成功 → XACK im:stream:deliver deliver {id}
进程崩溃 → 消息留在 PEL,由 XAUTOCLAIM 转移给其它接入机重投
Stream 多付的代价是每条投递一次 XACK、PEL 需要清理、内存要靠 MAXLEN 裁剪。所以常见的分层是:
| 通道 | 用途 | 语义 |
|---|---|---|
| Pub/Sub | 唤醒信号(谁有新消息、哪个分片要刷新) | 最多一次(at-most-once),允许丢 |
| Stream / MQ | 需重放的内部事件(扇出任务、索引、审计) | 至少一次(at-least-once)+ 消费组 |
| 收件箱 + 拉取 | 最终正确性 | 客户端幂等拉取,是唯一权威 |
集群与运维注意点
- 集群模式下 Pub/Sub 是全网广播:一条 PUBLISH 会传播到所有节点,订阅者多时要注意总带宽。Redis 7.0 起可用
SSUBSCRIBE/SPUBLISH把传播限制在分片内,正好匹配"按分片订阅"的拓扑; - 订阅连接必须独立:订阅状态下的连接不能执行普通命令,业务连接与订阅连接要分开,各自有重连与超时逻辑;
- 重连后先补偿再订阅:顺序反了会在"订阅完成"与"补偿拉取"之间留下新的漏窗。稳妥做法是先补偿拉到时刻 T,订阅完成后再补拉一次 T 之后的部分(幂等,重复无害);
- 监控三件事:
PUBSUB NUMSUB的订阅者数是否符合预期、订阅端处理延迟直方图、看门狗触发补拉的次数(这个数突然上涨,说明 Pub/Sub 正在丢消息); - 大广播加抖动:配置刷新、全员公告这类广播要带随机抖动,避免所有进程在同一毫秒同时回源拉全量数据。
失败场景速查
| 场景 | 影响 | 处置 |
|---|---|---|
| Redis 整体不可用 | 唤醒全停 | 降级:看门狗周期从 30s 缩到 3s,靠拉取维持 |
| 某接入机订阅失效 | 该机用户消息延迟 | 重订 + 补偿拉取;持续失效则摘除该机路由 |
| 频道名写错或版本不一致 | 静默丢消息、无报错 | 频道名常量集中定义 + 上线校验 PUBSUB NUMSUB |
| 消息体过大 | 带宽打满 | 消息体只放定位信息(uid/conv_id/max_seq) |
| 慢订阅者被 Redis 断开 | 反复重连 | 检查订阅回调是否阻塞,必须投递到本地队列异步处理 |
小结:在 IM 里 Pub/Sub 的定位是"服务间的快路径唤醒"——频道按 im:{域}:{类型}:{标识} 命名、消息体只放定位信息不放正文;订阅端按分片分组订阅,而不是全量订阅后本地过滤;丢消息的兜底是分层的:收件箱拉取保正确性、重连后补偿拉取补漏窗、看门狗比对 seen_seq 与 max_seq 保最终一致;要求不丢的内部事件改用 Redis Stream(消费组 + XACK + PEL)或专业 MQ,绝不让正确性依赖 Pub/Sub。