服务端主动推送:单播、组播与广播的取舍
主动推送是 IM 必须使用长连接的全部理由:消息要在毫秒级到达任意在线用户手里,HTTP 轮询做不到这一点。推送的难点从来不是「怎么发」,而是「发给谁、发几次、失败怎么办」——单播查路由、组播查房间、广播查全量在线表,三者的成本差一个数量级。
三种推送语义
| 语义 | 目标集合 | 目标规模 | 单次成本 | 典型场景 |
|---|---|---|---|---|
| 单播(Unicast) | 一个用户 | 1 | 一次路由查找 + 一次写 | 私聊、回执、系统通知 |
| 组播(Multicast) | 一个群或房间 | 2 ~ 10⁴ | 成员数 × 一次写 | 群聊、直播间、协作文档 |
| 广播(Broadcast) | 全部在线连接 | 10⁴ ~ 10⁶ | 在线数 × 一次写 | 全站公告、配置下发、强制下线 |
成本不是「发一次」,而是「写 N 次」。给 10 万在线连接广播就是 10 万次 socket 写,无论中间包了几层框架,这个量级都不会消失。
单播:先查路由,再写连接
单播的核心不是发送,而是定位「这个人此刻挂在哪个网关的哪条连接上」。
// conns 由 Hub 维护:uid -> 该用户当前所有在线连接(多端在线)
func (h *Hub) PushToUser(uid string, msg []byte) error {
conns := h.lookup(uid)
if len(conns) == 0 {
return ErrUserOffline // 转离线消息表,不要在这里原地重试
}
var firstErr error
for _, c := range conns {
if err := c.Send(msg); err != nil { // Send 只写有界 channel,非阻塞
firstErr = err
h.unregister(c) // 队列满或连接已死:立即摘除,不留死连接
}
}
return firstErr
}
三条硬规则:lookup 走内存映射(跨机时走路由表),Send 必须非阻塞(写队列满是背压问题,见第 10 章),任何写失败都要顺手注销连接,而不是把失败留给下一次心跳。
组播:房间成员表与扩散策略
群聊的扩散(Fan-out)方式决定组播怎么写:
| 策略 | 做法 | 优点 | 代价 |
|---|---|---|---|
| 写扩散(Push) | 发消息时写入每个成员的收件箱 | 读极快,收件箱拉取即可 | 大群写放大严重 |
| 读扩散(Pull) | 消息只写群会话,成员各自拉取 | 写成本恒定 | 拉取时要做多会话归并 |
| 混合 | 小群写扩散,大群读扩散 + 在线直推 | 兼顾 | 逻辑复杂,必须有统一 seq |
在线成员优先直推,离线成员落收件箱或走离线消息表——这是 IM 里最通用的一条折中路线。
func (h *Hub) PushToRoom(roomID string, msg []byte, except string) (ok, fail int) {
for _, uid := range h.roomMembers(roomID) { // 成员表由 Hub 维护,可按 uid 分片
if uid == except { continue } // 发送者自身不回推,多端同步另走一路
if h.PushToUser(uid, msg) != nil { fail++; continue }
ok++
}
return ok, fail // ok/fail 必须落日志,否则「谁没收到」无从追查
}
广播:分批、限速、可中断
广播的风险不是延迟,而是抖动:一次性写 10 万连接会瞬间吃掉 CPU、网卡与写缓冲,把正常聊天挤成卡顿。
tick := time.NewTicker(5 * time.Millisecond) // 全局限速:每 5ms 放行一批
defer tick.Stop()
for i, c := range h.snapshot() { // 先快照再遍历,避免长时间持有 Hub 锁
if c.Send(msg) != nil { h.unregister(c) }
if i%512 == 0 { <-tick.C } // 每 512 条等一拍,约 10 万连接/秒的上限
}
广播必须具备三个开关:全局限速、按比例灰度(先 1% 在线用户)、随时中断(公告内容写错了要能撤回)。同时广播消息应走独立的发送配额,不能和私聊共用一个写队列。
路由表:谁在线、挂在哪
| 存法 | 结构 | 适用规模 | 注意 |
|---|---|---|---|
| 单机内存 | map[uid]map[*Conn]struct{} | 单机 10 万连接 | 加读写锁或分片锁,注销必须成对 |
| Redis | SET im:online:{uid} gateway_ip + TTL | 多机中小规模 | 必须续期,进程崩溃后靠 TTL 自然清理 |
| Redis Hash | HSET im:route {uid} {gateway} | 百万级 | 单 key 过大,需按 uid 分桶 |
| 注册中心 / etcd | 网关发布路由,查询走本地缓存 | 大规模 | 缓存必须有失效与全量重建路径 |
无论哪种存法,都要能回答「这条连接现在还在吗」。幽灵路由(路由表里有人、实际连接已断)会导致消息静默丢失,比报错更难查。
真实命令与输出示例
# 网关按实例订阅自己的推送通道;订阅数大于 1 说明重复订阅,会重复推送
redis-cli PUBSUB NUMSUB im:push:gw-1
1) "im:push:gw-1"
2) (integer) 1
redis-cli --latency -h 10.0.0.12
min: 0, max: 2, avg: 0.21 (4123 samples) # 推送链路的 Redis 往返延迟
# 用 msg_id 追踪一条消息实际写了几次、失败几次
grep -c '"msg_id":"m-9f3a1c"' /var/log/im-gateway.log
grep '"msg_id":"m-9f3a1c"' /var/log/im-gateway.log | grep -c '"push_result":"fail"'
842 members, ok=839, fail=3, cost=37ms, slow_gateways=["10.0.0.14"]
一次组播写出 842 条、失败 3 条、耗时 37ms,一眼就能看出是哪台网关在拖慢整体。
取舍对比
| 维度 | 单播 | 组播 | 广播 |
|---|---|---|---|
| 目标规模 | 1 | 10² ~ 10⁴ | 10⁴ ~ 10⁶ |
| 典型耗时 | < 1ms | 1 ~ 50ms | 50 ~ 500ms |
| 失败影响面 | 一条消息 | 部分成员丢失 | 全站卡顿 |
| 是否需要 ACK | 需要(点对点语义) | 抽样或服务端代收 | 不需要 |
| 幂等要求 | 高(客户端去重) | 高 | 中 |
| 限流方式 | 无需 | 按房间令牌桶 | 分批 + 全局配额 + 灰度 |
| 观测指标 | 推送延迟、失败率 | 房间耗时、失败成员数 | 广播耗时、期间私聊 P99 |
常见坑
| 坑 | 现象 | 正确做法 |
|---|---|---|
| 广播直接 for 循环全量写 | 某一台慢网关拖住整个循环 | 分批 + 每批超时 + 异步循环 |
| 组播对同一用户多端写两次 | 客户端重复渲染同一条消息 | 按 uid 去重后再扩散 |
| 单播失败立即重试 | 背压时重试放大,雪崩 | 失败即摘连接,交给重连与补拉 |
| 在线表用无 TTL 的 Redis Set | 进程崩溃后留下幽灵路由 | 每条路由带 TTL 并定期续期 |
| 广播与私聊共用写队列 | 公告把聊天消息挤出队列 | 独立配额、独立优先级队列 |
| 全量广播触发 GC 尖峰 | 每次公告 CPU 抖动数秒 | 复用消息字节切片,避免重复序列化 |
巡检项
- 在线连接数与路由表条目数每日对账(差异超过 1% 即查幽灵路由),推送失败率、写队列水位、广播耗时全部进监控(第 19 章给出指标名与阈值)。
- 广播入口必须有权限校验、限速与可中断开关,禁止业务直接调用全量广播;大群(成员 > 1000)保留专用扩散路径与压测数据。
小结:推送的取舍本质是「目标集合规模 × 失败处理方式」的组合——单播查路由、组播查房间并去重、广播分批限速且可中断;发送失败一律摘除死连接而不原地重试,跨机场景把归属关系落到带 TTL 的路由表,并用 msg_id 追踪实际写出次数与失败数,才能让「消息到底送到没有」变成可观测的问题。