3.2 实时广播:服务端中继

笔触定好了,下一步是「一笔画了,其他人立刻看到」——服务端中继。白板不是把笔直接点对点推,而是一切事件先进服务端、服务端定序、再扇出给房间内其余人。本篇讲这套中继怎么搭、怎么合帧、怎么削峰。

为什么必须经服务端中继

点对点(P2P)看似省服务器,但白板要「所有在线成员都收到、且顺序一致」,纯 P2P 做不到全局定序和补数据。服务端当单一真相源:所有笔经它盖章 seq,再由它广播——顺序、完整性、补齐全靠这一根轴。P2P 只能当「加速通道」,不能替代中继。

中继的完整链路

步骤 动作 说明
1 客户端发笔 带 actor、局部信息
2 服务端定序 盖全局 seq
3 写日志 进事件流(可回放)
4 扇出 推给房间内其余成员
5 多节点 Redis Pub/Sub 跨节点

关键在「定序 + 日志 + 扇出」三合一,缺一就散。

合帧与削峰

热房间一笔就 O(N) 扇出,笔多了就 O(N²) 带宽。三招削峰: | 招 | 做法 | 效果 | |---|---|---| | 按笔合并 | 短时间坐标聚成一条 | 事件数 ↓ | | 批量推 | 100~200ms 内事件攒一帧推 | 请求数 ↓ | | 增量编码 | 点用相对前点增量 | payload ↓ |

合帧带来 100ms 左右额外延迟,白板画线人眼无感,但能省掉大量小请求,是甜点。

多节点中继

单节点在本地遍历房间成员推;多节点走 Redis Pub/Sub:本节点发布「房间频道」,其它节点订阅后推各自本地成员。中继逻辑必须节点无关——事件只依赖「房间 + seq」,不依赖「哪个节点」,才能水平扩。

踩坑提示

最坑的是「扇出顺序在各客户端不一致」:扇出走批量时,若每帧不保序,A 收到 1、2、3,B 收到 1、3、2,画面就分叉了。批量推也要按 seq 严格保序,乱序帧在客户端按 seq 排序渲染。再一个坑:广播前先把笔写日志,「先日志后扇出」,保证任何时刻重连补齐都能从日志追回来,不因扇出失败丢笔。

小结

实时广播 = 服务端定序 + 写日志 + 合帧扇出 + 多节点 Pub/Sub。核心是「一根轴」:所有笔经服务端盖 seq,再有序推出去。中继立起来,多人就「画得同步」。

中继服务端的骨架

Node 侧最简实现只需要三个对象:连接表、房间表、频道订阅。

// 连接进入房间
ws.on("join", (boardId) => {
  ws.boardId = boardId;
  rooms.join(boardId, ws);             // 本地房间表
  redis.subscribe(`board:${boardId}`); // 跨节点频道
});

// 收到一笔:定序、写日志、扇出
ws.on("stroke", async (ev) => {
  ev.seq = await log.append(ev.boardId, ev); // 先日志
  broadcast(ev.boardId, ev);                 // 后扇出
});

顺序只有一句话:先写日志,再扇出。日志写失败就不发——发了却没记,重连的人补不回来,画面就分叉了。

对象 存哪 作用
连接表 本节点内存 ws 与用户对应
房间表 本节点内存 房间到本地连接
操作日志 Redis 或持久存储 定序与回放
跨节点频道 Redis Pub/Sub 多节点扇出

扇出的批量化实现

合帧的核心是一个「按房间聚合的定时器」:

const pending = new Map(); // boardId -> 队列
function enqueue(ev) {
  const q = pending.get(ev.boardId) || [];
  q.push(ev);
  pending.set(ev.boardId, q);
}
setInterval(() => {
  for (const [boardId, q] of pending) {
    if (!q.length) continue;
    q.sort((a, b) => a.seq - b.seq); // 按 seq 保序
    emit(boardId, { type: "batch", ops: q });
    pending.set(boardId, []);
  }
}, 120); // 100~150ms 一帧

注意那个 sort:批量不等于乱序,每帧内仍按 seq 排好再发。客户端收到 batch 后按序应用即可,无需再排一次。

背压与掉队处理

房间人一多,慢客户端收不过来就会积压。应对策略: - 每个连接维护待发队列,超过阈值(如 500 帧)就丢中间保端点——把过时的中间 chunk 合并掉,只留关键点; - 观众端(只读成员)只推聚合帧,不推全量; - 实在跟不上就让它「重连走日志补齐」,而不是傻推。

掉队信号 判断 处置
队列长度 超过 500 帧 触发合帧压缩
心跳延迟 超过 3 秒 降级为聚合帧
重连请求 客户端带 lastSeq 从日志 seq 之后补齐

多节点部署的注意

Redis Pub/Sub 是「至少一次」语义:网络抖动可能重复投递。所以消息必须带 seq 幂等键,接收端按 seq 去重。订阅在节点重启时要重建,且频道粒度按房间划分,避免全量广播把所有节点打满。房间与连接状态相关的更多实践,可参考 Go IM 实战 与 微服务治理。

心跳与连接保活

中继的另一条暗线是心跳。WebSocket 半开连接(TCP 没断但对端已僵)是最隐蔽的坑:服务端以为人还在,笔照推不误,全推进了黑洞。做法:

// 服务端定时探活
setInterval(() => {
  for (const ws of rooms.members(boardId)) {
    if (Date.now() - ws.lastPong > 15000) ws.close(4001, "timeout");
    else ws.send(JSON.stringify({ type: "ping" }));
  }
}, 5000);

客户端回 pong 顺便带上 lastSeq,服务端趁机比对:落后了就从日志补齐,把「保活」和「补序」合并成一次开销。

信号 阈值 动作
无 pong 15 秒 关连接,清房间表
lastSeq 落后 任何差值 从日志补齐
重连进来 带 lastSeq 增量追放而非全量

房间成员状态怎么同步

光推笔不够,「谁在线、谁的光标在哪」也要广播:成员进入 / 离开放入房间频道,光标位置做节流后单独推。成员状态走低频全量广播(每秒一次快照),笔触走高频增量广播,两条频率分开,互不拖累。

部署这套中继,服务器与 Redis 一把梭,可以先看 腾讯云云产品合集特惠 把实例备齐。

单节点的极限在哪

粗算一下:单进程 Node 异步推 2000 笔 / 秒不成问题,瓶颈在房间人数。100 人的房间,一笔扇出 100 次,每秒 50 笔就是 5000 次发送——内存与网卡先抗议。经验值:

房间人数 单节点表现 建议
50 人内 轻松 单节点
50~200 人 需合帧削峰 限频 + 观众模式
200 人以上 单节点吃力 分节点订阅

「大房间 + 少数人画 + 多数人看」是白板的常态,观众模式(只推聚合帧)能把扇出量压一个数量级。服务选型与扩容的更多经验,可参考 云开发 Node 入门。

延伸阅读