9、mediasoup实战:房间与Peer管理、媒体流路由、动态带宽适配、录制功能

好,终于到了实战环节。前面几章我们把 mediasoup 的原理、信令、SFU 架构都聊透了。这一章,咱们直接上手写代码。

我会带你实现一个多人视频会议最核心的几个模块:房间管理Peer 生命周期媒体流路由动态带宽适配,还有录制功能。这些都是我在实际项目中踩过坑、优化过的方案,你拿过去就能用。

核心要点:本章所有代码基于 mediasoup v3 + Node.js,信令使用 WebSocket。你不需要完整跑起来,重点是理解设计思路和关键代码片段。

mediasoup 多人视频会议核心模块 mediasoup Router 房间管理 Room → Router 映射 Peer 管理 Producer/Consumer 媒体路由 PipeTransport 转发 带宽适配 REMBB/Transport-CC 录制功能:plainTransport + FFmpeg 每个 Room 对应一个 Router,Peer 通过 Producer/Consumer 发布和订阅媒体 带宽适配基于 REMB 或 Transport-CC 反馈动态调整

9.1 房间管理:一个 Room 就是一个 Router

在 mediasoup 里,一个房间对应一个 Router。这是我个人习惯的做法,也是官方推荐的方式。每个 Router 独立管理自己的 WebRTC Transport、Producer 和 Consumer,互不干扰。

为什么这么做?你想想看,如果多个房间共享一个 Router,那媒体流的隔离就成问题了。A 房间的人不小心订阅了 B 房间的流,这画面太美我不敢看。

设计思路:房间 ID 作为 key,Router 作为 value,存在一个 Map 里。创建房间时调用 mediasoup Worker 的 createRouter() 方法。

// 房间管理核心代码
const rooms = new Map();

async function createRoom(roomId) {
  if (rooms.has(roomId)) {
    console.log(`房间 ${roomId} 已存在`);
    return rooms.get(roomId);
  }
  
  const router = await worker.createRouter({
    mediaCodecs: [
      {
        kind: 'audio',
        mimeType: 'audio/opus',
        clockRate: 48000,
        channels: 2
      },
      {
        kind: 'video',
        mimeType: 'video/VP8',
        clockRate: 90000,
        parameters: {
          'x-google-start-bitrate': 1000
        }
      },
      {
        kind: 'video',
        mimeType: 'video/H264',
        clockRate: 90000,
        parameters: {
          'packetization-mode': 1,
          'profile-level-id': '42e01f',
          'level-asymmetry-allowed': 1
        }
      }
    ]
  });
  
  const room = { router, peers: new Map() };
  rooms.set(roomId, room);
  
  console.log(`房间 ${roomId} 创建成功`);
  return room;
}

这里有个细节:媒体编码配置。我建议把 VP8 和 H264 都加上。为什么?因为不同浏览器支持的编码不一样。Chrome 对 VP8 支持好,Safari 偏爱 H264。你只配一种,就有人进不来。

注意:Router 创建后不能修改 mediaCodecs。如果你需要支持新的编码,只能重新创建 Router。所以一开始就配全了比较好。

9.2 Peer 管理:从加入房间到发布订阅

一个 Peer 加入房间,需要做三件事:

  1. 创建 WebRTC Transport(发送端和接收端各一个)
  2. 发布自己的音视频(创建 Producer)
  3. 订阅其他人的流(创建 Consumer)

我记得第一次做的时候,把 Transport 和 Producer 搞混了。Transport 是管道,Producer 是管道里的水。管道要先铺好,水才能流。

