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 });
      }
    }
  });
});

信令坑点

  1. 消息顺序很重要:如果ICE候选在SDP answer之前到达,浏览器会忽略它。需要实现缓冲:先排队候选地址,等remote description设置后刷新。
  2. 重连:Socket.IO处理传输层重连,但你需要应用层的信令状态恢复。把待处理操作存在Redis里,让新服务器实例可以恢复。
  3. 过期状态:参与者异常断连而不走正常离开流程,成员记录会残留。用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。


在线工具

本站提供浏览器本地工具,免注册即可试用 →

#WebRTC#实时通信#STUN#TURN#SFU#音视频#2026