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。

笔记加载中…