// Peer 加入房间流程
async function joinRoom(socket, roomId, peerId) {
  const room = rooms.get(roomId);
  if (!room) {
    socket.emit('error', '房间不存在');
    return;
  }
  
  // 1. 创建发送端 Transport
  const sendTransport = await room.router.createWebRtcTransport({
    listenIps: [{ ip: '0.0.0.0', announcedIp: '你的公网IP' }],
    enableUdp: true,
    enableTcp: true,
    preferUdp: true,
    initialAvailableOutgoingBitrate: 1000000, // 1Mbps
    maxSctpMessageSize: 262144
  });
  
  // 2. 创建接收端 Transport
  const recvTransport = await room.router.createWebRtcTransport({
    listenIps: [{ ip: '0.0.0.0', announcedIp: '你的公网IP' }],
    enableUdp: true,
    enableTcp: true,
    preferUdp: true
  });
  
  // 3. 保存 Peer 信息
  const peer = {
    id: peerId,
    sendTransport,
    recvTransport,
    producers: new Map(),
    consumers: new Map()
  };
  
  room.peers.set(peerId, peer);
  
  // 4. 返回 Transport 参数给客户端
  socket.emit('transport-created', {
    send: {
      id: sendTransport.id,
      iceParameters: sendTransport.iceParameters,
      iceCandidates: sendTransport.iceCandidates,
      dtlsParameters: sendTransport.dtlsParameters
    },
    recv: {
      id: recvTransport.id,
      iceParameters: recvTransport.iceParameters,
      iceCandidates: recvTransport.iceCandidates,
      dtlsParameters: recvTransport.dtlsParameters
    }
  });
}

客户端拿到 Transport 参数后,会创建本地的 RTCPeerConnection,然后通过信令交换 ICE 候选和 DTLS 指纹。这个过程和标准 WebRTC 一模一样,只是信令走的是我们自己的 WebSocket。

9.3 媒体流路由:谁订阅谁,我说了算

多人会议里,不是每个人都需要所有人的流。比如一个 50 人的会议,你只看 3x3 的九宫格,那只需要订阅 9 个人的流就够了。

媒体流路由的核心逻辑是:当新 Peer 发布 Producer 时,通知房间内其他 Peer 来订阅;当 Peer 离开时,通知其他人取消订阅。

// 发布 Producer 并通知其他人
async function produce(socket, roomId, peerId, kind, rtpParameters) {
  const room = rooms.get(roomId);
  const peer = room.peers.get(peerId);
  
  // 创建 Producer
  const producer = await peer.sendTransport.produce({
    kind,
    rtpParameters
  });
  
  peer.producers.set(producer.id, producer);
  
  // 通知房间内其他 Peer 来订阅
  for (const [otherPeerId, otherPeer] of room.peers) {
    if (otherPeerId === peerId) continue;
    
    // 创建 Consumer
    const consumer = await otherPeer.recvTransport.consume({
      producerId: producer.id,
      rtpCapabilities: otherPeer.rtpCapabilities,
      paused: false
    });
    
    otherPeer.consumers.set(consumer.id, consumer);
    
    // 发送给客户端
    socket.to(otherPeerId).emit('new-consumer', {
      producerId: producer.id,
      id: consumer.id,
      kind: consumer.kind,
      rtpParameters: consumer.rtpParameters,
      type: consumer.type,
      producerPeerId: peerId
    });
  }
  
  console.log(`Peer ${peerId} 发布了 ${kind} 流`);
}

这里有个坑:Consumer 的 rtpCapabilities 必须和 Producer 匹配。如果不匹配,consume 会直接报错。我建议在 Peer 加入时,先让客户端上报自己的 rtpCapabilities,服务端存起来备用。

路由策略选择:

  • 全量路由:每个人订阅所有人的流。适合小规模会议(≤6人)
  • 选择性路由:客户端告诉服务端要订阅谁。适合大规模会议
  • Simulcast 路由:根据带宽选择不同质量的流。适合网络不稳定场景

9.4 动态带宽适配:别让用户卡成 PPT

带宽适配是 SFU 的核心能力。说白了就是:网络不好的时候,自动降低视频质量;网络恢复了,再升回去。

mediasoup 支持两种带宽估计机制:

机制 原理 适用场景
REMBB 接收端计算丢包率,发送 REMB 消息给发送端 老版本浏览器、简单场景
Transport-CC 基于每个包的到达时间计算带宽 Chrome 70+、更精确

我个人更推荐 Transport-CC。为什么?因为它更细粒度。REMBB 是每 500ms 发一次反馈,Transport-CC 可以做到每个包都反馈。精度完全不在一个量级。

在 mediasoup 里启用带宽适配很简单:

// 创建 Transport 时启用带宽估计
const transport = await router.createWebRtcTransport({
  listenIps: [{ ip: '0.0.0.0', announcedIp: '你的公网IP' }],
  enableUdp: true,
  enableTcp: true,
  preferUdp: true,
  initialAvailableOutgoingBitrate: 1000000,
  enableTraceEvent: ['bwe']  // 开启带宽估计追踪
});

