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条下行流。效率高得多。
8.2 mediasoup入门:架构概览
mediasoup的架构其实不复杂。我画了一张图,你看一眼就明白了。
这张图里,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传输,用于服务器直连或非浏览器端
创建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用Producer和Consumer这两个概念来区分。
- Producer:生产者。客户端把自己的音视频数据推送到SFU,就创建一个Producer。
- Consumer:消费者。客户端从SFU拉取别人的音视频数据,就创建一个Consumer。
一个Transport上可以有多个Producer和多个Consumer。比如一个客户端可以同时推音频和视频,那就是两个Producer。同时它也可以拉取其他三个人的视频,那就是三个Consumer。
客户端推流的流程:
- 客户端创建PeerConnection,获取本地音视频流
- 客户端通过信令服务器告知SFU:我要推流了
- SFU创建一个Transport,生成ICE候选和DTLS指纹,返回给客户端
- 客户端完成ICE连接和DTLS握手
- 客户端发送RTP流,SFU在Transport上创建一个Producer
拉流的流程类似,只是方向相反:
- 客户端告诉SFU:我想看某个人的视频
- SFU在对应的Transport上创建一个Consumer
- SFU把Consumer的RTP参数发给客户端
- 客户端创建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();
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。把它们的生命周期和关系理清楚,后面的开发就会顺畅很多。