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 入门。
