WebRTC 架构与信令设计

从整体架构切入 WebRTC:拆解信令面、媒体面与数据面三条通道,解释标准为何只定义 API 而不定义信令传输,给出基于 WebSocket 的最小可用信令服务实现,梳理房间状态、幂等重连与鉴权的工程要点,并附常见坑与选型权衡,帮助读者从零搭建可扩展的信令层。

WebRTC 是浏览器原生提供的实时通信能力集合,它把音视频采集、编解码、NAT 穿透、加密传输这一整套复杂机制收敛到少数几个 JavaScript 对象里。但第一次接触的人常有一个误解:以为引入 WebRTC 就等于拥有了完整的通话系统。事实恰恰相反,WebRTC 只解决了端到端媒体与数据的传输,而把「两端如何互相发现、如何交换参数」这件最关键的事留给了应用自己。

本文从架构分层出发,讲清楚信令面、媒体面、数据面各自负责什么,为什么 W3C 标准刻意不定义信令协议,以及如何用最少的代码搭出一个能跑通双人通话的信令服务。读完你应该能独立设计一个可扩展的信令层,而不是只会抄一段 RTCPeerConnection 的示例。

一、WebRTC 的整体架构

1.1 三条相互独立的通道

一个完整的实时通信会话在逻辑上由三条通道构成,它们使用的协议、传输路径和故障表现完全不同:

通道承载内容典型协议是否由标准定义
信令面SDP、ICE 候选、房间事件自定义(WebSocket / HTTPS / SIP)否,应用自定义
媒体面音视频 RTP 包SRTP over UDP(DTLS 握手)是,RFC 8825 系列
数据面任意二进制 / 文本SCTP over DTLS over UDP是,RFC 8831

信令面是「控制平面」,媒体面与数据面是「数据平面」。控制平面只在会话建立与变更时使用,数据平面则持续传输。理解了这层分离,就能理解为什么信令服务器宕机后已建立的通话仍能继续,也能理解为什么信令通道的性能几乎不影响通话质量。

1.2 浏览器内的核心对象

浏览器提供了一组职责清晰的对象,它们的生命周期与上面三条通道对应:

  • RTCPeerConnection:媒体面的核心,管理 ICE、DTLS、SRTP 状态机,负责创建 Offer/Answer、收集候选、收发轨道。
  • MediaStream 与 MediaStreamTrack:媒体流的容器与单条轨道,来自 getUserMedia 或远端。
  • RTCDataChannel:数据面的句柄,基于 SCTP,可配置可靠性与有序性。
  • RTCIceCandidate、RTCSessionDescription:信令层需要搬运的数据结构。

关键点在于:RTCPeerConnection 完全不知道信令如何传输。它只是通过 createOffer、setLocalDescription 等 API 产出或消费 SDP,通过 onicecandidate 吐出候选。把这些对象串起来的那根线,就是应用要自己写的信令。

1.3 为什么信令必须自己写

标准不定义信令有三个现实原因。第一,信令语义高度依赖业务:一对一通话只需要转发两个 SDP,而一个 500 人的直播房间需要房间管理、权限、混流协商、订阅关系,这些无法用一套通用协议覆盖。第二,信令往往需要与既有系统集成,比如复用已有的 HTTP 鉴权、复用 IM 的会话通道。第三,信令传输方式应自由选择,可以走 WebSocket、可以走 HTTP 轮询、甚至可以通过二维码线下传递。

二、信令必须携带什么

一个最小可用的信令协议,其消息类型可以归纳为下表。注意每条消息都必须是「可序列化、可转发、可持久化」的纯数据。

消息类型方向关键载荷说明
join客户端到服务端roomId、token加入房间
peers服务端到客户端已在房间的 peerId 列表决定谁发起 Offer
offer双向sdp、targetPeerId会话描述
answer双向sdp、targetPeerId会话描述
candidate双向candidate、sdpMid、sdpMLineIndexICE 候选
bye双向peerId主动离开
error服务端到客户端code、message错误反馈

其中 sdpMid 与 sdpMLineIndex 是候选消息的必填字段,它们把候选与 SDP 中某一条 m-line 绑定。很多「候选收到了但连不上」的问题,根源就是这两个字段被丢弃或错位。

三、一个最小可用的信令服务

下面的 Node.js 实现省略了鉴权与持久化,只保留房间转发逻辑,但它已经能支撑双人通话。核心思想是:服务端不做任何 SDP 解析,只按房间广播。

