WebRTC 生产实战:信令架构、NAT穿透、SFU架构与10个常见陷阱
那次掉线40%用户的通话故障
"视频通话全挂了。"仪表盘显示40%的连接失败。4G用户、企业防火墙后面的用户、CGNAT地区的用户——全部连不上。我们的单台TURN服务器达到了瓶颈:10000个并发中继连接,每个2 Mbps,顶着20 Gbps的流量。内核在悄悄丢弃UDP包,因为接收缓冲区满了。WebRTC的demo在localhost没问题,但生产环境需要完全不同的认知。
我部署过四个WebRTC生产系统——从处理5000并发问诊的远程医疗平台,到支撑50000观众的直播服务。这是一份从零到一的实战笔记。
核心概念速查
| 概念 | 角色 | 关键机制 | 生产影响 |
|---|---|---|---|
| 信令 | 在媒体传输前交换会话元数据 | WebSocket/SSE/HTTP长轮询 | 重连、消息顺序、房间状态 |
| ICE | 交互式连接建立 | STUN + TURN 候选地址 | 85%直连P2P,15%需要中继 |
| STUN | 穿透NAT发现公网IP:Port | 查询公网STUN服务器 | 对对称型NAT无效 |
| TURN | 直连失败时的媒体中继 | 专用中继服务器 | 带宽消耗大,每流约500 kbps |
| SDP | 媒体能力协商 | 编解码器、格式、ICE候选地址 | Offer/Answer模型 |
| SFU | 选择性转发单元 | 路由流而不解码 | 多人通话带宽高效 |
| MCU | 多点控制单元 | 将所有流混合为一路 | CPU高,客户端带宽低 |
信令架构:看不见的骨架
信令是WebRTC最被低估的部分。规范故意不定义它,意味着每个团队都自己实现——多数都搞砸了。
WebSocket(双向信令):
┌──────────┐ ┌──────────────┐
│ 客户端 A │──ws://signaling:8080──▶│ 信令服务器 │◀──ws://signaling:8080──│ 客户端 B │
└──────────┘ └──────┬───────┘ └──────────┘
│
┌───────┴───────┐
│ 房间状态 │
│ Redis │
└───────────────┘
生产信令服务器(Node.js + Socket.IO)
// server/signaling.ts
import { Server } from "socket.io";
import { createAdapter } from "@socket.io/redis-adapter";
import { Redis } from "ioredis";
const io = new Server(3000, {
pingInterval: 10000,
pingTimeout: 5000,
connectTimeout: 10000,
});
// Redis适配器支持水平扩展
const pubClient = new Redis({ host: "redis" });
const subClient = pubClient.duplicate();
io.adapter(createAdapter(pubClient, subClient));
io.on("connection", (socket) => {
socket.on("join-room", async ({ roomId, userId }) => {
// 检查房间容量
const count = await redis.scard(`room:${roomId}:participants`);
if (count >= 12) {
socket.emit("error", { message: "房间已满" });
return;
}
socket.join(roomId);
await redis.sadd(`room:${roomId}:participants`, userId);
// 通知其他参与者
socket.to(roomId).emit("participant-joined", { userId });
// 发送现有参与者列表
const participants = await redis.smembers(`room:${roomId}:participants`);
socket.emit("room-state", { participants });
});
// 转发信令消息
socket.on("offer", ({ to, sdp }) => {
io.to(to).emit("offer", { from: socket.id, sdp });
});
socket.on("answer", ({ to, sdp }) => {
io.to(to).emit("answer", { from: socket.id, sdp });
});
socket.on("ice-candidate", ({ to, candidate }) => {
io.to(to).emit("ice-candidate", { from: socket.id, candidate });
});
socket.on("disconnect", async () => {
const rooms = Array.from(socket.rooms);
for (const roomId of rooms) {
if (roomId !== socket.id) {
await redis.srem(`room:${roomId}:participants`, socket.id);
socket.to(roomId).emit("participant-left", { userId: socket.id });
}
}
});
});
信令坑点
- 消息顺序很重要:如果ICE候选在SDP answer之前到达,浏览器会忽略它。需要实现缓冲:先排队候选地址,等remote description设置后刷新。
- 重连:Socket.IO处理传输层重连,但你需要应用层的信令状态恢复。把待处理操作存在Redis里,让新服务器实例可以恢复。
- 过期状态:参与者异常断连而不走正常离开流程,成员记录会残留。用TTL过期的Redis key管理房间成员。
NAT穿透:残酷的现实
NAT为什么阻断P2P
没NAT:
主机A (1.2.3.4:50000) ←──────────→ 主机B (5.6.7.8:60000)
直接UDP正常工作。
有NAT:
主机A (192.168.1.5:50000) ──NAT──▶ 公网 (1.2.3.4:12345)
主机B (10.0.0.3:60000) ──NAT──▶ 公网 (5.6.7.8:54321)
A发包到5.6.7.8:54321 → NAT B丢弃(没有映射记录)
NAT只允许来自内网主机最近联系过的地址的入站包。两台都在NAT后面的主机不能直接互通——ICE就是为这个而生的。
ICE过程逐步解析
1. 收集候选地址
└─ Host: 192.168.1.5:50000(本地,基本不可用)
└─ SRFLX(STUN): 1.2.3.4:12345(反射地址,NAT映射后的公网IP)
└─ RELAY(TURN): turn.example.com:3478(中继,永远可用)
2. 按优先级排序并交换
└─ 本地 < 反射 < 中继(优先级递增)
3. 连通性检查
└─ 逐对尝试候选地址
└─ 发送STUN binding请求验证可达性
└─ 第一个成功的对获选
4. 提名
└─ 选定最高优先级的成功候选对
└─ 媒体开始传输
NAT类型与其影响
| NAT类型 | P2P可能? | 需要TURN? | 普遍性 |
|---|---|---|---|
| 全锥形 | 是(用STUN) | 否 | 少见(家用路由) |
| 受限锥形 | 是(用STUN) | 否 | 常见 |
| 端口受限锥形 | 是(用STUN) | 否 | 非常常见 |
| 对称NAT | 否 | 是 | 企业、移动、CGNAT |
| CGNAT(运营商级) | 否 | 是 | 移动运营商 |
实践中,10-15%的P2P连接需要TURN中继。移动网络用户这个数字跳到25-30%。生产环境不能跳过TURN。
TURN服务器配置(coturn)
# /etc/coturn/turnserver.conf
listening-port=3478
tls-listening-port=5349
relay-ip=10.0.1.5
external-ip=203.0.113.5/10.0.1.5
lt-cred-mech
user=myapp:myapp_turn_secret
realm=myapp.com
max-bps=2000000 # 每会话2 Mbps
no-multicast-peers
no-loopback-peers
cert=/etc/letsencrypt/live/turn.myapp.com/fullchain.pem
pkey=/etc/letsencrypt/live/turn.myapp.com/privkey.pem
prometheus
TURN容量计算公式
TURN带宽需求 =
(峰值并发用户 × TURN使用率 × 每流码率 × 双向)
示例:10000并发用户,15% TURN率,1 Mbps视频
= 10000 × 0.15 × 1 × 2 = 3000 Mbps = 3 Gbps
服务器数 = 3000 Mbps / 1000 Mbps每台 = 最少3台TURN
SFU架构:超越P2P
超过3个人,P2P网格就崩溃了:
P2P Mesh(4人间):
A↔B, A↔C, A↔D, B↔C, B↔D, C↔D = 6条连接
每人给3人对等发送 = 上行3倍码率
P2P Mesh(8人间):
28条连接,每人给7人发送 = 上行7倍码率
手机在3-4路上行时就扛不住了
SFU(选择性转发单元):
8人 × 1条SFU连接 = 8条连接
每人上传一次,SFU转发给其他7人
上行:1倍码率。下行:7倍码率(下行带宽充足)
mediasoup SFU实现
// server/sfu.ts
import * as mediasoup from "mediasoup";
const worker = await mediasoup.createWorker({
logLevel: "warn",
rtcMinPort: 40000,
rtcMaxPort: 49999,
});
const router = await worker.createRouter({
mediaCodecs: [
{
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" },
},
{ kind: "audio", mimeType: "audio/opus", clockRate: 48000, channels: 2 },
],
});
// 参与者加入时
async function handleNewParticipant(transport, roomId) {
const producer = await transport.produce({
kind: "video",
rtpParameters: clientRtpParameters,
// 启用simulcast:3个质量层
encodings: [
{ rid: "h", maxBitrate: 1500000, scalabilityMode: "L1T3" }, // 高
{ rid: "m", maxBitrate: 500000, scalabilityMode: "L1T3" }, // 中
{ rid: "l", maxBitrate: 150000, scalabilityMode: "L1T3" }, // 低
],
});
}
Simulcast:一条流三种画质
上传端发送同一视频的三种不同码率版本。SFU根据每个接收端网络状况选择性转发最佳质量:
const pc = new RTCPeerConnection();
const stream = await navigator.mediaDevices.getUserMedia({
video: { width: 1280, height: 720 },
});
const transceiver = pc.addTransceiver(stream.getVideoTracks()[0], {
sendEncodings: [
{ rid: "h", maxBitrate: 1500_000, scaleResolutionDownBy: 1 }, // 720p
{ rid: "m", maxBitrate: 500_000, scaleResolutionDownBy: 2 }, // 360p
{ rid: "l", maxBitrate: 150_000, scaleResolutionDownBy: 4 }, // 180p
],
});
监控WebRTC统计
setInterval(async () => {
const stats = await pc.getStats();
for (const [_, report] of stats) {
if (report.type === "remote-inbound-rtp" && report.kind === "video") {
console.log({
packetLoss: report.fractionLost * 100 + "%",
jitter: report.jitter * 1000 + "ms",
bitrate: report.bitrate / 1000 + "kbps",
});
}
}
}, 2000);
10个常见生产陷阱
陷阱1:没有TURN服务器
"需要的时候再加TURN。" 10-15%的用户会连不上。移动端25-30%。从第一天就为TURN带宽做预算。
陷阱2:单台TURN
一台 = 单点故障。至少在不同区域部署2台TURN。
陷阱3:忽略ICE候选滴漏
别等所有候选地址都就绪。每发现一个就发送:
pc.onicecandidate = ({ candidate }) => {
if (candidate) signaling.send({ type: "ice-candidate", candidate });
};
陷阱4:getUserMedia不加约束
// ❌ 这可能请求手机的4K摄像头
const stream = await navigator.mediaDevices.getUserMedia({ video: true });
// ✅ 指定合理约束
const stream = await navigator.mediaDevices.getUserMedia({
video: {
width: { ideal: 1280 }, height: { ideal: 720 },
frameRate: { ideal: 30 }, facingMode: "user",
},
audio: { echoCancellation: true, noiseSuppression: true },
});
陷阱5:不处理getUserMedia错误
try {
const stream = await navigator.mediaDevices.getUserMedia(constraints);
} catch (err) {
switch (err.name) {
case "NotAllowedError": showError("摄像头/麦克风权限被拒绝,请在浏览器设置中开启"); break;
case "NotFoundError": showError("未找到摄像头或麦克风"); break;
case "NotReadableError": showError("摄像头/麦克风正被其他应用占用"); break;
}
}
陷阱6:通话中网络切换
WiFi切4G。ICE连接断开。解决方案:ICE重启。
pc.oniceconnectionstatechange = () => {
if (pc.iceConnectionState === "disconnected") {
setTimeout(async () => {
if (pc.iceConnectionState === "disconnected") {
pc.restartIce();
const offer = await pc.createOffer({ iceRestart: true });
await pc.setLocalDescription(offer);
signaling.send({ type: "offer", sdp: pc.localDescription });
}
}, 5000);
}
};
陷阱7:浏览器兼容性差异
| 浏览器 | 已知问题 | 解决方案 |
|---|---|---|
| Chrome | VP9 simulcast有时丢层 | Simulcast用VP8或H264 |
| Firefox | H264不支持simulcast | 多人通话用VP8 |
| Safari | getUserMedia必须有用户手势 | 放在click处理器里 |
| Safari iOS | 一次只能播一路视频 | 缩略图用canvas |
| Chrome Android | 部分设备限制720p | 显式请求720p |
陷阱8:不监控WebRTC统计
没有统计就是瞎搞。把关键指标导出到监控系统。
陷阱9:码率过高
默认WebRTC可以在720p推到2.5 Mbps。弱网下导致拥塞。限制它:
const sender = pc.getSenders().find(s => s.track?.kind === "video");
const params = sender.getParameters();
params.encodings[0].maxBitrate = 1000_000; // 上限1 Mbps
await sender.setParameters(params);
陷阱10:忘记清理
僵尸PeerConnection泄露内存。每次都正确关闭:
function hangup() {
pc.getSenders().forEach(s => pc.removeTrack(s));
localStream.getTracks().forEach(t => t.stop());
pc.close();
pc = null;
}
监控仪表盘指标
| 指标 | 含义 | 健康范围 | 告警阈值 |
|---|---|---|---|
| ICE连接时间 | 建立连接耗时 | 1-3秒 | > 10秒 |
| 丢包率 | 丢包百分比 | 0-2% | > 5% |
| 抖动 | 包到达间隔方差 | 0-30ms | > 50ms |
| RTT | 往返时延 | 20-80ms | > 300ms |
| 帧率 | 视频每秒帧数 | 25-30 | < 15 |
| 码率 | 当前视频码率 | 500-1500 kbps | < 100 kbps |
| TURN使用率 | 走中继连接占比 | 10-20% | > 30% |
总结:WebRTC生产部署是20%媒体和80%基础设施。三条最致命的问题:不部署TURN(10-30%连接失败)、不处理网络切换(ICE重启是必须的)、所有东西都跑单机(TURN、信令、SFU各自需要独立的扩展策略)。从第一天就建监控——WebRTC的问题没统计就是隐形的。给TURN带宽做预算,在真实移动网络上测试,永远别信在localhost能跑的demo。
在线工具
- JSON 格式化 — 调试信令消息载荷和SDP会话描述
- Base64 编解码 — 处理base64编码的ICE候选地址和证书
- 哈希计算器 — 为长期认证生成TURN凭证哈希
本站提供浏览器本地工具,免注册即可试用 →