12、SFU服务器搭建:基于mediasoup的SFU部署、房间与Peer管理、媒体流转发

聊到SFU,很多刚接触WebRTC的朋友第一反应是:「这东西是不是特别重?」

其实不然。我最早接触mediasoup时,也被它的C++底层吓到过。但用下来发现,它的Node.js层封装得相当优雅。说白了,mediasoup就是一个跑在服务端的「媒体路由器」——它不负责编解码,只负责把各路媒体流高效地转发给需要的人。

12.1 mediasoup的核心架构

先看一张图,把整体逻辑理清楚。

mediasoup SFU 核心架构 Peer A (发送端) Producer Peer B (接收端) Consumer Peer C (收发) Producer + Consumer Router (每个房间一个) Transport Producer Consumer RtpObserver Worker (C++ 子进程) libwebrtc DTLS / ICE SRTP / SCTP 客户端 Router (JS层) Worker (C++层)

看到这个分层结构了吗?mediasoup启动后,会创建多个Worker(C++子进程),每个Worker里可以跑多个Router。我一般把每个Router对应一个「房间」。Router内部管理着Transport、Producer、Consumer这些核心对象。

嗯,这里有个关键点:Worker是真正的媒体处理单元,它跑在独立的进程中。这样做的好处是——某个Worker挂了,不会影响其他Worker。我在生产环境里就遇到过Worker内存泄漏的情况,幸好其他房间没受影响。

12.2 部署mediasoup服务

部署其实不复杂。先装依赖,再写几行核心代码就能跑起来。

// 安装 mediasoup
npm install mediasoup

// 创建 Worker 和 Router
const mediasoup = require('mediasoup');

async function createWorker() {
  const worker = await mediasoup.createWorker({
    logLevel: 'warn',
    logTags: ['info', 'ice', 'dtls', 'rtp', 'srtp', 'rtcp'],
    rtcMinPort: 40000,
    rtcMaxPort: 49999
  });

  worker.on('died', () => {
    console.error('Worker 进程挂了,赶紧重启!');
    // 我一般在这里做自动拉起
  });

  return worker;
}

async function createRouter(worker) {
  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
        }
      }
    ]
  });

  return router;
}
我的经验:端口范围别设太小。40000-49999 够用,但如果你的服务器同时跑几百个房间,建议扩大到 30000-59999。我曾经因为端口耗尽,导致新用户一直连不上,排查了半天才发现是端口不够用。

12.3 房间与Peer管理

房间管理说白了就是维护一个Map。Key是房间ID,Value是Router实例。Peer管理也类似,每个房间里维护一个Peer列表。

// 房间管理器
class RoomManager {
  constructor() {
    this.rooms = new Map();  // roomId -> { router, peers }
  }

  async createRoom(roomId, worker) {
    if (this.rooms.has(roomId)) {
      console.warn(`房间 ${roomId} 已存在`);
      return this.rooms.get(roomId);
    }

    const router = await createRouter(worker);
    const room = {
      router,
      peers: new Map()  // peerId -> { transports, producers, consumers }
    };

    this.rooms.set(roomId, room);
    console.log(`房间 ${roomId} 创建成功`);
    return room;
  }

  getRoom(roomId) {
    return this.rooms.get(roomId);
  }

  closeRoom(roomId) {
    const room = this.rooms.get(roomId);
    if (room) {
      room.router.close();
      this.rooms.delete(roomId);
      console.log(`房间 ${roomId} 已关闭`);
    }
  }
}

// Peer 加入房间
async function joinRoom(room, peerId) {
  if (room.peers.has(peerId)) {
    console.warn(`Peer ${peerId} 已在房间中`);
    return room.peers.get(peerId);
  }

  const peer = {
    id: peerId,
    transports: new Map(),
    producers: new Map(),
    consumers: new Map()
  };

  room.peers.set(peerId, peer);
  console.log(`Peer ${peerId} 加入房间`);
  return peer;
}
注意:房间和Peer的清理工作一定要做。我曾经在生产环境里忘了清理已断开的Peer,导致内存只增不减。建议加个心跳机制,30秒没心跳就自动清理。