// signaling-server.js
import { WebSocketServer } from "ws";

const rooms = new Map(); // roomId -> Map<peerId, WebSocket>

const wss = new WebSocketServer({ port: 8080 });

function broadcast(roomId, senderId, payload) {
  const room = rooms.get(roomId);
  if (!room) return;
  for (const [peerId, ws] of room) {
    if (peerId === senderId) continue;
    if (ws.readyState === ws.OPEN) {
      ws.send(JSON.stringify(payload));
    }
  }
}

wss.on("connection", (ws) => {
  let joined = null;

  ws.on("message", (raw) => {
    const msg = JSON.parse(raw.toString());

    if (msg.type === "join") {
      const { roomId, peerId } = msg;
      joined = { roomId, peerId };
      if (!rooms.has(roomId)) rooms.set(roomId, new Map());
      const room = rooms.get(roomId);

      // 先把已有成员告诉新成员,由新成员决定向谁发 Offer
      ws.send(JSON.stringify({
        type: "peers",
        peers: [...room.keys()],
      }));

      room.set(peerId, ws);
      broadcast(roomId, peerId, { type: "peer-joined", peerId });
      return;
    }

    // offer / answer / candidate 一律原样转发
    if (joined && ["offer", "answer", "candidate", "bye"].includes(msg.type)) {
      broadcast(joined.roomId, joined.peerId, { ...msg, from: joined.peerId });
    }
  });

  ws.on("close", () => {
    if (!joined) return;
    const room = rooms.get(joined.roomId);
    if (room) {
      room.delete(joined.peerId);
      if (room.size === 0) rooms.delete(joined.roomId);
    }
    broadcast(joined.roomId, joined.peerId, { type: "peer-left", peerId: joined.peerId });
  });
});

浏览器侧的接线同样克制。下面的客户端把「谁是发起方」交给房间成员列表决定:新加入者向每个已存在的 peer 发 Offer。

// client.js
const ws = new WebSocket("wss://example.com/signaling");
const pc = new RTCPeerConnection({
  iceServers: [{ urls: "stun:stun.example.com:3478" }],
});
const peers = new Map(); // peerId -> RTCPeerConnection

ws.addEventListener("message", async (event) => {
  const msg = JSON.parse(event.data);

  if (msg.type === "peers") {
    // 我后加入,主动向每个已有成员发起
    for (const peerId of msg.peers) {
      await createOfferTo(peerId);
    }
    return;
  }

  if (msg.type === "offer") {
    const remote = getOrCreatePeer(msg.from);
    await remote.setRemoteDescription(msg.sdp);
    const answer = await remote.createAnswer();
    await remote.setLocalDescription(answer);
    ws.send(JSON.stringify({ type: "answer", targetPeerId: msg.from, sdp: answer }));
    return;
  }

  if (msg.type === "answer") {
    await peers.get(msg.from).setRemoteDescription(msg.sdp);
    return;
  }

  if (msg.type === "candidate") {
    const remote = peers.get(msg.from);
    await remote.addIceCandidate({
      candidate: msg.candidate,
      sdpMid: msg.sdpMid,
      sdpMLineIndex: msg.sdpMLineIndex,
    });
  }
});

async function createOfferTo(peerId) {
  const remote = getOrCreatePeer(peerId);
  const offer = await remote.createOffer();
  await remote.setLocalDescription(offer);
  ws.send(JSON.stringify({ type: "offer", targetPeerId: peerId, sdp: offer }));
}

function getOrCreatePeer(peerId) {
  if (peers.has(peerId)) return peers.get(peerId);
  const remote = new RTCPeerConnection({
    iceServers: [{ urls: "stun:stun.example.com:3478" }],
  });

  remote.onicecandidate = ({ candidate }) => {
    if (!candidate) return; // null 表示候选收集结束
    ws.send(JSON.stringify({
      type: "candidate",
      targetPeerId: peerId,
      candidate: candidate.candidate,
      sdpMid: candidate.sdpMid,
      sdpMLineIndex: candidate.sdpMLineIndex,
    }));
  };

  remote.ontrack = ({ streams }) => {
    document.querySelector("#remoteVideo").srcObject = streams[0];
  };

  peers.set(peerId, remote);
  return remote;
}

