服务端主动推送:单播、组播与广播的取舍

主动推送是 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 万连接加读写锁或分片锁,注销必须成对
RedisSET im:online:{uid} gateway_ip + TTL多机中小规模必须续期,进程崩溃后靠 TTL 自然清理
Redis HashHSET 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,一眼就能看出是哪台网关在拖慢整体。

取舍对比

维度单播组播广播
目标规模110² ~ 10⁴10⁴ ~ 10⁶
典型耗时< 1ms1 ~ 50ms50 ~ 500ms
失败影响面一条消息部分成员丢失全站卡顿
是否需要 ACK需要(点对点语义)抽样或服务端代收不需要
幂等要求高(客户端去重)高中
限流方式无需按房间令牌桶分批 + 全局配额 + 灰度
观测指标推送延迟、失败率房间耗时、失败成员数广播耗时、期间私聊 P99

常见坑

坑现象正确做法
广播直接 for 循环全量写某一台慢网关拖住整个循环分批 + 每批超时 + 异步循环
组播对同一用户多端写两次客户端重复渲染同一条消息按 uid 去重后再扩散
单播失败立即重试背压时重试放大,雪崩失败即摘连接,交给重连与补拉
在线表用无 TTL 的 Redis Set进程崩溃后留下幽灵路由每条路由带 TTL 并定期续期
广播与私聊共用写队列公告把聊天消息挤出队列独立配额、独立优先级队列
全量广播触发 GC 尖峰每次公告 CPU 抖动数秒复用消息字节切片,避免重复序列化

巡检项

  • 在线连接数与路由表条目数每日对账(差异超过 1% 即查幽灵路由),推送失败率、写队列水位、广播耗时全部进监控(第 19 章给出指标名与阈值)。
  • 广播入口必须有权限校验、限速与可中断开关,禁止业务直接调用全量广播;大群(成员 > 1000)保留专用扩散路径与压测数据。

小结:推送的取舍本质是「目标集合规模 × 失败处理方式」的组合——单播查路由、组播查房间并去重、广播分批限速且可中断;发送失败一律摘除死连接而不原地重试,跨机场景把归属关系落到带 TTL 的路由表,并用 msg_id 追踪实际写出次数与失败数,才能让「消息到底送到没有」变成可观测的问题。

笔记加载中…