2.2 房间模型:加入、离开与广播

连接稳了,下一步是「房间」——白板按房间隔离协作。房间是白板的核心容器:谁进来、谁出去、一笔推给谁,都靠它。本篇讲房间模型怎么建、广播怎么扇出、多节点怎么共享。

房间要维护什么

数据 存哪 用途
在线成员列表 内存 + Redis 集合 广播扇出、人数
房间最新状态 Redis 新成员进房补齐
事件日志 日志流 回放、撤销
房间元信息 数据库 持久、权限

加入一个房间的完整链路

步骤 动作 说明
1 客户端带 roomId 申请 校验 token 与权限
2 服务端拉最新状态 从 Redis/快照补齐
3 加入在线成员集 Redis SADD
4 推送「有人进来了」 房间广播成员变更
5 开始转发事件 纳入本房间广播组

离开是反向:SREM、广播「有人走了」、触发「该成员未画完」的收尾。

广播扇出的两条路

单节点时,一笔事件直接在「本节点该房间的本地连接」里推一遍即可。多节点时,本节点推不了别的节点上的成员,得走 Redis Pub/Sub:把事件发布到「房间频道」,所有节点的订阅者收到后,再推给自己本地该房间的成员。这条「发布订阅」是房间跨节点的正确性关键。

部署形态 广播方式 扇出范围
单节点 本地遍历 本机该房间
多节点 Redis Pub/Sub 全部节点本地

踩坑提示

房间广播有个隐藏坑:扇出风暴。一个热房间一千人,每人画一笔要推给其余 999 人,事件量是 O(人数²)。要「按笔合并」——短时间内的多个坐标点聚合成一条批量事件再推,把 O(N²) 压成 O(N)。合并的代价是延迟略增,但白板画线场景人眼对 100ms 内的合并无感,这是性能与体验的甜点。

房间的数据结构

工程上推荐把房间拆成「热数据」与「冷数据」两层:热数据放内存和 Redis,扛住每秒上千次的读写;冷数据落库,只在创建和查询时碰一下。

数据 层级 读写频率 存储载体
在线成员集合 热 高频 Redis 有序集合
房间最新快照 热 中频 Redis 字符串
事件日志 热→冷 只增追加 Redis 流 + 对象存储
房间元信息 冷 低频 数据库

成员用「有序集合」而不是普通集合,是因为要顺手维护成员的进房顺序和最后活跃时间——踢人时先踢最不活跃的,满了扩容时也知道谁最值得保留。

加入房间的代码

加入房间永远是「校验、登记、广播、补齐」四步,一步都不能省,顺序也不能乱:

// 加入房间:校验、登记、广播、补齐
async function joinRoom(roomId, socket) {
  const room = await getRoomMeta(roomId);        // 冷数据只查一次
  if (!room || !room.members.includes(socket.user.id)) {
    return socket.close(4003, 'forbidden');
  }
  await redis.sadd(`room:${roomId}:online`, socket.user.id);
  socket.join(roomId);                           // 本地广播组
  publish(roomId, { type: 'member', kind: 'join', actor: socket.user.id });
  const snap = await redis.get(`room:${roomId}:snapshot`);
  socket.send(snap);                             // 新成员先补齐
}

注意 publish 走的是 Pub/Sub,只有多节点部署时才需要;单节点直接用本地广播接口即可,省一次网络往返。

广播扇出的实现与指标

多节点下发布一个事件,所有订阅该房间的节点都会各推一遍自己的本地连接。这条链路要盯两个指标:扇出量和传播延迟,任何一个失控都会以「卡顿」的形式被用户感知。

指标 健康线 超了怎么办
单房间扇出量 每秒 500 条以内 按笔合并
跨节点传播延迟 50ms 以内 检查 Redis 连接池
房间峰值人数 2000 以内 分房 / 分区广播

热房间的合并策略

一千人的直播白板,笔笔直推必然是平方级爆炸,合并策略要按操作类型分档,不能一刀切:

策略 合并窗口 适用场景
不合并 无 橡皮擦、文本等离散操作
按笔合并 50ms 画线拖尾(最常用)
按帧合并 16ms 低延迟协同绘制
按量合并 100 条 大批量导入图形

合并窗口在客户端和服务端各做一次:客户端先合一遍减少上行流量,服务端再合一遍减少下行扇出。关于扇出风暴更系统的压测方法,可以参考 Go IM 实战:手搭一个即时消息系统 中的广播章节,思路在白板场景完全通用。

离开房间的收尾

离开不是从集合里删掉就完事。成员退出时要处理三件事:未提交的草稿(画到一半的笔要通知本人补完或丢弃)、房主转移、成员变更广播。

收尾动作 触发条件 超时兜底
草稿提交 / 丢弃 主动离开 30s 后自动丢弃
房主转移 房主退出 顺位继承最久在线者
清理空房间 最后一人退出 延迟 5 分钟销毁

延迟销毁是刻意留的缓冲:用户可能只是刷新了一下页面,5 分钟内回来房间还在,体验上是「无感断开」。想做得更稳,可以参考 房间模型详解 的生命周期设计。

多节点下的房间一致性

房间跨节点后,最容易出的是「成员列表不一致」:A 节点 SADD 了自己本地的连接,但 B 节点还不知道。靠 Pub/Sub 同步成员变更事件可以缓解,但 Pub/Sub 不保证送达——订阅者重启的那一瞬间,事件就丢了。所以成员集的权威来源必须是 Redis,本地内存只是缓存,启动时先从 Redis 全量拉一遍重建,而不是从空开始。

一致性手段 解决什么 代价
Pub/Sub 广播变更 成员变更近实时通知 不保证送达
Redis 集合为准 本地缓存可重建 多一次读取
定期全量对账 兜底纠偏 分钟级延迟

对账是最后一道保险:每隔一分钟,每个节点把自己本地的房间成员与 Redis 集合 diff 一次,多了就踢、少了就补。听起来笨,但它能兜住所有「消息丢了」的边角情况。对账机制更完整的实现,可以参考 Go IM 实战:手搭一个即时消息系统 的会话同步章节。

房间的容量与拆分

房间不是无限的容器,工程上要给每个房间设软上限。一个直观的量级:房间人数到 500 左右,扇出量就开始明显影响单节点 CPU;到 2000,连合并都救不回来。超容量的房间要考虑「拆」:把一个大房间拆成多个子频道,成员按兴趣分摊,广播也只在子频道内扇出,再由一个汇总频道推送关键事件。

房间规模 典型现象 应对手段
50 人内 无感 单节点 + 直推
50~500 人 偶发卡顿 合并 + 多节点
500~2000 人 明显卡顿 分区广播 + 限速
2000 人以上 不可用 拆子频道 / 只读模式

直播公开课里的白板通常走「只读模式」:主讲人可画,其余人只看不画,扇出从双向退化为单向,人数上限能再翻几倍。这种模式在 课堂场景的实时分发实践 里有完整拆解,白板与音视频的分发策略可以一起设计。

小结

房间 = 成员集 + 最新状态 + 事件流 + 元信息。广播在单节点走本地、多节点走 Pub/Sub;热房间靠按笔合并压扇出。把房间容器立起来,笔才有地方「画」。

延伸阅读