超大群与热点群:分层扩散、限流与降级

群人数超过两千后,写放大的倍数与单群热点会同时爆发:一条消息要触达数万设备,一个群就能吃掉整机出口带宽。超大群必须换一套扩散策略——不再对每个成员产生写操作,而是按「在线 / 活跃 / 沉默」分层(tiered fan-out),只对值得投递的层动手,其余靠位点补拉;同时在群维度限流,并准备分级降级(degradation)开关。

先把问题量化

10 万人群,3000 条/s 消息:
  写扩散 = 3000 × 100000 = 3 亿次写/s   → 任何存储都撑不住
  读扩散 = 3000 次落库 + 3000 次 last_seq 更新 ≈ 6000 次/s → 余量充足
  在线推送 = 假设在线 5 万人,单连接 2 KB/s 下行 → 100 MB/s ≈ 800 Mbps 出口
客户端渲染:5000 条/s 的弹幕式消息,UI 根本渲染不过来,投递成功等于投递失败

三个瓶颈分别是:存储写吞吐、单机出口带宽、客户端渲染能力。分层扩散解决第一个,限流与合并推送缓解后两个。

分层扩散

群消息(gid=1001, seq=1024)
  ├─ 第 1 层 在线成员:维护在线位图 → 按路由表逐设备长连接推送(不落持久化副本)
  ├─ 第 2 层 活跃成员(近 7 日登录过):写个人会话索引 im_conversation.last_seq(成本开关可控)
  └─ 第 3 层 沉默成员:不产生任何写操作,只更新 im_group.last_seq = 1024
        下次登录时按 gap = group.last_seq - member.last_read_seq 补拉区间

这一层的选择是显式成本函数:第 2 层把「下次登录要拉的群数」换成「现在多写一行」,活跃成员比例越低越划算。沉默成员占多数的群,直接把第 2 层关掉。

在线成员用位图(bitmap)维护,内存开销可估算、判定 O(1):

SETBIT   group:online:{gid} {uid_in_group} 1    # 按群内成员序号或 uid 取模做位下标
BITCOUNT group:online:{gid}                     # 在线人数,10 万人群约 12.5 KB 扫描
客户端每 30s 心跳一次;服务端刷新位并给 key 设过期时间兜底,防止僵死连接占位

10 万人群位图约 12.5 KB,100 万人群约 125 KB,代价可忽略。若 uid 在群内分布稀疏导致位图浪费,改用「在线成员 ZSet」(score 为心跳时间),并用 ZREMRANGEBYSCORE 清理超时成员。

限流:五个维度都要有

维度key 示例建议阈值超限行为
用户 → 单群rate:u:{uid}:g:{gid}5 条/s,突发 10拒绝并提示发送过快
用户 → 全局rate:u:{uid}20 条/s拒绝
群 → 全部rate:g:{gid}超大群 20 条/s队列缓冲,超阈值只保留最新 N 条
群 → 单成员下行rate:push:{uid}:{gid}10 条/s转入合并推送
接入机出口rate:node:{nodeId}出口带宽 60%拒绝新连接、触发降级

用滑动窗口(sliding window)在 Redis 上实现,脚本一次往返完成判定与打点:

-- KEYS[1]=限流 key  ARGV[1]=窗口毫秒  ARGV[2]=阈值  ARGV[3]=当前毫秒时间戳
local now = tonumber(ARGV[3])
redis.call('ZREMRANGEBYSCORE', KEYS[1], 0, now - tonumber(ARGV[1]))
if redis.call('ZCARD', KEYS[1]) >= tonumber(ARGV[2]) then return 0 end
redis.call('ZADD', KEYS[1], now, ARGV[3] .. ':' .. tostring(math.random(1000000)))
redis.call('PEXPIRE', KEYS[1], tonumber(ARGV[1]) * 2)
return 1

注意成员值必须带随机后缀:同一毫秒内的多次请求如果用时间戳当成员,ZADD 会互相覆盖,计数偏小导致限流失效。

降级分级

级别触发条件动作用户感知
L0 正常无实时逐条推送,已读回执开启完整实时
L1 合并推送单群下行超阈值 80%200 ms 窗口聚合成一个批量包轻微延迟,几乎无感
L2 只推摘要群消息 QPS 超阈值只推「新消息 N 条」+ 首条内容需下拉查看
L3 只写不推出口带宽或存储告警停止长连接推送,只保证落库与位点正确靠进入会话时拉取
L4 拒收大面积故障群消息上行返回可重试错误发送失败可重试,不丢数据

关键原则:降级丢掉的是推送,不是数据。只要消息落库、群 last_seq 单调推进,任何降级都能在恢复后靠补拉完全追上;反过来若为了保住推送而跳号落库,就再也补不回来了。

热点群隔离

  • 按群固定路由:gid 哈希到固定 worker,保证同群消息单线程顺序消费,避免为了保序又加锁。
  • 热点识别:统计 group:msg:qps(滑动窗口或按分钟聚合),超过基线 10 倍即标记热点,写入热点名单(本地缓存 + Redis)。
  • 热点隔离:名单内的群升级为专属队列、专属消费者与专属限流阈值,防止一个群的消息把私聊投递的线程池占满。
  • 名单驱动扩散:扩散层读到热点群直接跳过写扩散分支,只做在线推送,不再尝试为成员写会话行。
热点群请求链路:
  接入层 → 按 gid 路由到 worker(普通池或热点池)
    → 热点池:独立线程池 + 独立队列 + 更严格限流(如 20 条/s)
    → 落库(可接受小幅延迟,串行批量提交)
    → 在线位图 → 批量下行(200ms 聚合)

顺序、去重与补拉

  • 群内 seq 严格单调,客户端按 seq 排序,重复包按 (gid, seq) 去重;seq 是唯一排序依据,不依赖服务端时间戳。
  • 缺口检测(gap detection):本地最大 seq 为 1020 却收到 1024,说明 1021~1023 丢了或未推,客户端调用补拉接口 GET /groups/{gid}/messages?from_seq=1021&to_seq=1023。
  • 补拉接口必须限流且有区间长度上限,否则一次全量补拉就是一次小型压测。

兜底与边界

  • @全体成员:单独限流(如每群每天 10 次),只推在线成员,离线成员靠补拉感知。
  • 入群前历史:用 join_seq 截断,新人不会因为补拉而看到入群前的消息。
  • 客户端渲染限速:只渲染最新 N 条,中间消息折叠为「查看更早的 300 条」,把渲染瓶颈留在客户端自己控制。
  • 群解散 / 全员禁言:以群维度状态位为准直接拒绝上行,不做逐成员判定。
  • 消息落库失败:返回失败,不做「先回成功再补写」,否则用户看到的消息在别人那里不存在。

小结:超大群与热点群的解法是把扩散从「按成员」改成「按层」——在线成员走长连接推送、活跃成员可选写会话索引、沉默成员只更新群 last_seq 等登录补拉;在线位图内存开销极小,是分层的判定基础;限流要覆盖用户、群、单成员下行与接入机出口五个维度并用 Lua 保证原子性;降级分级到 L4 且只牺牲推送不牺牲数据,热点群靠专属队列与更严的限流隔离,最终一致性由 seq 单调、gap 检测与补拉接口共同保证。

笔记加载中…