1. 业务背景与核心冲突
在 1v1 通话/直播业务中,主播状态(空闲、忙线、离线)是最高频变动的数据。
- 核心痛点:状态不同步导致"拨打冲突"(用户看到空闲,拨打后系统报错忙线)。
- 技术挑战:主播规模动态增长(从百级到万级),全量推送会导致带宽爆炸,而传统轮询无法满足秒级实时性。
2. 方案演进与架构抉择 (The Journey)
在定案之前,我们深度评估了三种路径,最终选择了方案三:
方案一:全量 WebSocket 广播
- 做法:后端在任何主播状态变动时,向所有在线用户广播。
- 抉择理由(放弃) :虽然实现最简单,但缺乏扩展性。当主播上万、用户上万时,每秒数千条广播会形成"信令风暴",耗尽手机电量并导致低端机卡死。
方案二:智能局部轮询 (HTTP Polling)
- 做法:前端根据滑动位置,每 2-3 秒批量请求当前视口的主播状态。
- 抉择理由(放弃):实时性依然存在 2-3 秒的"真空期",且高频 HTTP 握手对服务器 CPU 压力极大,属于**"战术性打补丁"**。
方案三:分层信令与按需订阅 (当前定案)
- 做法:结合 WS 的实时性与 HTTP 的确定性,只对"用户正在看的主播"进行精准信令推送。
- 抉择理由(选用) :它在性能、实时性、扩展性之间取得了工业级的平衡。
3. 最终设计:分层同步架构
我们将状态同步拆解为三个防御圈:
第一层:展示层 (HTTP API) ------ 基础支撑
- 逻辑:进入页面通过 HTTP 获取静态资料。
- 职责:解决"看得到"的问题。
第二层:感知层 (Viewport-Based Signaling) ------ 核心实时
- 核心思维:引入**"信令(Signaling)"**概念。信令不载数据,只载状态变动信号。
- 动态订阅逻辑:
- 视口感知:前端实时计算 Swiper 视口内的 ID 列表。
- 按需上报 :通过 WebSocket 发送订阅指令
{"op": "watch", "ids": [101, 102...]}。 - 精准广播 :后端仅针对该连接当前关注的 ID 发送极简信令
{"id": 101, "s": 2}。
- 职责:解决"感知准"的问题,保证视口内状态毫秒级更新。
第三层:原子层 (Fast-Check API) ------ 最终防御
- 逻辑:在用户点击"拨打"的一瞬间,前端发起一个 50ms 内返回的 HTTP 强校验。
- 职责:解决"绝不报错"的问题,作为信令丢失或瞬间延迟的最后兜底。
4. 关键技术细节与坑点规避
4.1 如何定义"信令"以优化带宽?
信令必须极致精简。我们不传长字符串,而是使用枚举数字。
{"i": 888, "s": 2}远好于{"userId": 888, "status": "streaming"}。- 单次信令控制在 50 字节以内,万次推送也仅约 0.5MB。
4.2 解决"切屏与重连"的状态回补
- 问题:用户从设置页切回主播墙,或者网络断开重连,中间错过的信令怎么办?
- 对策 :引入 "唤醒自愈" 机制。在
onShow或onReconnect事件中,前端自动触发一次当前视口 ID 的全量 HTTP 查询,抹平状态差。
4.3 后端订阅 Map 的内存管理
- 设计 :后端在 Redis 或内存中维护
Conn_ID -> Watching_IDs的映射。 - 优化 :用户滑走后,新的订阅列表会覆盖旧列表,后端无需手动逻辑删除,直接以最新的
watch指令为准。
5. 方案总结:为什么这是"最棒"的?
- 对后端友好:不再被海量轮询骚扰,也不必盲目广播。
- 对前端友好 :全局 Map 结构,
O(1)更新,UI 自动响应。 - 对业务友好:解决了"拨打冲突"这一顽疾,提升了产品的专业感。
- 对未来友好:系统架构不随业务规模崩塌。
6. 伪代码示例
我们需要把前端的视口计算和后端的订阅推送逻辑用伪代码拆解出来。
这份伪代码展示了如何通过简单的 Map 映射 来实现"按需订阅",而不需要动用复杂的数据库操作。
1. 前端逻辑:视口感知与订阅上报
前端的核心任务是:监听滚动,算出 ID,告诉后端。
javascript
// 前端:监听 Swiper 或 滚动组件
let currentWatchingIds = [];
// 1. 获取当前屏幕可见的主播 ID 列表
function updateViewportSubscription() {
const visibleItems = swiper.getVisibleItems(); // 获取当前视口内的组件
const newIds = visibleItems.map(item => item.hostId);
// 2. 对比是否有变化,避免重复上报
if (isDifferent(currentWatchingIds, newIds)) {
currentWatchingIds = newIds;
// 3. 通过 WebSocket 发送"信令订阅"指令
ws.send(JSON.stringify({
op: "watch",
ids: currentWatchingIds
}));
}
}
// 节流处理,防止滑动过快导致请求风暴
const throttledUpdate = throttle(updateViewportSubscription, 500);
swiper.on('scroll', throttledUpdate);
2. 后端逻辑:内存订阅池与事件驱动
后端的任务是:记住谁在看谁,变动时精准投递。
Node.js 处理这种映射表非常简单,直接利用内存中的 Map 或 Set。
javascript
const WebSocket = require('ws');
const wss = new WebSocket.Server({ port: 8080 });
/**
* 内存映射表
* hostIdToClients: Map<HostID, Set<WS_Client>>
* 记录哪些连接正在看哪位主播
*/
const hostIdToClients = new Map();
wss.on('connection', (ws) => {
// 为每个连接维护一个自己正在看的列表,方便清理
ws.watchingIds = new Set();
ws.on('message', (message) => {
const data = JSON.parse(message);
// 处理前端发来的 watch 信令
if (data.op === 'watch') {
const newIds = data.ids; // [101, 102, 103...]
// 1. 清理旧订阅
ws.watchingIds.forEach(id => {
const clients = hostIdToClients.get(id);
if (clients) clients.delete(ws);
});
ws.watchingIds.clear();
// 2. 建立新订阅
newIds.forEach(id => {
if (!hostIdToClients.has(id)) {
hostIdToClients.set(id, new Set());
}
hostIdToClients.get(id).add(ws);
ws.watchingIds.add(id);
});
}
});
ws.on('close', () => {
// 自动清理该连接的所有订阅
ws.watchingIds.forEach(id => {
const clients = hostIdToClients.get(id);
if (clients) clients.delete(ws);
});
});
});
2. 状态变更广播逻辑
当数据库或缓存中的主播状态发生变动时(比如主播接通了电话),触发此函数。
javascript
/**
* 主播状态变更时调用
* @param {string} hostId 主播ID
* @param {number} status 状态码: 1空闲, 2忙线
*/
function broadcastStatusChange(hostId, status) {
const signal = JSON.stringify({
t: 'S_SYNC', // 消息类型
id: hostId,
s: status
});
// 只精准推送给"正在看他"的人
const targetClients = hostIdToClients.get(hostId);
if (targetClients) {
targetClients.forEach(ws => {
// 确保连接还活着
if (ws.readyState === WebSocket.OPEN) {
ws.send(signal);
}
});
}
}
3. 为什么 Node.js 做这个最棒?
- 非阻塞 IO:Node.js 可以轻松维护成千上万个 WebSocket 连接,内存消耗远低于 Java 或 PHP。
- Map 操作极快 :Node.js 的
Map和Set都是 V8 引擎深度优化的,处理hostIdToClients的读写开销微乎其微。 - 单线程优势 :由于 Node.js 主线程处理逻辑是单线程的,你在更新订阅关系表时不需要加锁,完全不用担心竞态带来的数据错乱问题。
最后总结流程图