12.4 媒体流转发核心逻辑

媒体流转发是SFU的灵魂。说白了就是:A发视频,B、C、D都要看,那服务器就把A的流复制三份,分别推给B、C、D。

mediasoup里,这个逻辑通过Producer和Consumer来实现:

  • Producer:代表一个Peer发出的媒体流(音频或视频)
  • Consumer:代表一个Peer接收的媒体流
  • Transport:Peer和Router之间的连接通道(WebRTC Transport或Plain Transport)
// 创建 Transport(每个 Peer 需要创建自己的 Transport)
async function createWebRtcTransport(router) {
  const transport = await router.createWebRtcTransport({
    listenIps: [
      {
        ip: '0.0.0.0',  // 监听所有网卡
        announcedIp: '你的公网IP'  // 生产环境一定要填公网IP
      }
    ],
    enableUdp: true,
    enableTcp: true,
    preferUdp: true,
    initialAvailableOutgoingBitrate: 1000000  // 初始1Mbps
  });

  return transport;
}

// 创建 Producer(Peer 发布自己的流)
async function createProducer(transport, kind, rtpParameters) {
  const producer = await transport.produce({
    kind,  // 'audio' 或 'video'
    rtpParameters
  });

  return producer;
}

// 创建 Consumer(为其他 Peer 订阅流)
async function createConsumer(router, consumerTransport, producer) {
  // 检查是否可以消费
  const canConsume = await router.canConsume({
    producerId: producer.id,
    rtpCapabilities: consumerTransport.rtpCapabilities
  });

  if (!canConsume) {
    console.warn('无法消费该流,可能是编解码器不匹配');
    return null;
  }

  const consumer = await consumerTransport.consume({
    producerId: producer.id,
    rtpCapabilities: consumerTransport.rtpCapabilities,
    paused: false  // 是否一开始就暂停
  });

  return consumer;
}

核心转发逻辑:

当Peer A发布视频后,服务器需要遍历房间内所有其他Peer,为每个Peer创建一个Consumer。这样每个Peer都能看到A的画面。

但要注意——不要给发送者自己创建Consumer,否则会出现回声或画面循环。

12.5 实际项目中的避坑指南

我在做第一个mediasoup项目时,踩了不少坑。挑几个典型的说说:

问题 原因 解决方案
客户端连不上 announcedIp 没填公网IP 填写服务器的公网IP或域名
视频卡顿严重 初始码率设得太高 initialAvailableOutgoingBitrate 设为 500000
内存持续增长 Peer断开后没清理 监听 'close' 事件,及时释放资源
部分浏览器没画面 H264 参数不匹配 profile-level-id 用 42e01f
我曾经踩过的坑:有一次线上环境突然所有用户都连不上,排查了半天发现是防火墙把UDP端口封了。mediasoup默认用UDP传输媒体数据,TCP只是备用。所以记得在防火墙里开放 rtcMinPort 到 rtcMaxPort 的UDP端口。

12.6 完整的媒体流转发流程

把上面的代码串起来,一个典型的流转发流程是这样的:

  1. 服务端启动Worker,创建Router(每个房间一个)
  2. Peer加入房间,创建WebRTC Transport
  3. Peer发布媒体流,创建Producer
  4. 服务端遍历房间内其他Peer,为每个Peer创建Consumer
  5. 当有新的Peer加入时,服务端自动为其创建已有Producer的Consumer
  6. 当Peer离开时,清理其所有Transport、Producer、Consumer

嗯,这个流程看起来简单,但实际写代码时要注意并发问题。比如两个Peer同时加入房间,可能会竞争创建Transport。我一般用队列或者锁来处理,保证每个Peer的操作是串行的。

最后说一句:mediasoup的文档其实写得不错,但有些细节藏在源码里。遇到问题别慌,去GitHub的issues里搜一搜,八成能找到答案。我每次升级版本都会先看看changelog,避免踩坑。


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