8、SFU服务器搭建:mediasoup入门、SFU核心概念、Transport与Producer/Consumer

好,咱们进入实战环节了。前面几章我们把WebRTC的基础原理、信令流程、媒体协商都捋了一遍。从这一章开始,我们要真正动手搭建一个SFU服务器。我选的是mediasoup,为什么?因为它在Node.js生态里最成熟,性能也够硬。我自己在几个中型会议项目里都用过,踩过不少坑,今天一并分享给你。

8.1 什么是SFU?为什么需要它?

先简单回顾一下。SFU全称是Selective Forwarding Unit,选择性转发单元。说白了,它就是一个媒体流的中转站。

你想想看,如果四个人开会,每个人都要把自己的视频发给其他三个人。没有SFU的话,每个客户端要建立3个P2P连接,总共6条链路。人一多,浏览器根本扛不住。

SFU的做法是:每个客户端只跟SFU建立一个连接。客户端把媒体流推给SFU,SFU再转发给其他人。这样每个客户端只需要处理一条上行流和N-1条下行流。效率高得多。

核心要点:SFU不混流、不转码,只做转发。它保留了流的原始质量,同时大幅降低了客户端的负担。

8.2 mediasoup入门:架构概览

mediasoup的架构其实不复杂。我画了一张图,你看一眼就明白了。

mediasoup SFU 架构图 客户端 A 客户端 B 客户端 C mediasoup Router (核心路由器) WebRTC Transport (接收/发送媒体) WebRTC Transport (接收/发送媒体) WebRTC Transport (接收/发送媒体) 每个客户端通过一个 Transport 连接到 Router,Router 负责转发媒体流

这张图里,Router是核心。每个房间对应一个Router实例。客户端通过Transport跟Router通信。Transport就是一条逻辑通道,负责收发媒体数据。

8.3 核心概念:Transport

Transport是mediasoup里最基础的概念。我刚开始学的时候,总觉得它跟WebRTC的PeerConnection是一回事。其实不完全一样。

Transport是mediasoup内部的概念,它代表一条媒体传输通道。每个Transport对应一个WebRTC的PeerConnection。但Transport更抽象,它不关心底层是UDP还是TCP,也不关心是音频还是视频。

mediasoup有两种Transport:

  • WebRtcTransport:最常用,基于ICE/DTLS,用于浏览器端
  • PlainTransport:纯RTP传输,用于服务器直连或非浏览器端
我的经验:99%的场景用WebRtcTransport就够了。PlainTransport我只有在对接一些老旧的监控设备时才用过。

创建Transport的代码大概长这样:

const transport = await router.createWebRtcTransport({
  listenIps: [
    {
      ip: '0.0.0.0',      // 监听所有网卡
      announcedIp: '你的公网IP'  // 对外公布的IP
    }
  ],
  enableUdp: true,
  enableTcp: true,
  preferUdp: true,
  initialAvailableOutgoingBitrate: 1000000  // 初始1Mbps
});

嗯,这里要注意announcedIp。如果你在本地开发,填127.0.0.1就行。但部署到服务器上,一定要填公网IP。我曾经因为这个字段没配对,折腾了一整天,客户端一直连不上。

8.4 Producer与Consumer:推流与拉流

有了Transport,接下来就是推流和拉流了。mediasoup用ProducerConsumer这两个概念来区分。

  • Producer:生产者。客户端把自己的音视频数据推送到SFU,就创建一个Producer。
  • Consumer:消费者。客户端从SFU拉取别人的音视频数据,就创建一个Consumer。

一个Transport上可以有多个Producer和多个Consumer。比如一个客户端可以同时推音频和视频,那就是两个Producer。同时它也可以拉取其他三个人的视频,那就是三个Consumer。

关键点:Producer和Consumer都是绑定在Transport上的。一个Transport可以同时承载上行和下行流。

客户端推流的流程:

  1. 客户端创建PeerConnection,获取本地音视频流
  2. 客户端通过信令服务器告知SFU:我要推流了
  3. SFU创建一个Transport,生成ICE候选和DTLS指纹,返回给客户端
  4. 客户端完成ICE连接和DTLS握手
  5. 客户端发送RTP流,SFU在Transport上创建一个Producer

