9、mediasoup实战:房间与Peer管理、媒体流路由、动态带宽适配、录制功能
好,终于到了实战环节。前面几章我们把 mediasoup 的原理、信令、SFU 架构都聊透了。这一章,咱们直接上手写代码。
我会带你实现一个多人视频会议最核心的几个模块:房间管理、Peer 生命周期、媒体流路由、动态带宽适配,还有录制功能。这些都是我在实际项目中踩过坑、优化过的方案,你拿过去就能用。
核心要点:本章所有代码基于 mediasoup v3 + Node.js,信令使用 WebSocket。你不需要完整跑起来,重点是理解设计思路和关键代码片段。
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 加入房间,需要做三件事:
- 创建 WebRTC Transport(发送端和接收端各一个)
- 发布自己的音视频(创建 Producer)
- 订阅其他人的流(创建 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