一对一通话用点对点就够,但一旦参与人数超过三四个,端到端的全互联就会迅速崩溃。多人实时通信的核心问题从「如何连上」变成了「如何高效地把一路媒体分发给多个人」,这正是 SFU 与 MCU 要解决的问题。
本文对比三种多人拓扑的代价,重点剖析 SFU 的转发模型,并给出主流开源实现的差异与选型建议。读完你应该能根据房间规模与业务形态,判断该用 Mesh、SFU 还是 MCU,以及该选哪个开源项目起步。
一、三种多人拓扑
多人实时通信在拓扑上只有三种基本选择,它们的带宽与算力代价差异巨大:
| 拓扑 | 上行路数 | 下行路数 | 服务端算力 | 典型人数 |
|---|---|---|---|---|
| Mesh | N-1 | N-1 | 无 | 2~4 |
| SFU | 1 | N-1 | 转发 | 4~50 |
| MCU | 1 | 1 | 解码混流再编码 | 不限,但成本高 |
设房间人数为 N,每个参与者需要编码并上传自己的一路媒体,同时接收其他 N-1 人的媒体。Mesh 让每个客户端直接与其余所有人建立连接,因此每人的上行与下行都是 N-1 路。
二、Mesh 的适用边界
Mesh 的优点是实现最简单,没有服务端媒体处理,延迟最低,隐私最好。缺点是客户端带宽与算力随人数平方级增长。
以 720p、1.5 Mbps 的视频为例,四人 Mesh 下每人的下行带宽就是 4.5 Mbps,上行也是 4.5 Mbps。到了六人,下行接近 8 Mbps,且编码器需要同时产生多路码流,移动端几乎无法承受。
Mesh 的合理边界是 2 到 4 人的小规模通话,尤其是企业内网或对隐私要求极高的场景。一旦预期人数会增长,就不应选择 Mesh,否则后期迁移成本很高。
三、MCU 的原理与代价
MCU(Multipoint Control Unit)在服务端把所有人的媒体解码、混合、再编码成一路流下发给每个人。因此每个客户端只需上行一路、下行一路,带宽与算力压力从客户端转移到了服务器。
MCU 的代价是服务端需要为每个房间做实时解码与编码,CPU 开销极大,且混流会引入额外延迟。此外,混流后的画面布局固定,无法像 SFU 那样让每个用户自由选择看谁。
MCU 适合的场景是:参与者数量极大(如直播连麦的观众侧)、终端能力极弱(如老式硬件终端),或者需要把多方通话接入传统电话网(混音后送 PSTN)。对于现代 Web 应用,纯 MCU 已较少见,更常见的是 SFU 加服务端选择性混流(如把多个小画面合成一路用于旁路直播)。
四、SFU 的核心机制
4.1 转发而非混流
SFU(Selective Forwarding Unit)的核心思想是「只转发,不解码」。每个发布者上传一路(或几路)媒体,SFU 根据订阅关系,把对应的 RTP 包转发给订阅者。服务端不做解码与重编码,因此算力开销远小于 MCU,只受网络吞吐限制。
代价是下行带宽:每个订阅者仍要接收 N-1 路,但上行只有 1 路。相比 Mesh,SFU 把上行压力从客户端剥离,这是它成为主流的根本原因。
4.2 发布、订阅与路由
SFU 内部通常有以下抽象:
| 概念 | 含义 |
|---|---|
| Transport | 与某个客户端的传输通道,分上行与下行 |
| Producer | 一路上行媒体,对应一个发布者的轨道 |
| Consumer | 一路下行媒体,对应一个订阅关系 |
| Router | 房间级别的媒体路由容器,管理 Producer 与 Consumer |
| RTP Capabilities | 协商后的编解码与头部扩展能力 |
发布者创建 Producer,订阅者创建 Consumer 并指向某个 Producer。SFU 在 Consumer 上按需做转发决策,比如是否转发、转发哪一层。
4.3 选择性转发与分层
SFU 的「选择性」体现在两处。第一,按订阅关系选择转发哪些流,而不是广播全部。第二,在 Simulcast 或 SVC 场景下,为每个订阅者选择合适的分层:网络好的订阅者拿高清层,网络差的拿低清层,无需重新编码。
这种分层转发是 SFU 相对 MCU 的最大优势:一路发布,多档分发,既省上行又省服务端算力。
五、主流开源 SFU 对比
| 项目 | 语言 | 定位 | 特点 |
|---|---|---|---|
| mediasoup | C++ / Node.js | 库,非服务 | 性能高,API 精细,需自行组装 |
| Janus | C | 通用网关 | 插件化,功能全,配置复杂 |
| LiveKit | Go | 一体化平台 | 开箱即用,含 SDK 与房间管理 |
| Jitsi Videobridge | Java | 会议桥 | 与 Jitsi 生态深度集成 |
| Pion | Go | 协议库 | 纯 Go,适合自研定制 |
选择建议:需要极致控制与性能,用 mediasoup;需要快速上线完整产品,用 LiveKit;需要与 SIP、RTSP 等传统协议互通,用 Janus;需要深度定制协议栈,用 Pion 自研。
六、mediasoup 服务端骨架
下面的 Node.js 代码展示了 mediasoup 的核心流程:创建 Worker、Router、Transport,并在其上建立 Producer 与 Consumer。它省略了信令部分,只保留媒体层。
import mediasoup from "mediasoup";
async function startWorker() {
const worker = await mediasoup.createWorker({
rtcMinPort: 40000,
rtcMaxPort: 40100,
logLevel: "warn",
});
worker.on("died", () => {
console.error("worker 异常退出,需重启进程");
});
return worker;
}
async function createRouter(worker) {
const mediaCodecs = [
{ kind: "audio", mimeType: "audio/opus", clockRate: 48000, channels: 2 },
{
kind: "video",
mimeType: "video/VP8",
clockRate: 90000,
parameters: {},
},
];
return worker.createRouter({ mediaCodecs });
}
// 为客户端创建双向传输
async function createWebRtcTransport(router) {
return router.createWebRtcTransport({
listenIps: [{ ip: "0.0.0.0", announcedIp: "203.0.113.10" }],
enableUdp: true,
enableTcp: true,
preferUdp: true,
});
}
// 发布者:客户端上行轨道 -> Producer
async function createProducer(transport, rtpParameters, kind) {
return transport.produce({ kind, rtpParameters });
}
// 订阅者:为某个 Producer 创建 Consumer
async function createConsumer(transport, producer, rtpCapabilities) {
if (!transport.router.canConsume({ producerId: producer.id, rtpCapabilities })) {
throw new Error("该消费者无法订阅此生产者,编解码不兼容");
}
return transport.consume({
producerId: producer.id,
rtpCapabilities,
paused: true, // 先暂停,等客户端准备好再恢复
});
}
canConsume 这一步至关重要:它检查订阅者能力与发布者能力是否有交集。若客户端只支持 H264 而发布者用的是 VP8,就需要服务端转码或直接拒绝,这是 SFU 场景下必须显式处理的兼容性问题。
6.1 传输握手与能力协商
Producer 与 Consumer 都建立在 Transport 之上,而 Transport 需要先完成 DTLS 握手才能承载媒体。服务端侧的完整流程如下:
// 客户端拿到 router 的 RTP 能力后,用它来创建本地 sender
function getRouterRtpCapabilities(router) {
return router.rtpCapabilities;
}
// 客户端发来 DTLS 参数后,服务端完成连接
async function connectTransport(transport, dtlsParameters) {
await transport.connect({ dtlsParameters });
}
// Consumer 默认处于暂停态,客户端解码器就绪后再恢复
async function resumeConsumer(consumer) {
await consumer.resume();
}
// Simulcast 场景下为订阅者切换空间层
async function preferLayer(consumer, spatialLayer) {
await consumer.setPreferredLayers({ spatialLayer, temporalLayer: 2 });
}
// 关闭时按顺序释放,先 Consumer 再 Producer 再 Transport
async function teardown(consumer, producer, transport) {
consumer.close();
producer.close();
transport.close();
}
transport.connect 与 consumer.resume 是最容易漏掉的两步:前者漏掉会导致媒体通道建立失败,后者漏掉会导致订阅者一直黑屏却没有任何报错。排障时应优先检查这两处。
七、分层选择与带宽控制
7.1 SFU 如何决定转发哪一层
在 Simulcast 场景下,发布者上传三路独立编码(如 720p、360p、180p),SFU 为每个订阅者独立选择转发哪一路。选择的依据是订阅者侧上报的可用带宽与丢包情况:
// 服务端根据订阅者反馈动态切换空间层
function selectLayer(availableBitrate) {
if (availableBitrate > 1200000) return 0; // 720p
if (availableBitrate > 400000) return 1; // 360p
return 2; // 180p
}
async function adaptConsumer(consumer, availableBitrate) {
const target = selectLayer(availableBitrate);
const current = consumer.currentSpatialLayer;
if (target !== current) {
await consumer.setPreferredLayers({ spatialLayer: target, temporalLayer: 2 });
console.log(`切换订阅层: ${current} -> ${target}`);
}
}
7.2 空间层与时间层的取舍
SVC 与 Simulcast 都提供分层,但维度不同:
| 维度 | 含义 | 切换代价 |
|---|---|---|
| 空间层 spatial | 分辨率 | 需等关键帧,有短暂卡顿 |
| 时间层 temporal | 帧率 | 相对平滑 |
时间层切换只改变转发哪些帧,代价小;空间层切换意味着解码器要切换到不同分辨率的码流,需要等待新层的关键帧,会有短暂画面冻结。因此策略上应优先降时间层,再降空间层,恢复时反向操作。
7.3 上行与下行的独立控制
SFU 需要分别控制上行与下行。上行侧限制每个 Producer 的最大码率,防止单个发布者占满带宽;下行侧根据每个订阅者的带宽估计选择层。二者独立,互不干扰。
// 限制发布者上行码率(通过 setParameters 或 SDP 约束)
async function capProducerBitrate(producer, maxBitrate) {
const params = producer.rtpParameters;
for (const codec of params.codecs) {
codec.parameters = codec.parameters || {};
codec.parameters["x-google-max-bitrate"] = String(maxBitrate);
}
await producer.setRtpParameters(params);
}
八、扩展与级联
单台 SFU 受限于单机带宽与 CPU,规模增长后需要横向扩展。常见策略有两种:
- 房间级分片:把不同房间分配到不同 SFU 节点,房间内不跨节点。实现简单,但单房间规模受单机上限约束。
- 级联(Cascading):大房间跨多个 SFU 节点,节点之间互相转发媒体。复杂度高,但能突破单机上限。
级联的难点在于节点间的媒体路由与带宽控制。若一个房间横跨 A、B 两节点,A 上的发布者需要把媒体转发到 B 再分发给 B 上的订阅者,这会带来额外的延迟与带宽。实践中应尽量把同一房间的参与者调度到同一节点,只有在单房间确实超出单机能力时才启用级联。
九、选型决策清单
面对具体项目,可以按下面的顺序做判断:
- 预期最大并发人数是多少?小于 4 人考虑 Mesh,否则 SFU。
- 是否需要自由选择画面?需要则 SFU,MCU 的固定布局不满足。
- 是否有终端能力极弱的用户?有则考虑 MCU 或 SFU 加服务端混流旁路。
- 是否需要与传统电话网互通?需要则优先 Janus。
- 团队是否有精力自研媒体层?有则 mediasoup 或 Pion,无则 LiveKit。
对绝大多数现代 Web 会议产品,SFU 是默认答案,LiveKit 或 mediasoup 是最常见的起点。
十、容量规划与成本估算
10.1 单机 SFU 的瓶颈
SFU 单机能力主要受三方面限制,任何一项先到上限,整机就不可用:
| 资源 | 典型瓶颈 | 影响 |
|---|---|---|
| 出口带宽 | 1~10 Gbps | 决定可承载的下行总量 |
| CPU | 包转发与加密 | 每路媒体都要做 SRTP 加解密 |
| RTC 端口 | 端口范围 | 限制并发传输数 |
其中出口带宽通常最先成为瓶颈,因为 SFU 的下行流量是上行的 N 倍。以每人下行 1.5 Mbps 计算,一台 1 Gbps 出口的机器理论上能支撑约 660 路下行,但实际需留出余量,按 50% 利用率估算约 330 路。
10.2 估算公式
function estimateCapacity({ uplinkMbps, downlinkMbps, roomSize, perStreamKbps }) {
// 单房间上行:每人一路
const roomUplink = roomSize * perStreamKbps;
// 单房间下行:每人接收其余所有人
const roomDownlink = roomSize * (roomSize - 1) * perStreamKbps;
const maxRoomsByUplink = Math.floor((uplinkMbps * 1000) / roomUplink);
const maxRoomsByDownlink = Math.floor((downlinkMbps * 1000) / roomDownlink);
return {
roomUplink,
roomDownlink,
maxRooms: Math.min(maxRoomsByUplink, maxRoomsByDownlink),
};
}
console.log(estimateCapacity({
uplinkMbps: 1000,
downlinkMbps: 1000,
roomSize: 9,
perStreamKbps: 1500,
}));
注意下行随房间人数呈平方增长:9 人房间的下行是 9×8×1.5 Mbps ≈ 108 Mbps。这意味着大房间对单机出口的压力远高于小房间,容量规划必须按「房间规模分布」而非「平均人数」来做。
10.3 成本与体验的平衡
- 提高下行效率的手段是降低每路码率,但会牺牲画质。
- 用 Simulcast 让每个订阅者只接收适配自己网络的那一层,可显著降低下行总量。
- 把旁路直播、录制等非实时流量分流到独立的媒体服务器,避免挤占实时通道。
- 对超大房间(如百人会议)采用「只订阅活跃发言人」的策略,而非订阅全部。
十一、常见坑清单
- 用 Mesh 支撑五人以上会议,移动端上行带宽与编码算力直接崩溃。
- SFU 场景下忘记处理编解码不兼容,
canConsume返回 false 却未给用户提示。 - 把
announcedIp配置成内网地址,导致公网客户端连不上。 - Consumer 创建后忘记
resume,订阅者一直黑屏。 - 单机 SFU 未做端口范围规划,RTC 端口被防火墙拦截。
- 大房间不做好节点调度,随机分配到不同节点,被迫启用级联。
- 忽略 SFU 自身的下行带宽,把所有订阅者放在同一节点。
- 未对 Producer 做上行码率上限,单个发布者拖垮整个房间。
小结
多人实时通信的拓扑选择本质上是「把代价放在哪里」的问题:Mesh 把代价放在客户端,MCU 放在服务端算力,SFU 放在下行带宽。现代 Web 应用的默认答案是 SFU,因为它以最小的服务端开销换取了良好的扩展性。理解 Producer、Consumer、Transport、Router 这组抽象,是使用任何 SFU 的基础。SFU 上的分层转发策略与 弱网对抗与自适应码率 直接相关,而订阅关系的信令表达则依赖 SDP 协商与 Offer/Answer 流程 的再协商机制。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。