拉流的流程类似,只是方向相反:

  1. 客户端告诉SFU:我想看某个人的视频
  2. SFU在对应的Transport上创建一个Consumer
  3. SFU把Consumer的RTP参数发给客户端
  4. 客户端创建PeerConnection,接收RTP流

8.5 实战:搭建一个最简单的mediasoup服务器

光说不练假把式。我们写一个最简单的mediasoup服务器,就一个房间,两个人能互相看到对方。

先安装依赖:

npm install mediasoup express socket.io

然后创建服务器入口文件:

const mediasoup = require('mediasoup');
const express = require('express');
const http = require('http');
const { Server } = require('socket.io');

async function main() {
  // 创建Worker
  const worker = await mediasoup.createWorker();
  console.log('Worker创建成功,PID:', worker.pid);

  // 创建Router(一个房间一个Router)
  const router = await worker.createRouter({
    mediaCodecs: [
      {
        kind: 'audio',
        mimeType: 'audio/opus',
        clockRate: 48000,
        channels: 2
      },
      {
        kind: 'video',
        mimeType: 'video/VP8',
        clockRate: 90000
      }
    ]
  });

  console.log('Router创建成功,ID:', router.id);

  // 启动HTTP和Socket.IO服务
  const app = express();
  const server = http.createServer(app);
  const io = new Server(server);

  // 存储Transport和Producer
  const transports = new Map();
  const producers = new Map();

  io.on('connection', (socket) => {
    console.log('客户端连接:', socket.id);

    socket.on('createTransport', async (_, callback) => {
      const transport = await router.createWebRtcTransport({
        listenIps: [{ ip: '0.0.0.0', announcedIp: '127.0.0.1' }],
        enableUdp: true,
        enableTcp: true,
        preferUdp: true
      });

      transports.set(socket.id, transport);

      callback({
        id: transport.id,
        iceParameters: transport.iceParameters,
        iceCandidates: transport.iceCandidates,
        dtlsParameters: transport.dtlsParameters
      });
    });

    socket.on('connectTransport', async (dtlsParameters) => {
      const transport = transports.get(socket.id);
      await transport.connect({ dtlsParameters });
      console.log('Transport连接成功');
    });

    socket.on('produce', async ({ kind, rtpParameters }, callback) => {
      const transport = transports.get(socket.id);
      const producer = await transport.produce({ kind, rtpParameters });
      producers.set(socket.id, producer);

      callback({ id: producer.id });

      // 通知其他客户端有新的Producer
      socket.broadcast.emit('newProducer', {
        producerId: producer.id,
        socketId: socket.id,
        kind
      });
    });

    socket.on('consume', async ({ producerId, rtpCapabilities }, callback) => {
      const transport = transports.get(socket.id);
      const consumer = await transport.consume({
        producerId,
        rtpCapabilities,
        paused: false
      });

      callback({
        id: consumer.id,
        kind: consumer.kind,
        rtpParameters: consumer.rtpParameters,
        type: consumer.type
      });
    });
  });

  server.listen(3000, () => {
    console.log('服务器运行在 http://localhost:3000');
  });
}

main();
注意:上面的代码是简化版,只演示核心流程。生产环境需要处理房间管理、权限验证、断线重连、资源清理等逻辑。我曾经在生产环境漏掉了Transport的关闭逻辑,导致内存泄漏,服务器跑了三天就挂了。

8.6 避坑指南

最后分享几个我踩过的坑:

  • 端口范围:mediasoup默认使用随机端口。如果你在云服务器上部署,记得开放UDP端口范围(比如40000-49999)。我一开始只开了443端口,结果ICE连接一直失败。
  • Worker数量:一个Worker进程占用一个CPU核心。建议Worker数量等于CPU核心数。不要创建太多Worker,否则上下文切换会拖慢性能。
  • Router隔离:每个房间一个Router。不要把多个房间混在同一个Router里,否则一个房间的异常会影响其他房间。
  • Consumer的暂停与恢复:当用户离开页面时,记得暂停Consumer,而不是直接关闭。这样可以保留RTP流状态,用户回来时恢复速度更快。

好了,这一章的内容就到这。mediasoup的核心概念其实就这几个:Worker、Router、Transport、Producer、Consumer。把它们的生命周期和关系理清楚,后面的开发就会顺畅很多。


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