// 监听带宽变化事件
transport.on('trace', (trace) => {
  if (trace.type === 'bwe' && trace.event === 'bwe') {
    console.log(`带宽更新: ${trace.info.availableBitrate} bps`);
    
    // 可以在这里做自适应逻辑
    if (trace.info.availableBitrate < 500000) {
      // 带宽不足,通知客户端降低分辨率
      socket.emit('bandwidth-low', { bitrate: trace.info.availableBitrate });
    }
  }
});

实战经验:我曾经在一个项目中遇到带宽抖动特别厉害的情况。后来发现是 REMB 消息间隔太长,导致带宽估计滞后。换成 Transport-CC 后,问题解决了。如果你的用户反馈视频忽好忽坏,先检查带宽估计机制。

9.5 录制功能:用 plainTransport 对接 FFmpeg

录制是很多会议系统的刚需。mediasoup 的录制方案很优雅:用 plainTransport 把媒体流导出,然后喂给 FFmpeg 编码保存。

plainTransport 和 WebRTC Transport 的区别在于:它不处理 ICE/DTLS,直接暴露原始的 RTP 包。这样 FFmpeg 可以直接接收。

// 录制功能实现
const { spawn } = require('child_process');

async function startRecording(roomId, outputPath) {
  const room = rooms.get(roomId);
  if (!room) throw new Error('房间不存在');
  
  // 创建 plainTransport
  const plainTransport = await room.router.createPlainTransport({
    listenIp: { ip: '0.0.0.0', announcedIp: '你的公网IP' },
    rtcpMux: false,
    comedia: true
  });
  
  // 获取所有音频 Producer
  const audioProducers = [];
  for (const peer of room.peers.values()) {
    for (const producer of peer.producers.values()) {
      if (producer.kind === 'audio') {
        audioProducers.push(producer);
      }
    }
  }
  
  // 把音频流连接到 plainTransport
  for (const producer of audioProducers) {
    await plainTransport.connect({ producerId: producer.id });
  }
  
  // 启动 FFmpeg 进程
  const ffmpeg = spawn('ffmpeg', [
    '-f', 'sdp',
    '-i', '-',  // 从 stdin 读取 SDP
    '-c:v', 'libx264',
    '-preset', 'ultrafast',
    '-c:a', 'aac',
    '-f', 'mp4',
    outputPath
  ]);
  
  // 生成 SDP 并写入 FFmpeg 的 stdin
  const sdp = generateSdp(plainTransport);
  ffmpeg.stdin.write(sdp);
  ffmpeg.stdin.end();
  
  console.log(`录制开始: ${outputPath}`);
  
  return { plainTransport, ffmpeg };
}

function generateSdp(transport) {
  // 根据 transport 的 IP、端口、编码信息生成 SDP
  // 这里省略具体实现,网上有现成的库
  return `v=0\n...`;
}

注意:录制会消耗大量 CPU。如果同时录制多个房间,建议用单独的 Worker 进程处理。我曾经在生产环境遇到过 FFmpeg 进程吃满 CPU 导致 mediasoup 卡顿的情况,后来把录制任务放到单独的服务器上才解决。

录制文件的存储也是个问题。我建议按日期分目录,文件名带上房间 ID 和时间戳。比如:/recordings/2024/01/15/room-abc-20240115_143000.mp4。这样方便后续管理和清理。

9.6 避坑指南:我踩过的那些坑

最后分享几个实战中容易踩的坑:

  • 端口不够用:mediasoup 每个 Transport 都要占一个端口。如果会议规模大,记得把端口范围调大。我一般设置 40000-50000。
  • ICE 连接失败:大部分原因是 announcedIp 没配对。如果你在 Docker 里跑,一定要设置 announcedIp 为宿主机的公网 IP。
  • Consumer 创建失败:检查 rtpCapabilities 是否匹配。客户端上报的 capabilities 和服务端 Router 的 codecs 必须一致。
  • 录制文件损坏:FFmpeg 进程异常退出会导致文件不完整。建议先录成临时文件,录制完成后再重命名。

嗯,这一章的内容就到这里。代码量不小,但每个模块都是独立的,你可以挑自己需要的先实现。记住,先跑通一个房间两个人的场景,再慢慢加多人逻辑。一口吃不成胖子,写代码也一样。


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