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;热房间靠按笔合并压扇出。把房间容器立起来,笔才有地方「画」。
