WebRTC 扩展之道:信令、NAT 穿透与 Mesh/SFU/MCU 架构权衡
RTCPeerConnection 在两个对等方之间协商编解码器,并在连接建立后处理媒体流的编码、解码和传输。
- MediaStream 提供要发送的内容,封装对网络摄像头或麦克风的访问。
RTCDataChannel 与媒体连接并行运行,用于传输非音视频数据,例如聊天消息、文件分块、游戏状态或其他无需编解码器的应用数据。
这些 API 本身并不知道如何找到对端。这部分功能在 WebRTC 规范中完全缺失。
信令与 Offer/Answer 交换
在对端交换媒体之前,它们必须先交换自身能力描述,包括编解码器、网络信息和媒体类型,这些内容以 SDP(会话描述协议)的格式编码。
WebRTC 并未提供在端对端之间传递该描述的具体机制。这就是信令(Signaling),规范将这部分职责留给了上层应用开发者,通常通过 WebSocket 或 HTTP 长轮询实现。
交换过程遵循固定模式:由发起呼叫的端对端发送 Offer,由接收呼叫的端对端回复 Answer:
// Peer A: create and send the offer
const pc = new RTCPeerConnection({ iceServers });
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
signalingChannel.send({ type: 'offer', sdp: pc.localDescription });
// Peer B: accept the offer, respond with an answer
await pc.setRemoteDescription(offerFromA);
const answer = await pc.createAnswer();
await pc.setLocalDescription(answer);
signalingChannel.send({ type: 'answer', sdp: pc.localDescription });
// Peer A: complete the handshake
await pc.setRemoteDescription(answerFromB);
setLocalDescription 和 setRemoteDescription 是这里唯一执行实际工作的两个调用。其余步骤仅负责将 SDP 数据块从一个端对端的信令连接传递到另一个端对端。
NAT 穿透:ICE、STUN 与 TURN
SDP 交换告知每个端对端对方支持的特性,但它无法告诉双方如何相互连接,因为大多数设备位于 NAT 或防火墙之后,没有直接可路由的 IP 地址。
ICE(Interactive Connectivity Establishment)就是解决这个问题的:它会收集本端所有可能可达的地址,并逐一测试,直到找到一条可用的连接。这些地址被称为 candidates(候选地址),来自两种服务器:STUN(Session Traversal Utilities for NAT)和 TURN(Traversal Using Relays around NAT)。
图中展示了一个本端并行收集三种候选地址的过程:来自自身网络接口的 host candidate、由 STUN 服务器返回的 server-reflexive candidate(即 NAT 为其映射的公网 IP 和端口),以及在 TURN 服务器上分配的 relay candidate。三者都会作为 ICE candidates 发送给对端,哪一对组合能成功连通,通话就用哪一条。
STUN 处理的是常见场景:本端向 STUN 服务器询问 NAT 为自己映射的公网 IP 和端口,然后将其作为一个候选地址使用。
TURN 则是 STUN 搞不定时的兜底方案。对称型 NAT 和某些防火墙配置会直接阻断直连,这时 TURN 服务器就负责在两个对端之间中转流量。凡是 STUN 处理不了的网络环境它都能工作,代价是所有数据包都要绕经第三方服务器转发,而非点对点直连——这会增加延迟,还会在通话全程占用服务器带宽。
候选地址的交换方式有两种。第一种是普通 ICE:等所有候选地址都收集完毕后再一次性发送。这种方式简单,但会在一开始就引入延迟——要等最慢的那个候选地址收集完,连接才能开始协商。
第二种是 trickling ICE:每发现一个候选地址就立刻发送,协商从最先到达的那个候选地址开始,而不必等全部到位。但代价是要求双方的信令和 ICE 实现都能处理逐步到达的候选地址,而不是一次性收到全部。
对时效性要求高的应用来说,trickling 带来的额外实现复杂度是值得的。大多数现代 WebRTC 框架默认就支持它。
// 发送端:ICE 发现候选地址后,立即转发
pc.onicecandidate = (event) => {
if (event.candidate) {
signalingChannel.send({ type: 'ice-candidate', candidate: event.candidate });
}
};
// 接收端:每收到一个候选地址就立即添加,不必等待剩余的全部到达
signalingChannel.on('ice-candidate', ({ candidate }) => {
pc.addIceCandidate(candidate);
});
示例实现
为了直观感受这些组件在大规模场景下的实际成本,我搭建了一个信令服务器的 MVP:
服务器选用 Node.js。其单线程事件循环能够高效处理海量并发 WebSocket 连接,避免了每连接一线程的开销;
使用 Express 负责路由,并在其上叠加 Socket.IO 处理客户端连接。当客户端网络彻底阻断 WebSocket 时,系统会自动回退到 HTTP 长轮询。
示意图展示了浏览器客户端通过 WebSocket(或回退至 HTTP 长轮询)连接到应用程序负载均衡器(ALB),ALB 将连接路由至运行在 EKS 上 Docker 容器中的多个 Socket.IO 服务端 Pod。所有 Pod 共享同一个 Redis 集群来维护房间状态,因此客户端被分配到哪个 Pod 都无关紧要。
房间与对端的映射关系存储在 Redis(Amazon ElastiCache)中。Redis 只需快速回答“这个房间里还有谁”即可,无需持久化通话后的数据。
ALB 上的粘性会话是实现上述架构的关键。一个 WebSocket 连接必须在其整个生命周期内固定连接到同一信令服务器实例,因此来自同一客户端的所有请求都必须落到同一个后端 Pod 上。
简化到核心事件层面,服务器本身只是一个轻量级中继。它从不窥探 SDP 内容或 ICE 候选细节,仅在 Redis 中追踪房间成员关系,并将消息转发给对应的 Socket:
io.on('connection', (socket) => {
socket.on('join', async (roomId) => {
socket.join(roomId);
await redis.sadd(`room:${roomId}`, socket.id);
socket.to(roomId).emit('peer-joined', socket.id);
});
socket.on('offer', ({ target, sdp }) => {
io.to(target).emit('offer', {
from: socket.id,
sdp,
});
});
socket.on('answer', ({ target, sdp }) => {
io.to(target).emit('answer', {
from: socket.id,
sdp,
});
});
socket.on('ice-candidate', ({ target, candidate }) => {
io.to(target).emit('ice-candidate', {
from: socket.id,
candidate,
});
});
socket.on('disconnecting', async () => {
// 在 Socket.IO 将 socket 从房间中移除之前复制房间列表。
// socket.rooms 还包含一个以 socket.id 命名的私有房间。
const rooms = [...socket.rooms].filter(
(roomId) => roomId !== socket.id
);
for (const roomId of rooms) {
socket.to(roomId).emit('peer-left', socket.id);
await redis.srem(`room:${roomId}`, socket.id);
}
});
});
join 将 socket 加入房间并记录到 Redis,以便其他服务器实例能够看到它。offer、answer 和 ice-candidate 基本功能相同:接收目标 socket ID 并转发负载,而不解析 SDP 或 ICE 数据。
disconnecting 处理器在 Socket.IO 将 socket 从房间中移除之前执行清理。它首先复制当前的房间 ID(排除 socket 的私有房间),然后通知剩余的 peers 连接已离开,并将该 peer 从对应的 Redis 集合中移除。
提前复制房间 ID 很重要,因为处理器会执行异步的 Redis 操作,而在断开连接完成时,socket 的房间成员资格会被清除。
如果不进行这种清理,即使 socket 已不存在,Redis 仍可能记录该 peer 存在,从而残留过时的房间状态,导致剩余 peers 等待永远不会到达的信号消息或 ICE 候选。
基准测试
十个副本,每个占用 1 CPU 和 512 MB 内存,以每秒 10 万请求的压力持续 60 秒:
| 事件 | 完成数 | 失败数 | 可用率 |
|---|---|---|---|
| 加入房间 | 99991 | 9 | 99.991% |
| SDP offer | 99968 | 32 | 99.968% |
| SDP answer | 99982 | 18 | 99.982% |
| Trickle ICE | 99977 | 23 | 99.977% |
在负载下,每种事件类型的可用率都保持在 99.96% 以上,四种平均为 99.98%。信令在设计上就是轻量的:它只是在一条已建立的连接上传输小体积的 JSON 数据,而非媒体流,所以这类服务器的实际瓶颈几乎总是连接数和每连接的内存占用,而不是 CPU。
拓扑扩展
信令只负责建立连接。当两个或多个节点开始通话后,实际的媒体流量如何传输是另一个更难的问题,需要从三种拓扑中做出选择。
点对点(P2P)
最直接的方式是让每个节点与其余所有节点直接相连,即全网状(full mesh)结构。它完全不需要媒体服务器,成本低廉,且每路流都是端到端的;但连接数会按平方增长:n 个节点需要 n(n-1)/2 条连接。6 个节点意味着 15 条连接,每个节点既要把自己的流上传给其他 5 个节点,也要下载这 5 个节点的流。
按每路流 1 Mbps 计算,6 人网状通话中每个节点要上行 5 Mbps、下行 5 Mbps,仅为了维持自己的参与。正是这个开销让 mesh 在参与者稍多时就变得不切实际——这不是服务器的限制,而是每个客户端自己的上行带宽和 CPU 先扛不住了。
选择性转发单元(SFU)
SFU 把网状结构收敛成星形:每个节点只向中央服务器发送一次自己的流,服务器再把每路流转发给需要它的其他节点。无论通话规模多大,上传都从 (n-1) 条连接降到 1 条;但下载仍是 (n-1) 条,因为 SFU 仍需把其他每个人的流分别下发给每个节点。
总连接数从二次方降为线性,即 n 而非 n(n-1)/2,且单个节点的上传成本不再随通话人数增加。SFU 本身成了需要扩展的对象,但转发数据包远比解码再编码便宜,后者正是 MCU 所采用的代价高昂的方案。
多点会议单元(MCU)
MCU 更进一步:它在服务端解码每个对端的流,混合成一个综合流,重新编码后作为单一流向客户端发送。此时每个对点的上传和下载都只需 1 条连接。这是三种方案中带宽成本最低的。
这种带宽的降低是以实时转码为代价的。MCU 需要持续对每次通话中的每个参与者进行解码、混合和编码,其 CPU 占用乃至硬件编码器负载远高于 SFU。此外,MCU 在服务端固定了输出布局,客户端无法像接收 SFU 分开的流那样自由调整显示位置。
| 方案 | 上传 / 下载 | 带宽 | 连接数 |
|---|---|---|---|
| P2P | 5 Mbps / 5 Mbps | 30 Mbps | 15 |
| SFU | 1 Mbps / 5 Mbps | 12 Mbps | 6 |
| MCU | 1 Mbps / 1 Mbps | 12 Mbps | 6 |
对于大多数群组通话产品,SFU 胜出。它既保留了相对于 Mesh 拓扑的带宽优势,又保留了客户端对自身布局的控制权。MCU 仅在某些特定场景下能收回其高昂的成本,录制就是一个典型例子:当所有流已在服务端合并,将结果写入 S3 只需写一次文件,而无需在客户端侧将 n 个独立流进行合成。
规模化下其他关键要素
拓扑结构并非唯一在真实流量下出问题的维度。当生产流量涌入时,连通性、信令负载、浏览器支持、安全性和可靠性各自面临着独特的故障模式。
连通性
部分网络会直接阻断 STUN 和 TURN 流量,除非其表现得像普通的 HTTPS。通过在标准端口上以 TLS 方式运行 STUN 和 TURN,并将 STUN 服务器部署在靠近客户端的地理位置,可以提高直连成功的概率,减少退而求其次通过 TURN 中继转发的情况。
高负载下的信令
由于信令服务器不承载媒体流量,扩展它的过程就是常规的 Web 服务问题。一旦配置好粘性会话(sticky sessions),基于 CPU 和内存指标的 Horizontal Pod Autoscaler 就能像处理其他无状态服务一样轻松应对。
浏览器支持
旧版浏览器可能缺乏完整的 WebRTC 支持。使用 Adapter.js 这类 polyfill 库可以抹平不同浏览器版本间的 API 差异,避免在应用代码中针对每个浏览器单独分支处理。
安全性
WebRTC 默认加密媒体数据:DTLS 负责密钥交换,SRTP 加密音频和视频数据包。但这只是对等节点与中继之间的传输加密,并非用户间的端到端加密。
在信令通道上强制使用 HTTPS 和 WSS,要求 TURN 服务器进行身份认证,对信令 API 实施限流,并启用完美前向保密(PFS),可以弥补 DTLS/SRTP 本身无法覆盖的安全缺口。
可靠性
WebRTC 运行在 UDP 之上,存在丢包和乱序现象,且无重传机制。前向纠错(Forward Error Correction)在原数据流旁发送冗余数据,使接收方无需等待迟到的重传包,而是利用手头已有的冗余数据直接重构丢失的数据包——对于实时媒体而言,过晚到达的重传包本已失去意义。
后续步骤
本文介绍了 WebRTC 暴露的三种 API、信令与 NAT 穿透为何游离于规范之外、如何在高负载下对信令服务器进行基准测试,以及 Mesh、SFU 和 MCU 拓扑之间的带宽权衡。
这些内容并不局限于视频通话。任何基于 WebRTC 构建的产品——如屏幕共享、实时协作或云游戏——都会面临相同的信令、NAT 穿透和拓扑选择问题。
你还可以通过以下资源继续深入学习: