综合实战:构建一个视频会议系统(信令+媒体+录制+统计)

终于到了这一章。说实话,每次我带团队做视频会议系统,都会想起自己第一次把信令、媒体、录制、统计全部串起来时的那个下午——画面卡成PPT,录制文件打不开,统计面板全是NaN。嗯,踩过的坑多了,自然就知道该怎么走了。

这一章,我们就把这些模块真正拼在一起。不是Demo级别的玩具,而是一个能跑、能录、能看统计的生产级原型。

整体架构:四个模块怎么协作?

先看一张图,把整个系统的脉络理清楚。我个人习惯在动手写代码之前,先把模块间的数据流画出来,不然写着写着就容易「串线」。

信令服务器 房间管理 · 连接协商 媒体服务器(SFU) 转发 · 混流 · 转码 录制模块 MediaRecorder · 切片 统计模块 getStats · 面板渲染 客户端 A 客户端 B 客户端 C 信令流 媒体流 统计流

你看,信令负责「握手」,媒体负责「传数据」,录制负责「存下来」,统计负责「看状态」。四个模块各司其职,但数据是互相流动的。我在项目中遇到过最头疼的问题就是:录制模块拿不到媒体流的实时状态,结果录出来的文件全是静音。后来才意识到,统计模块必须给录制模块反馈一个「流健康度」信号。

第一步:信令模块——房间与连接管理

信令说白了就是「谁在哪个房间,谁要和谁连」。我用的是WebSocket + JSON协议,简单直接。

// 信令消息类型定义
const SIGNAL_TYPES = {
  JOIN_ROOM: 'join_room',
  LEAVE_ROOM: 'leave_room',
  OFFER: 'offer',
  ANSWER: 'answer',
  ICE_CANDIDATE: 'ice_candidate',
  ROOM_USERS: 'room_users'
};

// 客户端加入房间
function joinRoom(roomId, userId) {
  ws.send(JSON.stringify({
    type: SIGNAL_TYPES.JOIN_ROOM,
    roomId: roomId,
    userId: userId
  }));
}

// 服务端转发Offer
function handleOffer(ws, data) {
  // 查找目标用户的WebSocket连接
  const targetWs = userConnections.get(data.targetUserId);
  if (targetWs) {
    targetWs.send(JSON.stringify({
      type: SIGNAL_TYPES.OFFER,
      from: data.fromUserId,
      sdp: data.sdp
    }));
  }
}
我的习惯:信令服务器不要做任何媒体相关的逻辑。它只负责转发,不负责解析SDP。我曾经犯过这个错——在信令层解析SDP做权限校验,结果导致信令服务器CPU飙升,连握手都变慢了。

第二步:媒体模块——SFU转发与动态码率

媒体层我选的是SFU架构。为什么不是MCU?说白了,MCU混流太吃CPU,而且灵活性差。SFU只管转发,每个客户端收多路流,自己渲染。

// 创建PeerConnection并添加轨道
async function createPeerConnection(remoteUserId, stream) {
  const pc = new RTCPeerConnection({
    iceServers: [{ urls: 'stun:stun.l.google.com:19302' }]
  });

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

  // 监听远程流
  pc.ontrack = (event) => {
    const remoteStream = event.streams[0];
    document.getElementById(`remote-video-${remoteUserId}`).srcObject = remoteStream;
  };

  // ICE候选处理
  pc.onicecandidate = (event) => {
    if (event.candidate) {
      sendSignal({
        type: 'ice_candidate',
        targetUserId: remoteUserId,
        candidate: event.candidate
      });
    }
  };

  return pc;
}

这里有个坑:动态码率适配。你想想看,如果某个用户网络变差了,你还给他推1080p,那画面就会一直卡顿。我建议在媒体服务器端做Simulcast(联播),发多个分辨率的流,客户端根据带宽自动切换。

注意:Simulcast会增加上行带宽。如果用户上行只有1Mbps,你让他发三层流(360p、720p、1080p),他直接就断连了。建议默认只发两层,网络好的时候动态开启第三层。

第三步:录制模块——MediaRecorder + 切片存储

录制这块,我直接用的浏览器原生MediaRecorder API。但有个问题:如果只录单路流,回放时看不到其他人的画面。所以我的做法是:在媒体服务器端做合流录制,把多路画面合成一路视频。

// 客户端录制单路流(备用方案)
function startRecording(stream) {
  const options = {
    mimeType: 'video/webm;codecs=vp9',
    videoBitsPerSecond: 1000000 // 1Mbps
  };

  const recorder = new MediaRecorder(stream, options);
  const chunks = [];

  recorder.ondataavailable = (event) => {
    if (event.data.size > 0) {
      chunks.push(event.data);
    }
  };

  recorder.onstop = () => {
    const blob = new Blob(chunks, { type: 'video/webm' });
    uploadToServer(blob, getFileName());
  };

  recorder.start(5000); // 每5秒切片一次
  return recorder;
}