本地媒体需要在创建 Offer 前挂到 RTCPeerConnection 上,否则 SDP 里不会出现音视频 m-line:

const localStream = await navigator.mediaDevices.getUserMedia({ audio: true, video: true });
localStream.getTracks().forEach((track) => pc.addTrack(track, localStream));

四、连接建立的完整时序

把上面的代码展开成时序,一次典型的双人通话经历如下阶段:

  1. A 与 B 通过信令加入同一房间,服务端告知双方彼此存在。
  2. A 调用 createOffer 生成 SDP,setLocalDescription 后触发 ICE 候选收集。
  3. A 通过信令把 Offer 发给 B。
  4. B setRemoteDescription(offer),createAnswer,setLocalDescription(answer),回发 Answer。
  5. 双方持续通过 onicecandidate 交换候选,ICE 层做连通性检查,选出可用候选对。
  6. ICE 成功后进入 DTLS 握手,再建立 SRTP 密钥。
  7. ontrack 触发,远端媒体开始播放。

注意第 2 步与第 5 步是并行的:现代实现默认开启 Trickle ICE,候选会随收集随发送,不必等候选全部收集完再发 Offer,这能显著缩短建连时间。ICE 与 SDP 的细节分别见 ICE / STUN / TURN 与 NAT 穿透原理 与 SDP 协商与 Offer/Answer 流程 。

五、信令的工程化设计

5.1 房间与状态管理

生产环境不能把房间状态只放在单个进程的内存里。常见的做法是用 Redis 维护「房间到成员」的映射,并通过发布订阅让多实例的信令服务协同。当信令服务扩容到多节点时,同一个房间的两个 peer 可能连到不同实例,此时必须依赖一个共享的消息总线转发,否则会出现「双方都在房间却收不到对方消息」。

5.2 幂等与重连

网络抖动导致 WebSocket 断开是常态。信令设计必须回答三个问题:

  • 重连后 peerId 是否保持稳定,否则对端无法识别这是同一个人。
  • 未送达的 SDP / 候选是否需要补发,尤其是候选,丢失会导致 ICE 卡在 checking。
  • 旧连接上的会话如何优雅清理,避免僵尸 peer 占着房间名额。

实践中给每个客户端分配一个稳定的 sessionId,重连时用同一个 sessionId 重新 join,服务端据此替换旧连接并广播 peer-rejoined。

5.3 鉴权与安全

