30、项目实战二:多人视频会议系统

终于到了最后一个实战项目。说实话,多人视频会议系统是WebRTC应用里最典型的场景,也是最考验架构能力的。我当年在创业公司做第一个视频会议产品时,踩过的坑能写满一本笔记本。今天咱们就把这些经验一次性梳理清楚。

一、系统架构设计

多人视频会议,说白了就是让N个人同时看到彼此的画面和声音。这里有个核心问题:如果每个人都给其他人发一路流,那N个人就需要N×(N-1)路连接。10个人就是90路,20个人就是380路——客户端根本扛不住。

所以我们需要一个中心节点来做转发。这就是SFU(Selective Forwarding Unit)架构。

核心架构:SFU模式

每个客户端只上传一路视频流到服务器,服务器根据订阅关系,把流转发给其他参与者。客户端只接收自己需要的流,大大降低了带宽和CPU压力。

多人视频会议系统架构图(SFU模式) SFU 媒体服务器 客户端 A 客户端 B 客户端 C 上行视频流 上行 上行视频流 下行B+C流 下行A+C流 下行A+B流 信令服务器 信令 信令 信令 上行流 下行流 信令通道 信令服务器

我的经验:早期我尝试过MCU(多点控制单元)架构,服务器端混流,客户端只收一路。但MCU的CPU消耗太高,而且灵活性差。后来全面转向SFU,配合Simulcast( simulcast ),效果好了不止一个档次。

二、功能模块实现

一个完整的多人视频会议系统,至少需要以下几个核心模块。我按实现顺序来讲。

1. 房间管理模块

用户进入会议前,必须先加入一个房间。房间就是逻辑上的会议空间。

// 房间管理核心逻辑
class RoomManager {
  constructor() {
    this.rooms = new Map(); // roomId -> Room
  }

  createRoom(roomId, hostId) {
    const room = {
      id: roomId,
      host: hostId,
      participants: new Map(),
      createdAt: Date.now()
    };
    this.rooms.set(roomId, room);
    return room;
  }

  joinRoom(roomId, participant) {
    const room = this.rooms.get(roomId);
    if (!room) throw new Error('房间不存在');
    room.participants.set(participant.id, participant);
    // 通知其他用户有新成员加入
    this.broadcastToRoom(roomId, {
      type: 'user-joined',
      userId: participant.id
    });
    return room;
  }

  leaveRoom(roomId, userId) {
    const room = this.rooms.get(roomId);
    if (!room) return;
    room.participants.delete(userId);
    // 如果房间没人了,销毁房间
    if (room.participants.size === 0) {
      this.rooms.delete(roomId);
    }
  }
}

注意:房间ID一定要做防冲突处理。我曾经在生产环境遇到过两个团队同时创建了相同ID的房间,结果两拨人串会了。后来我改用UUID+时间戳的组合方式,再也没出过问题。

2. 媒体协商模块

每个新加入的用户,都需要和SFU服务器建立PeerConnection。这里有个关键点:不是用户之间直连,而是用户和SFU连接。

// 客户端与SFU建立连接
async function connectToSFU(sfuUrl, roomId, userId) {
  const pc = new RTCPeerConnection({
    iceServers: [{ urls: 'stun:stun.l.google.com:19302' }]
  });

  // 获取本地媒体流
  const localStream = await navigator.mediaDevices.getUserMedia({
    video: true,
    audio: true
  });

  // 添加本地轨道
  localStream.getTracks().forEach(track => {
    pc.addTrack(track, localStream);
  });

  // 创建offer
  const offer = await pc.createOffer();
  await pc.setLocalDescription(offer);

  // 通过信令发送offer到SFU
  const answer = await signaling.sendOffer({
    roomId,
    userId,
    sdp: offer
  });

  await pc.setRemoteDescription(answer);

  return { pc, localStream };
}

3. 订阅管理模块

用户进入房间后,不能一股脑把所有流都拉下来。我建议默认只订阅大画面和音频,其他用户用小画面展示。用户点击某个小画面时,再切换成大流。

订阅策略 说明 适用场景
全量订阅 订阅所有参与者的所有流 2-4人小会议
按需订阅 只订阅当前活跃说话者的高清流 5-10人中型会议
分层订阅 根据屏幕布局动态调整订阅质量 10人以上大型会议

避坑指南:我曾经在20人会议上默认全量订阅,结果用户的MacBook风扇直接起飞,CPU冲到100%。后来改成按需订阅+Simulcast分层,CPU降到了30%以下。

三、性能测试与优化

性能测试这块,我建议分三步走:单机压测、网络模拟、真实场景验证。

1. 单机压测

用一台高性能服务器模拟多个客户端,同时连接SFU。重点看这几个指标:

  • CPU使用率:SFU进程的CPU占用,超过80%就要警惕
  • 内存占用:每个连接大约消耗2-5MB内存
  • 带宽吞吐:服务器网卡的上行/下行流量
  • 连接数上限:系统能同时维持多少路PeerConnection

2. 网络模拟

用tc(Linux流量控制)模拟各种网络环境:

# 模拟丢包5%
tc qdisc add dev eth0 root netem loss 5%

# 模拟延迟100ms
tc qdisc add dev eth0 root netem delay 100ms

# 模拟带宽限制1Mbps
tc qdisc add dev eth0 root tbf rate 1mbit burst 32kbit latency 400ms

我习惯在三种网络条件下测试:

  • 良好网络:延迟<30ms,丢包<1%
  • 一般网络:延迟50-100ms,丢包1-3%
  • 差网络:延迟>200ms,丢包5%以上

3. 关键优化手段

经过多次实战,我总结出几个最有效的优化点:

  1. Simulcast分层编码:发送端同时编码多个分辨率,接收端按需选择。这是最核心的优化,没有之一。
  2. 动态码率调整:根据网络状况实时调整视频码率。我用的是Google Congestion Control算法,效果不错。
  3. 音频优先:在网络拥塞时,优先保证音频的传输。视频可以降分辨率,但音频不能断。
  4. 空闲连接清理:定期检查长时间没有数据传输的连接,主动断开释放资源。

我的小技巧:在SFU端做一层视频流缓存。当新用户加入时,不用等I帧,直接发送缓存的最近一个关键帧,能减少50%以上的加入延迟。

四、总结

多人视频会议系统,说白了就是三个核心:架构选SFU、订阅按需来、优化看网络。我做了这么多项目,发现最容易出问题的不是技术本身,而是对网络状况的预估不足。你想想看,用户可能用4G、可能用WiFi、甚至可能用卫星网络,每种场景下的表现都不一样。

所以我的建议是:先跑通基础功能,然后立刻上性能测试。别等到上线了才发现扛不住并发。嗯,今天就聊到这里,希望这些实战经验能帮你少走弯路。


公众号:蓝海资料掘金营,微信deep3321