// 服务端合流录制(推荐方案)
// 使用FFmpeg接收多路RTP流,合成为单路MP4
// ffmpeg -i rtp://xxx:port1 -i rtp://xxx:port2 \
//        -filter_complex "[0:v][1:v]hstack=inputs=2" \
//        -c:v libx264 output.mp4

核心要点:

  • 切片时长不要超过10秒,否则录制中断会导致大量数据丢失
  • 录制文件命名用 roomId_timestamp_userId 格式,方便检索
  • 合流录制建议在媒体服务器端做,不要在客户端做——客户端合流会消耗大量CPU,影响通话质量

第四步:统计模块——getStats + 实时面板

统计模块是很多人容易忽略的。但说实话,没有统计面板,你根本不知道系统出了什么问题。我习惯每2秒拉一次getStats,把关键指标渲染到面板上。

// 定期拉取统计信息
function startStatsMonitor(pc, userId) {
  setInterval(async () => {
    const stats = await pc.getStats();
    const report = {};

    stats.forEach(report => {
      // 视频接收端统计
      if (report.type === 'inbound-rtp' && report.kind === 'video') {
        report.videoPacketsLost = report.packetsLost;
        report.videoJitter = report.jitter;
        report.videoBitrate = calculateBitrate(report);
      }
      // 视频发送端统计
      if (report.type === 'outbound-rtp' && report.kind === 'video') {
        report.videoBitrateSent = report.bitrate;
        report.videoFrameRate = report.framesPerSecond;
      }
      // 网络候选对统计
      if (report.type === 'candidate-pair' && report.state === 'succeeded') {
        report.rtt = report.currentRoundTripTime;
      }
    });

    updateStatsPanel(userId, report);
  }, 2000);
}

// 更新统计面板
function updateStatsPanel(userId, report) {
  document.getElementById(`packet-loss-${userId}`).textContent = 
    (report.videoPacketsLost || 0) + ' 个';
  document.getElementById(`rtt-${userId}`).textContent = 
    ((report.rtt || 0) * 1000).toFixed(0) + ' ms';
  document.getElementById(`bitrate-${userId}`).textContent = 
    ((report.videoBitrate || 0) / 1000).toFixed(1) + ' Mbps';
}
指标 正常范围 告警阈值 处理建议
丢包率 < 1% > 5% 降低视频码率或切换音频编码
RTT < 100ms > 300ms 检查网络链路,考虑切换ICE候选
视频帧率 25-30 fps < 15 fps 降低分辨率或关闭Simulcast高码率层
音频抖动 < 30ms > 80ms 启用音频NetEQ缓冲
避坑指南:我曾经在统计面板里直接显示原始RTT值,结果数值跳来跳去,用户看了很慌。后来加了滑动平均滤波(取最近5次平均值),面板才稳定下来。另外,统计数据的存储也很重要——建议用环形缓冲区保留最近5分钟的数据,方便排查问题。

第五步:模块集成——启动与协调

最后一步,把四个模块串起来。启动顺序很重要:先启动信令服务器,再启动媒体服务器,然后客户端才能连接。录制模块和统计模块可以在客户端连接后动态启动。

// 主启动流程
async function startConference(roomId, userId) {
  // 1. 连接信令服务器
  const ws = connectSignaling('wss://signaling.example.com');
  await waitForConnection(ws);

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

  // 3. 加入房间,触发PeerConnection创建
  joinRoom(roomId, userId);

  // 4. 监听新用户加入,创建对应PeerConnection
  ws.onmessage = (event) => {
    const msg = JSON.parse(event.data);
    if (msg.type === 'room_users') {
      msg.users.forEach(remoteUser => {
        if (remoteUser !== userId) {
          createPeerConnection(remoteUser, localStream);
        }
      });
    }
  };

  // 5. 启动录制(可选,由用户点击触发)
  document.getElementById('btn-record').onclick = () => {
    const recorder = startRecording(localStream);
    // 同时录制远程流
    remoteStreams.forEach(stream => {
      startRecording(stream);
    });
  };

  // 6. 启动统计监控
  peerConnections.forEach((pc, remoteUserId) => {
    startStatsMonitor(pc, remoteUserId);
  });
}

嗯,到这里,一个完整的视频会议系统就搭起来了。你可能会问:这能支撑多少人同时在线?说实话,纯浏览器端SFU方案,单台服务器支撑50-100人没问题。如果超过这个量,就需要考虑集群部署和级联SFU了——那是另一个话题。

最后说一句:别追求一步到位。先把信令和媒体跑通,再加录制,最后上统计。每加一个模块,都要回归测试一下通话质量。我在项目中吃过这个亏——录制模块加完后,发现音频延迟从50ms飙到了200ms,查了半天是录制线程抢了编码器的CPU时间片。

好了,动手试试吧。有问题随时翻翻前面的章节,或者看看API文档。调试的时候多看看统计面板,它会告诉你一切。


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