信令通道往往携带敏感信息,必须加密(wss://)并鉴权。推荐的做法是业务系统先签发一个短期 token,客户端连接时携带,服务端在 join 之前校验。不要依赖客户端上报的 peerId 作为身份,因为它是可伪造的;身份应由服务端从 token 推导。

六、Glare 与完美协商模式

6.1 Glare 问题的由来

当房间里的两个 peer 几乎同时发起 Offer 时,就会撞上所谓的 glare:双方都处于 have-local-offer 状态,却各自收到了对方的 Offer。此时谁先执行 setRemoteDescription 谁就可能报错,因为信令状态机不允许在已有本地 Offer 的情况下直接接受远端 Offer。

这是多人房间与重连场景的高频故障。最初的粗暴解法是「规定只有先进房间的人才能发起」,但这在重连、成员动态变化时非常脆弱。

6.2 polite 与 impolite 角色

RFC 8829 的完美协商(Perfect Negotiation)模式给出了一套通用解法:为每一对连接预先约定一个 polite 方与一个 impolite 方。规则很简单:

  • 当发生冲突(收到 Offer 时自己已有本地 Offer)时,polite 方回滚自己的 Offer 并接受对方;impolite 方忽略对方的 Offer。
  • 冲突判定依赖 makingOffer 标志与 signalingState,而不是靠时间戳比较。
let polite = false;          // 由 peerId 字典序或房间角色决定
let makingOffer = false;
let ignoreOffer = false;

pc.onnegotiationneeded = async () => {
  try {
    makingOffer = true;
    await pc.setLocalDescription();
    signaling.send({ type: "offer", sdp: pc.localDescription });
  } finally {
    makingOffer = false;
  }
};

async function onRemoteOffer(sdp) {
  const offerCollision =
    sdp.type === "offer" && (makingOffer || pc.signalingState !== "stable");
  ignoreOffer = !polite && offerCollision;
  if (ignoreOffer) return;

  await pc.setRemoteDescription(sdp);
  await pc.setLocalDescription();
  signaling.send({ type: "answer", sdp: pc.localDescription });
}

注意 setLocalDescription() 不传参数时会自动生成与当前状态匹配的描述(Offer 或 Answer),这比手工调用 createOffer / createAnswer 更不容易出错。完美协商是生产代码的默认写法,不要用「谁先谁发起」的土办法。

6.3 角色分配的原则

polite 与 impolite 必须在一对连接内互斥且稳定。常见做法是比较两个 peerId 的字符串大小,小的一方为 polite;或者由服务端在分配房间成员时直接下发角色。无论哪种方式,角色都不能在会话中途翻转,否则冲突处理会陷入死循环。

七、数据通道:RTCDataChannel

媒体之外,WebRTC 还能通过 RTCDataChannel 传输任意数据。它基于 SCTP over DTLS,可以在可靠与不可靠之间自由配置,这让它同时适用于文件传输和游戏状态同步两类完全不同的需求。

7.1 可靠性与有序性配置

配置项取值适用场景
orderedtrue / false有序适合文本与指令,无序适合实时状态
maxRetransmits数字允许重传的最大次数,超过则丢弃
maxPacketLifeTime毫秒超过时限的包不再重传
protocol字符串应用层自定义子协议名

maxRetransmits 与 maxPacketLifeTime 互斥,只能设置其一。二者都不设置时默认是完全可靠且有序,语义等价于 TCP。

const pc = new RTCPeerConnection();

// 发起方创建通道
const channel = pc.createDataChannel("state", {
  ordered: false,          // 状态更新允许乱序
  maxRetransmits: 0,       // 不重传,丢包即放弃
});
channel.onopen = () => channel.send(JSON.stringify({ x: 120, y: 45 }));

// 接收方监听
pc.ondatachannel = (event) => {
  const recv = event.channel;
  recv.onmessage = (e) => console.log("收到:", e.data);
};

7.2 与 WebSocket 的取舍

数据通道的最大优势是端到端直连,不经过服务器,因此延迟更低、服务器成本更低。但它的前提是已经建立了 RTCPeerConnection,而建立连接本身需要信令。所以典型用法是:用信令通道完成首次握手,之后把高频、低价值的数据(光标位置、游戏输入)迁移到数据通道上。需要广播给多个人的场景,则应继续使用 WebSocket 或 SSE 方案。

八、常见坑清单

  • 忘记在 createOffer 之前 addTrack,导致 SDP 里没有媒体 m-line,对端 ontrack 永远不触发。
  • 把 onicecandidate 抛出的 null 也当候选发出去,对端解析失败。
  • 丢弃候选的 sdpMid / sdpMLineIndex,候选无法与 m-line 关联。
  • 在 setRemoteDescription 之前就 addIceCandidate,导致候选被静默丢弃(可用候选队列缓冲解决)。
  • 多个 peer 复用同一个 RTCPeerConnection,SDP 与候选互相污染。
  • 忘记处理 iceConnectionState 变为 failed 时触发 ICE 重启(createOffer({ iceRestart: true }))。
  • 信令消息不做大小限制与频率限制,被恶意客户端拖垮。

九、权衡与选型

信令传输方式的选择取决于场景。WebSocket 适合低延迟双向信令,是绝大多数场景的首选;HTTP 长轮询实现简单但在移动端耗电与延迟都差;SIP 适合与运营商电话网互通,但复杂度陡增。媒体面若需要多人通话,还需要引入 SFU,详见 SFU 与 MCU 架构选型 。

一个务实的判断标准是:如果同时在线人数不超过几人、且已有 WebSocket 基础设施,直接用 WebSocket 自建信令最省事;如果预期房间规模与并发量会增长,应尽早把信令设计成「无状态实例 + 共享状态存储」的形态,避免后期重构。

小结

WebRTC 把最难的媒体传输标准化了,却把信令留给了应用,这是它最容易被低估的设计。理解信令面、媒体面、数据面的分离,是设计实时通信系统的第一步。一个可靠的信令层需要具备稳定身份、幂等重连、候选补发与鉴权能力,而实现它的代码量其实很小。真正的复杂度在状态管理与规模扩展上,这也是后续文章会持续展开的方向。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「实时通信」更多文章

  1. 实时通信的端到端测试与压测
  2. 信令服务与房间水平扩展
  3. 低延迟直播:LL-HLS 与 WebRTC 直播